diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..95697c9 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: [mkb79] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1c2055f..f35811f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,10 +9,12 @@ jobs: createrelease: name: Create Release - runs-on: [ubuntu-latest] + runs-on: ubuntu-latest + outputs: + release_url: ${{ steps.create-release.outputs.upload_url }} steps: - name: Create Release - id: create_release + id: create-release uses: actions/create-release@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -21,13 +23,6 @@ jobs: release_name: Release ${{ github.ref }} draft: false prerelease: false - - name: Output Release URL File - run: echo "${{ steps.create_release.outputs.upload_url }}" > release_url.txt - - name: Save Release URL File for publish - uses: actions/upload-artifact@v2 - with: - name: release_url - path: release_url.txt build: name: Build packages @@ -44,13 +39,13 @@ jobs: zip -r9 audible_linux_ubuntu_latest audible OUT_FILE_NAME: audible_linux_ubuntu_latest.zip ASSET_MIME: application/zip # application/octet-stream - - os: ubuntu-18.04 + - os: ubuntu-20.04 TARGET: linux CMD_BUILD: > pyinstaller --clean -F --hidden-import audible_cli -n audible -c pyi_entrypoint.py && cd dist/ && - zip -r9 audible_linux_ubuntu_18_04 audible - OUT_FILE_NAME: audible_linux_ubuntu_18_04.zip + zip -r9 audible_linux_ubuntu_20_04 audible + OUT_FILE_NAME: audible_linux_ubuntu_20_04.zip ASSET_MIME: application/zip # application/octet-stream - os: macos-latest TARGET: macos @@ -85,34 +80,23 @@ jobs: OUT_FILE_NAME: audible_win.zip ASSET_MIME: application/zip steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.8 - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: '3.8' + python-version: 3.11 - name: Install dependencies run: | python -m pip install --upgrade pip .[pyi] && pip list - name: Build with pyinstaller for ${{matrix.TARGET}} run: ${{matrix.CMD_BUILD}} - - name: Load Release URL File from release job - uses: actions/download-artifact@v2 - with: - name: release_url - path: release_url - - name: Get Release File Name & Upload URL - id: get_release_info - shell: bash - run: | - value=`cat release_url/release_url.txt` - echo ::set-output name=upload_url::$value - name: Upload Release Asset id: upload-release-asset uses: actions/upload-release-asset@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - upload_url: ${{ steps.get_release_info.outputs.upload_url }} + upload_url: ${{ needs.createrelease.outputs.release_url }} asset_path: ./dist/${{ matrix.OUT_FILE_NAME}} asset_name: ${{ matrix.OUT_FILE_NAME}} asset_content_type: ${{ matrix.ASSET_MIME}} diff --git a/.github/workflows/pypi-publish-test.yml b/.github/workflows/pypi-publish-test.yml index fa0b3f0..8da74a7 100644 --- a/.github/workflows/pypi-publish-test.yml +++ b/.github/workflows/pypi-publish-test.yml @@ -6,20 +6,20 @@ on: jobs: build-n-publish: name: Build and publish Audible-cli to TestPyPI - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - name: Set up Python 3.9 - uses: actions/setup-python@v1 + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.11 - name: Install setuptools and wheel run: pip install --upgrade pip setuptools wheel - name: Build a binary wheel and a source tarball run: python setup.py sdist bdist_wheel - name: Publish distribution to Test PyPI - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository_url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 425ddb2..d1e6486 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -6,19 +6,19 @@ on: jobs: build-n-publish: name: Build and publish Audible-cli to PyPI - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - name: Set up Python 3.9 - uses: actions/setup-python@v1 + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: 3.11 - name: Install setuptools and wheel run: pip install --upgrade pip setuptools wheel - name: Build a binary wheel and a source tarball run: python setup.py sdist bdist_wheel - name: Publish distribution to PyPI - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ce7fa39..1f51b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,179 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## Unreleased -- +### Bugfix + +- Fixing `[Errno 18] Invalid cross-device link` when downloading files using the `--output-dir` option. This error is fixed by creating the resume file on the same location as the target file. + +### Added + +- The `--chapter-type` option is added to the download command. Chapter can now be + downloaded as `flat` or `tree` type. `tree` is the default. A default chapter type + can be set in the config file. + +### Changed + +- Improved podcast ignore feature in download command +- make `--ignore-podcasts` and `--resolve-podcasts` options of download command mutual + exclusive +- Switched from a HEAD to a GET request without loading the body in the downloader + class. This change improves the program's speed, as the HEAD request was taking + considerably longer than a GET request on some Audible pages. +- `models.LibraryItem.get_content_metadatata` now accept a `chapter_type` argument. + Additional keyword arguments to this method are now passed through the metadata + request. +- Update httpx version range to >=0.23.3 and <0.28.0. +- fix typo from `resolve_podcats` to `resolve_podcasts` +- `models.Library.resolve_podcats` is now deprecated and will be removed in a future version + +## [0.3.1] - 2024-03-19 + +### Bugfix + +- fix a `TypeError` on some Python versions when calling `importlib.metadata.entry_points` with group argument + +## [0.3.0] - 2024-03-19 + +### Added + +- Added a resume feature when downloading aaxc files. +- New `downlaoder` module which contains a rework of the Downloader class. +- If necessary, large audiobooks are now downloaded in parts. +- Plugin command help page now contains additional information about the source of + the plugin. +- Command help text now starts with ´(P)` for plugin commands. + +### Changed + +- Rework plugin module +- using importlib.metadata over setuptools (pkg_resources) to get entrypoints + +## [0.2.6] - 2023-11-16 + +### Added + +- Update marketplace choices in `manage auth-file add` command. Now all available marketplaces are listed. + +### Bugfix + +- Avoid tqdm progress bar interruption by logger’s output to console. +- Fixing an issue with unawaited coroutines when the download command exited abnormal. + +### Changed + +- Update httpx version range to >=0.23.3 and <0.26.0. + +### Misc + +- add `freeze_support` to pyinstaller entry script (#78) + +## [0.2.5] - 2023-09-26 + +### Added + +- Dynamically load available marketplaces from the `audible package`. Allows to implement a new marketplace without updating `audible-cli`. + +## [0.2.4] - 2022-09-21 + +### Added + +- Allow download multiple cover sizes at once. Each cover size must be provided with the `--cover-size` option + + +### Changed + +- Rework start_date and end_date option + +### Bugfix + +- In some cases, the purchase date is None. This results in an exception. Now check for purchase date or date added and skip, if date is missing + +## [0.2.3] - 2022-09-06 + +### Added + +- `--start-date` and `--end-date` option to `download` command +- `--start-date` and `--end-date` option to `library export` and `library list` command +- better error handling for license requests +- verify that a download link is valid +- make sure an item is published before downloading the aax, aaxc or pdf file +- `--ignore-errors` flag of the download command now continue, if an item failed to download + +## [0.2.2] - 2022-08-09 + +### Bugfix + +- PDFs could not be found using the download command (#112) + +## [0.2.1] - 2022-07-29 + +### Added + +- `library` command now outputs the `extended_product_description` field + +### Changed + +- by default a licenserequest (voucher) will not include chapter information by default +- moved licenserequest part from `models.LibraryItem.get_aaxc_url` to its own `models.LibraryItem.get_license` function +- allow book titles with hyphens (#96) +- if there is no title fallback to an empty string (#98) +- reduce `response_groups` for the download command to speed up fetching the library (#109) + +### Fixed + +- `Extreme` quality is not supported by the Audible API anymore (#107) +- download command continued execution after error (#104) +- Currently, paths with dots will break the decryption (#97) +- `models.Library.from_api_full_sync` called `models.Library.from_api` with incorrect keyword arguments + +### Misc + +- reworked `cmd_remove-encryption` plugin command (e.g. support nested chapters, use chapter file for aaxc files) +- added explanation in README.md for creating a second profile + +## [0.2.0] - 2022-06-01 + +### 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` subcommands 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` +- `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.2 to fix a bug in httpx +- 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 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 + +### Bugfix + +- fix a bug with the registration url ## [0.1.2] - 2022-03-27 @@ -19,7 +191,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 diff --git a/README.md b/README.md index cf3132a..5b6a0f9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ It depends on the following packages: * aiofiles * audible * click -* colorama (on windows machines) +* colorama (on Windows machines) * httpx * Pillow * tabulate @@ -30,7 +30,7 @@ pip install audible-cli ``` -or install it directly from github with +or install it directly from GitHub with ```shell @@ -40,18 +40,25 @@ pip install . ``` +or as the best solution using [pipx](https://pipx.pypa.io/stable/) + +```shell + +pipx install audible-cli + +``` + ## Standalone executables If you don't want to install `Python` and `audible-cli` on your machine, you can find standalone exe files below or on the [releases](https://github.com/mkb79/audible-cli/releases) -page. At this moment Windows, Linux and MacOS are supported. +page (including beta releases). At this moment Windows, Linux and macOS are supported. ### Links 1. Linux - - [debian 11 onefile](https://github.com/mkb79/audible-cli/releases/latest/download/audible_linux_debian_11.zip) - [ubuntu latest onefile](https://github.com/mkb79/audible-cli/releases/latest/download/audible_linux_ubuntu_latest.zip) - - [ubuntu 18.04 onefile](https://github.com/mkb79/audible-cli/releases/latest/download/audible_linux_ubuntu_18_04.zip) + - [ubuntu 20.04 onefile](https://github.com/mkb79/audible-cli/releases/latest/download/audible_linux_ubuntu_20_04.zip) 2. macOS - [macOS latest onefile](https://github.com/mkb79/audible-cli/releases/latest/download/audible_mac.zip) @@ -82,7 +89,7 @@ pyinstaller --clean -D --hidden-import audible_cli -n audible -c pyi_entrypoint ### Hints -There are some limitations when using plugins. The binarys maybe does not contain +There are some limitations when using plugins. The binary maybe does not contain all the dependencies from your plugin script. ## Tab Completion @@ -103,7 +110,7 @@ as config dir. Otherwise, it will use a folder depending on the operating system. | OS | Path | -| --- | --- | +|----------|-------------------------------------------| | Windows | ``C:\Users\\AppData\Local\audible`` | | Unix | ``~/.audible`` | | Mac OS X | ``~/.audible`` | @@ -147,7 +154,11 @@ The APP section supports the following options: - primary_profile: The profile to use, if no other is specified - filename_mode: When using the `download` command, a filename mode can be specified here. If not present, "ascii" will be used as default. To override - these option, you can provide a mode with the `filename-mode` option of the + these option, you can provide a mode with the `--filename-mode` option of the + download command. +- chapter_type: When using the `download` command, a chapter type can be specified + here. If not present, "tree" will be used as default. To override + these option, you can provide a type with the `--chapter-type` option of the download command. #### Profile section @@ -155,6 +166,7 @@ The APP section supports the following options: - auth_file: The auth file for this profile - country_code: The marketplace for this profile - filename_mode: See APP section above. Will override the option in APP section. +- chapter_type: See APP section above. Will override the option in APP section. ## Getting started @@ -162,6 +174,14 @@ Use the `audible-quickstart` or `audible quickstart` command in your shell to create your first config, profile and auth file. `audible-quickstart` runs on the interactive mode, so you have to answer multiple questions to finish. +If you have used `audible quickstart` and want to add a second profile, you need to first create a new authfile and then update your config.toml file. + +So the correct order is: + + 1. add a new auth file using your second account using `audible manage auth-file add` + 2. add a new profile to your config and use the second auth file using `audible manage profile add` + + ## Commands Call `audible -h` to show the help and a list of all available subcommands. You can show the help for each subcommand like so: `audible -h`. If a subcommand has another subcommands, you csn do it the same way. @@ -188,6 +208,19 @@ At this time, there the following buildin subcommands: - `wishlist` - `export` - `list` + - `add` + - `remove` + +## Example Usage + +To download all of your audiobooks in the aaxc format use: +```shell +audible download --all --aaxc +``` +To download all of your audiobooks after the Date 2022-07-21 in aax format use: +```shell +audible download --start-date "2022-07-21" --aax --all +``` ## Verbosity option @@ -199,9 +232,9 @@ There are 6 different verbosity levels: - error - critical -By default the verbosity level is set to `info`. You can provide another level like so: `audible -v ...`. +By default, the verbosity level is set to `info`. You can provide another level like so: `audible -v ...`. -If you use the `download` sudcommand with the `--all` flag there will be a huge output. Best practise is to set the verbosity level to `error` with `audible -v error download --all ...` +If you use the `download` subcommand with the `--all` flag there will be a huge output. Best practise is to set the verbosity level to `error` with `audible -v error download --all ...` ## Plugins @@ -217,13 +250,13 @@ You can provide own subcommands and execute them with `audible SUBCOMMAND`. All plugin commands must be placed in the plugin folder. Every subcommand must have his own file. Every file have to be named ``cmd_{SUBCOMMAND}.py``. Each subcommand file must have a function called `cli` as entrypoint. -This function have to be decorated with ``@click.group(name="GROUP_NAME")`` or +This function has to be decorated with ``@click.group(name="GROUP_NAME")`` or ``@click.command(name="GROUP_NAME")``. Relative imports in the command files doesn't work. So you have to work with absolute imports. Please take care about this. If you have any issues with absolute imports please add your plugin path to the `PYTHONPATH` variable or -add this lines of code to the begining of your command script: +add this lines of code to the beginning of your command script: ```python import sys @@ -239,7 +272,7 @@ Examples can be found If you want to develop a complete plugin package for ``audible-cli`` you can do this on an easy way. You only need to register your sub-commands or -sub-groups to an entry-point in your setup.py that is loaded by the core +subgroups to an entry-point in your setup.py that is loaded by the core package. Example for a setup.py diff --git a/plugin_cmds/cmd_decrypt.py b/plugin_cmds/cmd_decrypt.py new file mode 100644 index 0000000..8a09304 --- /dev/null +++ b/plugin_cmds/cmd_decrypt.py @@ -0,0 +1,683 @@ +"""Removes encryption of aax and aaxc files. + +This is a proof-of-concept and for testing purposes only. + +No error handling. +Need further work. Some options do not work or options are missing. + +Needs at least ffmpeg 4.4 +""" + + +import json +import operator +import pathlib +import re +import subprocess # noqa: S404 +import tempfile +import typing as t +from enum import Enum +from functools import reduce +from glob import glob +from shutil import which + +import click +from click import echo, secho + +from audible_cli.decorators import pass_session +from audible_cli.exceptions import AudibleCliException + + +class ChapterError(AudibleCliException): + """Base class for all chapter errors.""" + + +class SupportedFiles(Enum): + AAX = ".aax" + AAXC = ".aaxc" + + @classmethod + def get_supported_list(cls): + return list(set(item.value for item in cls)) + + @classmethod + def is_supported_suffix(cls, value): + return value in cls.get_supported_list() + + @classmethod + def is_supported_file(cls, value): + return pathlib.PurePath(value).suffix in cls.get_supported_list() + + +def _get_input_files( + files: t.Union[t.Tuple[str], t.List[str]], + recursive: bool = True +) -> t.List[pathlib.Path]: + filenames = [] + for filename in files: + # if the shell does not do filename globbing + expanded = list(glob(filename, recursive=recursive)) + + if ( + len(expanded) == 0 + and '*' not in filename + and not SupportedFiles.is_supported_file(filename) + ): + raise click.BadParameter("{filename}: file not found or supported.") + + expanded_filter = filter( + lambda x: SupportedFiles.is_supported_file(x), expanded + ) + expanded = list(map(lambda x: pathlib.Path(x).resolve(), expanded_filter)) + filenames.extend(expanded) + + return filenames + + +def recursive_lookup_dict(key: str, dictionary: t.Dict[str, t.Any]) -> t.Any: + if key in dictionary: + return dictionary[key] + for value in dictionary.values(): + if isinstance(value, dict): + try: + item = recursive_lookup_dict(key, value) + except KeyError: + continue + else: + return item + + raise KeyError + + +def get_aaxc_credentials(voucher_file: pathlib.Path): + if not voucher_file.exists() or not voucher_file.is_file(): + raise AudibleCliException(f"Voucher file {voucher_file} not found.") + + voucher_dict = json.loads(voucher_file.read_text()) + try: + key = recursive_lookup_dict("key", voucher_dict) + iv = recursive_lookup_dict("iv", voucher_dict) + except KeyError: + raise AudibleCliException(f"No key/iv found in file {voucher_file}.") from None + + return key, iv + + +class ApiChapterInfo: + def __init__(self, content_metadata: t.Dict[str, t.Any]) -> None: + chapter_info = self._parse(content_metadata) + self._chapter_info = chapter_info + + @classmethod + def from_file(cls, file: t.Union[pathlib.Path, str]) -> "ApiChapterInfo": + file = pathlib.Path(file) + if not file.exists() or not file.is_file(): + raise ChapterError(f"Chapter file {file} not found.") + content_string = pathlib.Path(file).read_text("utf-8") + content_json = json.loads(content_string) + return cls(content_json) + + @staticmethod + def _parse(content_metadata: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: + if "chapters" in content_metadata: + return content_metadata + + try: + return recursive_lookup_dict("chapter_info", content_metadata) + except KeyError: + raise ChapterError("No chapter info found.") from None + + def count_chapters(self): + return len(self.get_chapters()) + + def get_chapters(self, separate_intro_outro=False, remove_intro_outro=False): + def extract_chapters(initial, current): + if "chapters" in current: + return initial + [current] + current["chapters"] + else: + return initial + [current] + + chapters = list( + reduce( + extract_chapters, + self._chapter_info["chapters"], + [], + ) + ) + + if separate_intro_outro: + return self._separate_intro_outro(chapters) + elif remove_intro_outro: + return self._remove_intro_outro(chapters) + + return chapters + + def get_intro_duration_ms(self): + return self._chapter_info["brandIntroDurationMs"] + + def get_outro_duration_ms(self): + return self._chapter_info["brandOutroDurationMs"] + + def get_runtime_length_ms(self): + return self._chapter_info["runtime_length_ms"] + + def is_accurate(self): + return self._chapter_info["is_accurate"] + + def _separate_intro_outro(self, chapters): + echo("Separate Audible Brand Intro and Outro to own Chapter.") + chapters.sort(key=operator.itemgetter("start_offset_ms")) + + first = chapters[0] + intro_dur_ms = self.get_intro_duration_ms() + first["start_offset_ms"] = intro_dur_ms + first["start_offset_sec"] = round(first["start_offset_ms"] / 1000) + first["length_ms"] -= intro_dur_ms + + last = chapters[-1] + outro_dur_ms = self.get_outro_duration_ms() + last["length_ms"] -= outro_dur_ms + + chapters.append( + { + "length_ms": intro_dur_ms, + "start_offset_ms": 0, + "start_offset_sec": 0, + "title": "Intro", + } + ) + chapters.append( + { + "length_ms": outro_dur_ms, + "start_offset_ms": self.get_runtime_length_ms() - outro_dur_ms, + "start_offset_sec": round( + (self.get_runtime_length_ms() - outro_dur_ms) / 1000 + ), + "title": "Outro", + } + ) + chapters.sort(key=operator.itemgetter("start_offset_ms")) + + return chapters + + def _remove_intro_outro(self, chapters): + echo("Delete Audible Brand Intro and Outro.") + chapters.sort(key=operator.itemgetter("start_offset_ms")) + + intro_dur_ms = self.get_intro_duration_ms() + outro_dur_ms = self.get_outro_duration_ms() + + first = chapters[0] + first["length_ms"] -= intro_dur_ms + + for chapter in chapters[1:]: + chapter["start_offset_ms"] -= intro_dur_ms + chapter["start_offset_sec"] -= round(chapter["start_offset_ms"] / 1000) + + last = chapters[-1] + last["length_ms"] -= outro_dur_ms + + return chapters + +class FFMeta: + SECTION = re.compile(r"\[(?P
[^]]+)\]") + OPTION = re.compile(r"(?P