├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── pypi-publish-test.yml │ ├── pypi-publish.yml │ └── uv-lock-maintenance.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── audible.spec ├── docs ├── Makefile ├── make.bat └── source │ ├── conf.py │ └── index.rst ├── plugin_cmds ├── README.md ├── cmd_decrypt.py ├── cmd_get-annotations.py ├── cmd_goodreads-transform.py ├── cmd_image-urls.py ├── cmd_listening-stats.py └── convert_oa_cred.py ├── pyi_entrypoint.py ├── pyproject.toml ├── src └── audible_cli │ ├── __init__.py │ ├── __main__.py │ ├── _logging.py │ ├── _version.py │ ├── cli.py │ ├── cmds │ ├── __init__.py │ ├── cmd_activation_bytes.py │ ├── cmd_api.py │ ├── cmd_download.py │ ├── cmd_library.py │ ├── cmd_manage.py │ ├── cmd_quickstart.py │ └── cmd_wishlist.py │ ├── config.py │ ├── constants.py │ ├── decorators.py │ ├── downloader.py │ ├── exceptions.py │ ├── models.py │ ├── plugins.py │ └── utils.py ├── test.py ├── utils ├── code_completion │ ├── README.md │ ├── audible-complete-bash.sh │ └── audible-complete-zsh-fish.sh └── update_chapter_titles.py └── uv.lock /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mkb79] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: "/" 6 | schedule: 7 | interval: weekly 8 | groups: 9 | github-actions-updates: 10 | applies-to: version-updates 11 | dependency-type: development 12 | github-actions-security-updates: 13 | applies-to: security-updates 14 | dependency-type: development 15 | - package-ecosystem: pip 16 | directory: "/.github/workflows" 17 | schedule: 18 | interval: weekly 19 | groups: 20 | workflow-updates: 21 | applies-to: version-updates 22 | dependency-type: development 23 | workflow-security-updates: 24 | applies-to: security-updates 25 | dependency-type: development 26 | - package-ecosystem: pip 27 | directory: "/docs" 28 | schedule: 29 | interval: weekly 30 | groups: 31 | doc-updates: 32 | applies-to: version-updates 33 | dependency-type: development 34 | doc-security-updates: 35 | applies-to: security-updates 36 | dependency-type: production 37 | - package-ecosystem: uv 38 | directory: "/" 39 | schedule: 40 | interval: weekly 41 | # versioning-strategy: lockfile-only 42 | allow: 43 | - dependency-type: "all" 44 | groups: 45 | pip-version-updates: 46 | applies-to: version-updates 47 | dependency-type: development 48 | pip-security-updates: 49 | applies-to: security-updates 50 | dependency-type: production -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 7 | 8 | env: 9 | PYTHON_VERSION: "3.13" 10 | 11 | jobs: 12 | create_release: 13 | name: Create Release 14 | runs-on: ubuntu-latest 15 | outputs: 16 | release_url: ${{ steps.create-release.outputs.upload_url }} 17 | steps: 18 | - name: Create Release 19 | id: create-release 20 | uses: actions/create-release@v1 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | with: 24 | tag_name: ${{ github.ref }} 25 | release_name: Release ${{ github.ref }} 26 | draft: false 27 | prerelease: false 28 | 29 | build: 30 | name: Build packages 31 | needs: create_release 32 | runs-on: ${{ matrix.os }} 33 | strategy: 34 | matrix: 35 | include: 36 | - os: ubuntu-latest 37 | TARGET: linux 38 | CMD_BUILD: > 39 | uv run pyinstaller --clean -F --hidden-import audible_cli -n audible -c pyi_entrypoint.py && 40 | cd dist/ && 41 | zip -r9 audible_linux_ubuntu_latest audible 42 | OUT_FILE_NAME: audible_linux_ubuntu_latest.zip 43 | ASSET_MIME: application/zip # application/octet-stream 44 | - os: ubuntu-22.04 45 | TARGET: linux 46 | CMD_BUILD: > 47 | uv run pyinstaller --clean -F --hidden-import audible_cli -n audible -c pyi_entrypoint.py && 48 | cd dist/ && 49 | zip -r9 audible_linux_ubuntu_22_04 audible 50 | OUT_FILE_NAME: audible_linux_ubuntu_22_04.zip 51 | ASSET_MIME: application/zip # application/octet-stream 52 | - os: macos-latest 53 | TARGET: macos 54 | CMD_BUILD: > 55 | uv run pyinstaller --clean -F --hidden-import audible_cli -n audible -c pyi_entrypoint.py && 56 | cd dist/ && 57 | zip -r9 audible_mac audible 58 | OUT_FILE_NAME: audible_mac.zip 59 | ASSET_MIME: application/zip 60 | - os: macos-latest 61 | TARGET: macos 62 | CMD_BUILD: > 63 | uv run pyinstaller --clean -D --hidden-import audible_cli -n audible -c pyi_entrypoint.py && 64 | cd dist/ && 65 | zip -r9 audible_mac_dir audible 66 | OUT_FILE_NAME: audible_mac_dir.zip 67 | ASSET_MIME: application/zip 68 | - os: windows-latest 69 | TARGET: windows 70 | CMD_BUILD: > 71 | uv run pyinstaller --clean -D --hidden-import audible_cli -n audible -c pyi_entrypoint.py && 72 | cd dist/ && 73 | powershell Compress-Archive audible audible_win_dir.zip 74 | OUT_FILE_NAME: audible_win_dir.zip 75 | ASSET_MIME: application/zip 76 | - os: windows-latest 77 | TARGET: windows 78 | CMD_BUILD: > 79 | uv run pyinstaller --clean -F --hidden-import audible_cli -n audible -c pyi_entrypoint.py && 80 | cd dist/ && 81 | powershell Compress-Archive audible.exe audible_win.zip 82 | OUT_FILE_NAME: audible_win.zip 83 | ASSET_MIME: application/zip 84 | steps: 85 | - uses: actions/checkout@v4 86 | 87 | - name: Set up Python ${{ env.PYTHON_VERSION }} 88 | uses: actions/setup-python@v5 89 | with: 90 | python-version: ${{ env.PYTHON_VERSION }} 91 | 92 | - name: Install uv 93 | uses: astral-sh/setup-uv@v6 94 | 95 | - name: Build with pyinstaller for ${{matrix.TARGET}} 96 | run: ${{matrix.CMD_BUILD}} 97 | 98 | - name: Upload Release Asset 99 | id: upload-release-asset 100 | uses: actions/upload-release-asset@v1 101 | env: 102 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 103 | with: 104 | upload_url: ${{ needs.createrelease.outputs.release_url }} 105 | asset_path: ./dist/${{ matrix.OUT_FILE_NAME}} 106 | asset_name: ${{ matrix.OUT_FILE_NAME}} 107 | asset_content_type: ${{ matrix.ASSET_MIME}} 108 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish-test.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package to TestPyPI 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | PYTHON_VERSION: "3.13" 8 | 9 | jobs: 10 | build-n-publish: 11 | name: Build and publish Audible-cli to TestPyPI 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python ${{ env.PYTHON_VERSION }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ env.PYTHON_VERSION }} 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v6 24 | 25 | - name: Build package 26 | run: | 27 | uv build 28 | 29 | - name: Publish distribution to Test PyPI 30 | uses: pypa/gh-action-pypi-publish@release/v1 31 | with: 32 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 33 | repository_url: https://test.pypi.org/legacy/ 34 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | env: 7 | PYTHON_VERSION: "3.13" 8 | 9 | jobs: 10 | build-n-publish: 11 | name: Build and publish Audible-cli to TestPyPI 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Set up Python ${{ env.PYTHON_VERSION }} 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: ${{ env.PYTHON_VERSION }} 21 | 22 | - name: Install uv 23 | uses: astral-sh/setup-uv@v6 24 | 25 | - name: Build package 26 | run: | 27 | uv build 28 | 29 | - name: Publish distribution to PyPI 30 | uses: pypa/gh-action-pypi-publish@release/v1 31 | with: 32 | password: ${{ secrets.PYPI_API_TOKEN }} 33 | -------------------------------------------------------------------------------- /.github/workflows/uv-lock-maintenance.yml: -------------------------------------------------------------------------------- 1 | name: uv lock file maintenance 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - "pyproject.toml" 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | lock: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | token: ${{ secrets.GH_PAT }} 19 | 20 | - uses: astral-sh/setup-uv@v6 21 | with: 22 | enable-cache: true 23 | 24 | - name: "Run uv lock command" 25 | run: uv lock 26 | 27 | - uses: stefanzweifel/git-auto-commit-action@v5 28 | id: auto-commit-action 29 | with: 30 | commit_message: Regenerate uv.lock 31 | 32 | - name: "Run if changes have been detected" 33 | if: steps.auto-commit-action.outputs.changes_detected == 'true' 34 | run: echo "Changes!" 35 | 36 | - name: "Run if no changes have been detected" 37 | if: steps.auto-commit-action.outputs.changes_detected == 'false' 38 | run: echo "No Changes!" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /src/* 2 | !/src/audible_cli 3 | /src/audible_cli/*.bak 4 | library.csv 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | !audible.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | docs/build/ 80 | 81 | # PyBuilder 82 | target/ 83 | 84 | # Jupyter Notebook 85 | .ipynb_checkpoints 86 | 87 | # IPython 88 | profile_default/ 89 | ipython_config.py 90 | 91 | # pyenv 92 | .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | .idea 139 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). 6 | 7 | ## Unreleased 8 | 9 | ### Misc 10 | 11 | - switch from setup.py to pyproject.toml 12 | - use uv for development package management 13 | 14 | ## [0.3.2] - 2025-05-24 15 | 16 | ### Bugfix 17 | 18 | - 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. 19 | 20 | ### Added 21 | 22 | - The `--chapter-type` option is added to the download command. Chapter can now be 23 | downloaded as `flat` or `tree` type. `tree` is the default. A default chapter type 24 | can be set in the config file. 25 | 26 | ### Changed 27 | 28 | - Improved podcast ignore feature in download command 29 | - make `--ignore-podcasts` and `--resolve-podcasts` options of download command mutual 30 | exclusive 31 | - Switched from a HEAD to a GET request without loading the body in the downloader 32 | class. This change improves the program's speed, as the HEAD request was taking 33 | considerably longer than a GET request on some Audible pages. 34 | - `models.LibraryItem.get_content_metadatata` now accept a `chapter_type` argument. 35 | Additional keyword arguments to this method are now passed through the metadata 36 | request. 37 | - Update httpx version range to >=0.23.3 and <0.28.0. 38 | - fix typo from `resolve_podcats` to `resolve_podcasts` 39 | - `models.Library.resolve_podcats` is now deprecated and will be removed in a future version 40 | 41 | ### Removed 42 | 43 | - Python 3.6 - 3.9 compatibility 44 | 45 | ## [0.3.1] - 2024-03-19 46 | 47 | ### Bugfix 48 | 49 | - fix a `TypeError` on some Python versions when calling `importlib.metadata.entry_points` with group argument 50 | 51 | ## [0.3.0] - 2024-03-19 52 | 53 | ### Added 54 | 55 | - Added a resume feature when downloading aaxc files. 56 | - New `downlaoder` module which contains a rework of the Downloader class. 57 | - If necessary, large audiobooks are now downloaded in parts. 58 | - Plugin command help page now contains additional information about the source of 59 | the plugin. 60 | - Command help text now starts with ´(P)` for plugin commands. 61 | 62 | ### Changed 63 | 64 | - Rework plugin module 65 | - using importlib.metadata over setuptools (pkg_resources) to get entrypoints 66 | 67 | ## [0.2.6] - 2023-11-16 68 | 69 | ### Added 70 | 71 | - Update marketplace choices in `manage auth-file add` command. Now all available marketplaces are listed. 72 | 73 | ### Bugfix 74 | 75 | - Avoid tqdm progress bar interruption by logger’s output to console. 76 | - Fixing an issue with unawaited coroutines when the download command exited abnormal. 77 | 78 | ### Changed 79 | 80 | - Update httpx version range to >=0.23.3 and <0.26.0. 81 | 82 | ### Misc 83 | 84 | - add `freeze_support` to pyinstaller entry script (#78) 85 | 86 | ## [0.2.5] - 2023-09-26 87 | 88 | ### Added 89 | 90 | - Dynamically load available marketplaces from the `audible package`. Allows to implement a new marketplace without updating `audible-cli`. 91 | 92 | ## [0.2.4] - 2022-09-21 93 | 94 | ### Added 95 | 96 | - Allow download multiple cover sizes at once. Each cover size must be provided with the `--cover-size` option 97 | 98 | 99 | ### Changed 100 | 101 | - Rework start_date and end_date option 102 | 103 | ### Bugfix 104 | 105 | - 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 106 | 107 | ## [0.2.3] - 2022-09-06 108 | 109 | ### Added 110 | 111 | - `--start-date` and `--end-date` option to `download` command 112 | - `--start-date` and `--end-date` option to `library export` and `library list` command 113 | - better error handling for license requests 114 | - verify that a download link is valid 115 | - make sure an item is published before downloading the aax, aaxc or pdf file 116 | - `--ignore-errors` flag of the download command now continue, if an item failed to download 117 | 118 | ## [0.2.2] - 2022-08-09 119 | 120 | ### Bugfix 121 | 122 | - PDFs could not be found using the download command (#112) 123 | 124 | ## [0.2.1] - 2022-07-29 125 | 126 | ### Added 127 | 128 | - `library` command now outputs the `extended_product_description` field 129 | 130 | ### Changed 131 | 132 | - by default a licenserequest (voucher) will not include chapter information by default 133 | - moved licenserequest part from `models.LibraryItem.get_aaxc_url` to its own `models.LibraryItem.get_license` function 134 | - allow book titles with hyphens (#96) 135 | - if there is no title fallback to an empty string (#98) 136 | - reduce `response_groups` for the download command to speed up fetching the library (#109) 137 | 138 | ### Fixed 139 | 140 | - `Extreme` quality is not supported by the Audible API anymore (#107) 141 | - download command continued execution after error (#104) 142 | - Currently, paths with dots will break the decryption (#97) 143 | - `models.Library.from_api_full_sync` called `models.Library.from_api` with incorrect keyword arguments 144 | 145 | ### Misc 146 | 147 | - reworked `cmd_remove-encryption` plugin command (e.g. support nested chapters, use chapter file for aaxc files) 148 | - added explanation in README.md for creating a second profile 149 | 150 | ## [0.2.0] - 2022-06-01 151 | 152 | ### Added 153 | 154 | - `--aax-fallback` option to `download` command to download books in aax format and fallback to aaxc, if the book is not available as aax 155 | - `--annotation` option to `download` command to get bookmarks and notes 156 | - `questionary` package to dependencies 157 | - `add` and `remove` subcommands to wishlist 158 | - `full_response_callback` to `utils` 159 | - `export_to_csv` to `utils` 160 | - `run_async` to `decorators` 161 | - `pass_client` to `decorators` 162 | - `profile_option` to `decorators` 163 | - `password_option` to `decorators` 164 | - `timeout_option` to `decorators` 165 | - `bunch_size_option` to `decorators` 166 | - `ConfigFile.get_profile_option` get the value for an option for a given profile 167 | - `Session.selected.profile` to get the profile name for the current session 168 | - `Session.get_auth_for_profile` to get an auth file for a given profile 169 | - `models.BaseItem.create_base_filename` to build a filename in given mode 170 | - `models.LibraryItem.get_annotations` to get annotations for a library item 171 | 172 | ### Changed 173 | 174 | - bump `audible` to v0.8.2 to fix a bug in httpx 175 | - rework plugin examples in `plugin_cmds` 176 | - rename `config.Config` to `config.ConfigFile` 177 | - move `click_verbosity_logger` from `_logging` to `decorators` and rename it to `verbosity_option` 178 | - move `wrap_async` from `utils` to `decorators` 179 | - move `add_param_to_session` from `config` to `decorators` 180 | - move `pass_session` from `config` to `decorators` 181 | - `download` command let you now select items when using `--title` option 182 | 183 | ### Fixed 184 | 185 | - the `library export` and `wishlist export` command will now export to `csv` correctly 186 | - 187 | 188 | ## [0.1.3] - 2022-03-27 189 | 190 | ### Bugfix 191 | 192 | - fix a bug with the registration url 193 | 194 | ## [0.1.2] - 2022-03-27 195 | 196 | ### Bugfix 197 | 198 | - bump Audible to v0.7.1 to fix a bug when register a new device with pre-Amazon account 199 | 200 | ## [0.1.1] - 2022-03-20 201 | 202 | ### Added 203 | 204 | - the `--version` option now checks if an update for `audible-cli` is available 205 | - build macOS releases in `onedir` mode 206 | 207 | ### Bugfix 208 | 209 | - fix a bug where counting an item if the download fails 210 | - fix an issue where some items could not be downloaded do tue wrong content type 211 | - fix an issue where an aax downloaded failed with a `codec doesn't support full file assembly` message 212 | 213 | ## [0.1.0] - 2022-03-11 214 | 215 | ### Added 216 | 217 | - add the `api` command to make requests to the AudibleAPI 218 | - a counter of downloaded items for the download command 219 | - the `--verbosity/-v` option; default is INFO 220 | - the `--bunch-size` option to the download, library export and library list subcommand; this is only needed on slow internet connections 221 | - `wishlist` subcommand 222 | - the `--resolve-podcasts` flag to download subcommand; all episodes of a podcast will be fetched at startup, so a single episode can be searched via his title or asin 223 | - the `--ignore-podcasts` flag to download subcommand; if a podcast contains multiple episodes, the podcast will be ignored 224 | - the`models.Library.resolve_podcasts` method to append all podcast episodes to given library. 225 | - the `models.LibraryItem.get_child_items` method to get all episodes of a podcast item or parts for a MultiPartBook. 226 | - the`models.BaseItem` now holds a list of `response_groups` in the `_response_groups` attribute. 227 | - the`--format` option to `library export` subcommand 228 | - the `models.Catalog` class 229 | - the `models.Library.from_api_full_sync` method to fetch the full library 230 | 231 | ### Changed 232 | 233 | - the `--aaxc` flag of the download command now try to check if a voucher file exists before a `licenserequest` is make (issue #60) 234 | - the `--aaxc` flag of the download command now downloads mp3/m4a files if the `aaxc` format is not available and the `licenserequest` offers this formats 235 | - the `download` subcommand now download podcasts 236 | - *Remove sync code where async code are available. All plugins should take care about this!!!* 237 | - Bump `audible` to v0.7.0 238 | - rebuild `models.LibraryItem.get_aax_url` to build the aax download url in another way 239 | - `models.BaseItem.full_title` now contains publication name for podcast episodes 240 | - `models.LibraryItem` now checks the customer rights when calling `LibraryItem._is_downloadable` 241 | - `models.BaseItem` and `models.BaseList` now holds the `api_client` instead the `locale` and `auth` 242 | - rename `models.Wishlist.get_from_api` to `models.Wishlist.from_api` 243 | - rename `models.Library.get_from_api` to `models.Library.from_api`; this method does not fetch the full library for now 244 | 245 | ### Misc 246 | 247 | - bump click to v8 248 | 249 | ### Bugfix 250 | 251 | - removing an error using the `--output` option of the `library export` command 252 | - fixing some other bugs 253 | 254 | ## [0.0.9] - 2022-01-18 255 | 256 | ### Bugfix 257 | 258 | - bugfix error adding/removing auth file 259 | 260 | ## [0.0.8] - 2022-01-15 261 | 262 | ### Bugfix 263 | 264 | - bugfix errors in utils.py 265 | 266 | ## [0.0.7] - 2022-01-15 267 | 268 | ### Bugfix 269 | 270 | - utils.py: Downloading pdf files was broken. Downloader now follows a redirect when downloading a file. 271 | 272 | ### Added 273 | 274 | - Add spec file to create binary with pyinstaller 275 | - Add binary for some platforms 276 | - Add timeout option to download command 277 | 278 | ### Changed 279 | - models.py: If no supported codec is found when downloading aax files, no url 280 | is returned now. 281 | - utils.py: Downloading a file with the `Downloader` class now checks the 282 | response status code, the content type and compares the file size. 283 | - models.py: Now all books are fetched if the library is greater than 1000. 284 | This works for the download and library command. 285 | 286 | ## [0.0.6] - 2022-01-07 287 | 288 | ### Bugfix 289 | 290 | - cmd_library.py: If library does not contain a cover url, audible-cli 291 | has raised an Exception. Now the cover url field will set to '-' if no 292 | cover url is available. 293 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # audible-cli 2 | 3 | **audible-cli** is a command line interface for the 4 | [Audible](https://github.com/mkb79/Audible) package. 5 | Both are written with Python. 6 | 7 | ## Requirements 8 | 9 | audible-cli needs at least *Python 3.10* and *Audible v0.8.2*. 10 | 11 | It depends on the following packages: 12 | 13 | * aiofiles 14 | * audible 15 | * click 16 | * colorama (on Windows machines) 17 | * httpx 18 | * Pillow 19 | * tabulate 20 | * toml 21 | * tqdm 22 | 23 | ## Installation 24 | 25 | You can install audible-cli from pypi with 26 | 27 | ```shell 28 | 29 | pip install audible-cli 30 | 31 | ``` 32 | 33 | or install it directly from GitHub with 34 | 35 | ```shell 36 | 37 | git clone https://github.com/mkb79/audible-cli.git 38 | cd audible-cli 39 | pip install . 40 | 41 | ``` 42 | 43 | or as the best solution using [uvx](https://github.com/astral-sh/uv) 44 | 45 | ```shell 46 | 47 | uvx --with audible-cli audible 48 | 49 | ``` 50 | 51 | ## Standalone executables 52 | 53 | If you don't want to install `Python` and `audible-cli` on your machine, you can 54 | find standalone exe files below or on the [releases](https://github.com/mkb79/audible-cli/releases) 55 | page (including beta releases). At this moment Windows, Linux and macOS are supported. 56 | 57 | ### Links 58 | 59 | 1. Linux 60 | - [ubuntu latest onefile](https://github.com/mkb79/audible-cli/releases/latest/download/audible_linux_ubuntu_latest.zip) 61 | - [ubuntu 22.04 onefile](https://github.com/mkb79/audible-cli/releases/latest/download/audible_linux_ubuntu_22_04.zip) 62 | 63 | 2. macOS 64 | - [macOS latest onefile](https://github.com/mkb79/audible-cli/releases/latest/download/audible_mac.zip) 65 | - [macOS latest onedir](https://github.com/mkb79/audible-cli/releases/latest/download/audible_mac_dir.zip) 66 | 67 | 3. Windows 68 | - [Windows onefile](https://github.com/mkb79/audible-cli/releases/latest/download/audible_win.zip) 69 | - [Windows onedir](https://github.com/mkb79/audible-cli/releases/latest/download/audible_win_dir.zip) 70 | 71 | On every execution, the binary code must be extracted. On Windows machines this can result in a long start time. If you use `audible-cli` often, I would prefer the `directory` package for Windows! 72 | 73 | ### Creating executables on your own 74 | 75 | You can create them yourself this way 76 | 77 | ```shell 78 | 79 | git clone https://github.com/mkb79/audible-cli.git 80 | 81 | # onefile output 82 | uv run pyinstaller --clean -F --hidden-import audible_cli -n audible -c pyi_entrypoint 83 | 84 | # onedir output 85 | uv run pyinstaller --clean -D --hidden-import audible_cli -n audible -c pyi_entrypoint 86 | ``` 87 | 88 | ### Hints 89 | 90 | There are some limitations when using plugins. The binary maybe does not contain 91 | all the dependencies from your plugin script. 92 | 93 | ## Tab Completion 94 | 95 | Tab completion can be provided for commands, options and choice values. Bash, 96 | Zsh and Fish are supported. More information can be found 97 | [here](https://github.com/mkb79/audible-cli/tree/master/utils/code_completion). 98 | 99 | 100 | ## Basic information 101 | 102 | ### App dir 103 | 104 | audible-cli use an app dir where it expects all necessary files. 105 | 106 | If the ``AUDIBLE_CONFIG_DIR`` environment variable is set, it uses the value 107 | as config dir. Otherwise, it will use a folder depending on the operating 108 | system. 109 | 110 | | OS | Path | 111 | |----------|-------------------------------------------| 112 | | Windows | ``C:\Users\\AppData\Local\audible`` | 113 | | Unix | ``~/.audible`` | 114 | | Mac OS X | ``~/.audible`` | 115 | 116 | ### The config file 117 | 118 | The config data will be stored in the [toml](https://github.com/toml-lang/toml) 119 | format as ``config.toml``. 120 | 121 | It has a main section named ``APP`` and sections for each profile created 122 | named ``profile.`` 123 | 124 | ### profiles 125 | 126 | audible-cli make use of profiles. Each profile contains the name of the 127 | corresponding auth file and the country code for the audible marketplace. If 128 | you have audiobooks on multiple marketplaces, you have to create a profile for 129 | each one with the same auth file. 130 | 131 | In the main section of the config file, a primary profile is defined. 132 | This profile is used, if no other is specified. You can call 133 | `audible -P PROFILE_NAME`, to select another profile. 134 | 135 | ### auth files 136 | 137 | Like the config file, auth files are stored in the config dir too. If you 138 | protected your auth file with a password call `audible -p PASSWORD`, to 139 | provide the password. 140 | 141 | If the auth file is encrypted, and you don’t provide the password, you will be 142 | asked for it with a „hidden“ input field. 143 | 144 | ### Config options 145 | 146 | An option in the config file is separated by an underline. In the CLI prompt, 147 | an option must be entered with a dash. 148 | 149 | #### APP section 150 | 151 | The APP section supports the following options: 152 | - primary_profile: The profile to use, if no other is specified 153 | - filename_mode: When using the `download` command, a filename mode can be 154 | specified here. If not present, "ascii" will be used as default. To override 155 | these option, you can provide a mode with the `--filename-mode` option of the 156 | download command. 157 | - chapter_type: When using the `download` command, a chapter type can be specified 158 | here. If not present, "tree" will be used as default. To override 159 | these option, you can provide a type with the `--chapter-type` option of the 160 | download command. 161 | 162 | #### Profile section 163 | 164 | - auth_file: The auth file for this profile 165 | - country_code: The marketplace for this profile 166 | - filename_mode: See APP section above. Will override the option in APP section. 167 | - chapter_type: See APP section above. Will override the option in APP section. 168 | 169 | ## Getting started 170 | 171 | Use the `audible-quickstart` or `audible quickstart` command in your shell 172 | to create your first config, profile and auth file. `audible-quickstart` 173 | runs on the interactive mode, so you have to answer multiple questions to finish. 174 | 175 | 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. 176 | 177 | So the correct order is: 178 | 179 | 1. add a new auth file using your second account using `audible manage auth-file add` 180 | 2. add a new profile to your config and use the second auth file using `audible manage profile add` 181 | 182 | 183 | ## Commands 184 | 185 | 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. 186 | 187 | At this time, there the following buildin subcommands: 188 | 189 | - `activation-bytes` 190 | - `api` 191 | - `download` 192 | - `library` 193 | - `export` 194 | - `list` 195 | - `manage` 196 | - `auth-file` 197 | - `add` 198 | - `remove` 199 | - `config` 200 | - `edit` 201 | - `profile` 202 | - `add` 203 | - `list` 204 | - `remove` 205 | - `quickstart` 206 | - `wishlist` 207 | - `export` 208 | - `list` 209 | - `add` 210 | - `remove` 211 | 212 | ## Example Usage 213 | 214 | To download all of your audiobooks in the aaxc format use: 215 | ```shell 216 | audible download --all --aaxc 217 | ``` 218 | To download all of your audiobooks after the Date 2022-07-21 in aax format use: 219 | ```shell 220 | audible download --start-date "2022-07-21" --aax --all 221 | ``` 222 | 223 | ## Verbosity option 224 | 225 | There are 6 different verbosity levels: 226 | 227 | - debug 228 | - info 229 | - warning 230 | - error 231 | - critical 232 | 233 | By default, the verbosity level is set to `info`. You can provide another level like so: `audible -v ...`. 234 | 235 | 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 ...` 236 | 237 | ## Plugins 238 | 239 | ### Plugin Folder 240 | 241 | If the ``AUDIBLE_PLUGIN_DIR`` environment variable is set, it uses the value 242 | as location for the plugin dir. Otherwise, it will use a the `plugins` subdir 243 | of the app dir. Read above how Audible-cli searches the app dir. 244 | 245 | ### Custom Commands 246 | 247 | You can provide own subcommands and execute them with `audible SUBCOMMAND`. 248 | All plugin commands must be placed in the plugin folder. Every subcommand must 249 | have his own file. Every file have to be named ``cmd_{SUBCOMMAND}.py``. 250 | Each subcommand file must have a function called `cli` as entrypoint. 251 | This function has to be decorated with ``@click.group(name="GROUP_NAME")`` or 252 | ``@click.command(name="GROUP_NAME")``. 253 | 254 | Relative imports in the command files doesn't work. So you have to work with 255 | absolute imports. Please take care about this. If you have any issues with 256 | absolute imports please add your plugin path to the `PYTHONPATH` variable or 257 | add this lines of code to the beginning of your command script: 258 | 259 | ```python 260 | import sys 261 | import pathlib 262 | sys.path.insert(0, str(pathlib.Path(__file__).parent)) 263 | ``` 264 | 265 | Examples can be found 266 | [here](https://github.com/mkb79/audible-cli/tree/master/plugin_cmds). 267 | 268 | 269 | ## Own Plugin Packages 270 | 271 | If you want to develop a complete plugin package for ``audible-cli`` you can 272 | do this on an easy way. You only need to register your sub-commands or 273 | subgroups to an entry-point in your setup.py that is loaded by the core 274 | package. 275 | 276 | Example for a setup.py 277 | 278 | ```python 279 | from setuptools import setup 280 | 281 | setup( 282 | name="yourscript", 283 | version="0.1", 284 | py_modules=["yourscript"], 285 | install_requires=[ 286 | "click", 287 | "audible_cli" 288 | ], 289 | entry_points=""" 290 | [audible.cli_plugins] 291 | cool_subcommand=yourscript.cli:cool_subcommand 292 | another_subcommand=yourscript.cli:another_subcommand 293 | """, 294 | ) 295 | ``` 296 | 297 | ## Command priority order 298 | 299 | Commands will be added in the following order: 300 | 301 | 1. plugin dir commands 302 | 2. plugin packages commands 303 | 3. build-in commands 304 | 305 | If a command is added, all further commands with the same name will be ignored. 306 | This enables you to "replace" build-in commands very easy. 307 | 308 | ## List of known add-ons for `audible-cli` 309 | 310 | - [audible-cli-flask](https://github.com/mkb79/audible-cli-flask) 311 | - [audible-series](https://pypi.org/project/audible-series/) 312 | 313 | If you want to add information about your add-on please open a PR or a new issue! 314 | -------------------------------------------------------------------------------- /audible.spec: -------------------------------------------------------------------------------- 1 | # -*- mode: python ; coding: utf-8 -*- 2 | 3 | 4 | block_cipher = None 5 | 6 | 7 | a = Analysis(['pyi_entrypoint.py'], 8 | pathex=[], 9 | binaries=[], 10 | datas=[], 11 | hiddenimports=['audible_cli'], 12 | hookspath=[], 13 | hooksconfig={}, 14 | runtime_hooks=[], 15 | excludes=[], 16 | win_no_prefer_redirects=False, 17 | win_private_assemblies=False, 18 | cipher=block_cipher, 19 | noarchive=False) 20 | pyz = PYZ(a.pure, a.zipped_data, 21 | cipher=block_cipher) 22 | 23 | exe = EXE(pyz, 24 | a.scripts, 25 | a.binaries, 26 | a.zipfiles, 27 | a.datas, 28 | [], 29 | name='audible', 30 | debug=False, 31 | bootloader_ignore_signals=False, 32 | strip=False, 33 | upx=True, 34 | upx_exclude=[], 35 | runtime_tmpdir=None, 36 | console=True, 37 | disable_windowed_traceback=False, 38 | target_arch=None, 39 | codesign_identity=None, 40 | entitlements_file=None ) 41 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | sys.path.insert(0, os.path.abspath("../../src")) 16 | import audible_cli 17 | 18 | 19 | # -- Project information ----------------------------------------------------- 20 | 21 | project = "audible-cli" 22 | copyright = "2020, mkb79" 23 | author = "mkb79" 24 | 25 | # The full version, including alpha/beta/rc tags 26 | version = audible_cli.__version__ 27 | 28 | 29 | # -- General configuration --------------------------------------------------- 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | "recommonmark", 36 | "sphinx.ext.autodoc", 37 | "sphinx.ext.coverage", 38 | "sphinx.ext.napoleon", 39 | "sphinx_rtd_theme", 40 | "sphinx.ext.autosummary" 41 | ] 42 | 43 | master_doc = "index" 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ["_templates"] 47 | 48 | # List of patterns, relative to source directory, that match files and 49 | # directories to ignore when looking for source files. 50 | # This pattern also affects html_static_path and html_extra_path. 51 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 52 | 53 | 54 | # -- Options for HTML output ------------------------------------------------- 55 | 56 | # The theme to use for HTML and HTML Help pages. See the documentation for 57 | # a list of builtin themes. 58 | # 59 | html_theme = "sphinx_rtd_theme" 60 | 61 | # Add any paths that contain custom static files (such as style sheets) here, 62 | # relative to this directory. They are copied after the builtin static files, 63 | # so a file named "default.css" will overwrite the builtin "default.css". 64 | html_static_path = ["_static"] 65 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | ==================================== 2 | audible-cli |version| documentation! 3 | ==================================== 4 | 5 | 6 | **audible-cli** is a command line interface for the audible package written in python. 7 | 8 | | 9 | 10 | .. toctree:: 11 | :maxdepth: 2 12 | :caption: Table of Contents 13 | 14 | 15 | Indices and tables 16 | ================== 17 | 18 | * :ref:`genindex` 19 | * :ref:`modindex` 20 | * :ref:`search` 21 | -------------------------------------------------------------------------------- /plugin_cmds/README.md: -------------------------------------------------------------------------------- 1 | # Plugin Commands 2 | 3 | ## Command priority order 4 | 5 | Commands will be added in the following order: 6 | 7 | 1. plugin dir commands 8 | 2. plugin packages commands 9 | 3. build-in commands 10 | 11 | If a command is added, all further commands with the same name will be ignored. 12 | This enables you to "replace" build-in commands very easy. 13 | 14 | ## Location 15 | 16 | Audible-cli expected plugin commands in the `plugins` subdir of the app dir. You can provide a custom dir with the ``AUDIBLE_PLUGIN_DIR`` environment variable. 17 | 18 | ## Commands in this folder 19 | 20 | To use commands in these folder simply copy them to the plugin folder. 21 | 22 | ## Custom Commands 23 | 24 | You can provide own subcommands and execute them with `audible SUBCOMMAND`. 25 | All plugin commands must be placed in the plugin folder. Every subcommand must 26 | have his own file. Every file have to be named ``cmd_{SUBCOMMAND}.py``. 27 | Each subcommand file must have a function called `cli` as entrypoint. 28 | This function have to be decorated with ``@click.group(name="GROUP_NAME")`` or 29 | ``@click.command(name="GROUP_NAME")``. 30 | 31 | Relative imports in the command files doesn't work. So you have to work with 32 | absolute imports. Please take care about this. 33 | 34 | ## Own Plugin Packages 35 | 36 | If you want to develop a complete plugin package for ``audible-cli`` you can 37 | do this on an easy way. You only need to register your sub-commands or 38 | sub-groups to an entry-point in your setup.py that is loaded by the core 39 | package. 40 | 41 | Example for a setup.py 42 | 43 | ```python 44 | from setuptools import setup 45 | 46 | setup( 47 | name="yourscript", 48 | version="0.1", 49 | py_modules=["yourscript"], 50 | install_requires=[ 51 | "click", 52 | "audible_cli" 53 | ], 54 | entry_points=""" 55 | [audible.cli_plugins] 56 | cool_subcommand=yourscript.cli:cool_subcommand 57 | another_subcommand=yourscript.cli:another_subcommand 58 | """, 59 | ) 60 | ``` 61 | -------------------------------------------------------------------------------- /plugin_cmds/cmd_decrypt.py: -------------------------------------------------------------------------------- 1 | """Removes encryption of aax and aaxc files. 2 | 3 | This is a proof-of-concept and for testing purposes only. 4 | 5 | No error handling. 6 | Need further work. Some options do not work or options are missing. 7 | 8 | Needs at least ffmpeg 4.4 9 | """ 10 | 11 | 12 | import json 13 | import operator 14 | import pathlib 15 | import re 16 | import subprocess # noqa: S404 17 | import tempfile 18 | import typing as t 19 | from enum import Enum 20 | from functools import reduce 21 | from glob import glob 22 | from shutil import which 23 | 24 | import click 25 | from click import echo, secho 26 | 27 | from audible_cli.decorators import pass_session 28 | from audible_cli.exceptions import AudibleCliException 29 | 30 | 31 | class ChapterError(AudibleCliException): 32 | """Base class for all chapter errors.""" 33 | 34 | 35 | class SupportedFiles(Enum): 36 | AAX = ".aax" 37 | AAXC = ".aaxc" 38 | 39 | @classmethod 40 | def get_supported_list(cls): 41 | return list(set(item.value for item in cls)) 42 | 43 | @classmethod 44 | def is_supported_suffix(cls, value): 45 | return value in cls.get_supported_list() 46 | 47 | @classmethod 48 | def is_supported_file(cls, value): 49 | return pathlib.PurePath(value).suffix in cls.get_supported_list() 50 | 51 | 52 | def _get_input_files( 53 | files: t.Union[t.Tuple[str], t.List[str]], 54 | recursive: bool = True 55 | ) -> t.List[pathlib.Path]: 56 | filenames = [] 57 | for filename in files: 58 | # if the shell does not do filename globbing 59 | expanded = list(glob(filename, recursive=recursive)) 60 | 61 | if ( 62 | len(expanded) == 0 63 | and '*' not in filename 64 | and not SupportedFiles.is_supported_file(filename) 65 | ): 66 | raise click.BadParameter("{filename}: file not found or supported.") 67 | 68 | expanded_filter = filter( 69 | lambda x: SupportedFiles.is_supported_file(x), expanded 70 | ) 71 | expanded = list(map(lambda x: pathlib.Path(x).resolve(), expanded_filter)) 72 | filenames.extend(expanded) 73 | 74 | return filenames 75 | 76 | 77 | def recursive_lookup_dict(key: str, dictionary: t.Dict[str, t.Any]) -> t.Any: 78 | if key in dictionary: 79 | return dictionary[key] 80 | for value in dictionary.values(): 81 | if isinstance(value, dict): 82 | try: 83 | item = recursive_lookup_dict(key, value) 84 | except KeyError: 85 | continue 86 | else: 87 | return item 88 | 89 | raise KeyError 90 | 91 | 92 | def get_aaxc_credentials(voucher_file: pathlib.Path): 93 | if not voucher_file.exists() or not voucher_file.is_file(): 94 | raise AudibleCliException(f"Voucher file {voucher_file} not found.") 95 | 96 | voucher_dict = json.loads(voucher_file.read_text()) 97 | try: 98 | key = recursive_lookup_dict("key", voucher_dict) 99 | iv = recursive_lookup_dict("iv", voucher_dict) 100 | except KeyError: 101 | raise AudibleCliException(f"No key/iv found in file {voucher_file}.") from None 102 | 103 | return key, iv 104 | 105 | 106 | class ApiChapterInfo: 107 | def __init__(self, content_metadata: t.Dict[str, t.Any]) -> None: 108 | chapter_info = self._parse(content_metadata) 109 | self._chapter_info = chapter_info 110 | 111 | @classmethod 112 | def from_file(cls, file: t.Union[pathlib.Path, str]) -> "ApiChapterInfo": 113 | file = pathlib.Path(file) 114 | if not file.exists() or not file.is_file(): 115 | raise ChapterError(f"Chapter file {file} not found.") 116 | content_string = pathlib.Path(file).read_text("utf-8") 117 | content_json = json.loads(content_string) 118 | return cls(content_json) 119 | 120 | @staticmethod 121 | def _parse(content_metadata: t.Dict[str, t.Any]) -> t.Dict[str, t.Any]: 122 | if "chapters" in content_metadata: 123 | return content_metadata 124 | 125 | try: 126 | return recursive_lookup_dict("chapter_info", content_metadata) 127 | except KeyError: 128 | raise ChapterError("No chapter info found.") from None 129 | 130 | def count_chapters(self): 131 | return len(self.get_chapters()) 132 | 133 | def get_chapters(self, separate_intro_outro=False, remove_intro_outro=False): 134 | def extract_chapters(initial, current): 135 | if "chapters" in current: 136 | return initial + [current] + current["chapters"] 137 | else: 138 | return initial + [current] 139 | 140 | chapters = list( 141 | reduce( 142 | extract_chapters, 143 | self._chapter_info["chapters"], 144 | [], 145 | ) 146 | ) 147 | 148 | if separate_intro_outro: 149 | return self._separate_intro_outro(chapters) 150 | elif remove_intro_outro: 151 | return self._remove_intro_outro(chapters) 152 | 153 | return chapters 154 | 155 | def get_intro_duration_ms(self): 156 | return self._chapter_info["brandIntroDurationMs"] 157 | 158 | def get_outro_duration_ms(self): 159 | return self._chapter_info["brandOutroDurationMs"] 160 | 161 | def get_runtime_length_ms(self): 162 | return self._chapter_info["runtime_length_ms"] 163 | 164 | def is_accurate(self): 165 | return self._chapter_info["is_accurate"] 166 | 167 | def _separate_intro_outro(self, chapters): 168 | echo("Separate Audible Brand Intro and Outro to own Chapter.") 169 | chapters.sort(key=operator.itemgetter("start_offset_ms")) 170 | 171 | first = chapters[0] 172 | intro_dur_ms = self.get_intro_duration_ms() 173 | first["start_offset_ms"] = intro_dur_ms 174 | first["start_offset_sec"] = round(first["start_offset_ms"] / 1000) 175 | first["length_ms"] -= intro_dur_ms 176 | 177 | last = chapters[-1] 178 | outro_dur_ms = self.get_outro_duration_ms() 179 | last["length_ms"] -= outro_dur_ms 180 | 181 | chapters.append( 182 | { 183 | "length_ms": intro_dur_ms, 184 | "start_offset_ms": 0, 185 | "start_offset_sec": 0, 186 | "title": "Intro", 187 | } 188 | ) 189 | chapters.append( 190 | { 191 | "length_ms": outro_dur_ms, 192 | "start_offset_ms": self.get_runtime_length_ms() - outro_dur_ms, 193 | "start_offset_sec": round( 194 | (self.get_runtime_length_ms() - outro_dur_ms) / 1000 195 | ), 196 | "title": "Outro", 197 | } 198 | ) 199 | chapters.sort(key=operator.itemgetter("start_offset_ms")) 200 | 201 | return chapters 202 | 203 | def _remove_intro_outro(self, chapters): 204 | echo("Delete Audible Brand Intro and Outro.") 205 | chapters.sort(key=operator.itemgetter("start_offset_ms")) 206 | 207 | intro_dur_ms = self.get_intro_duration_ms() 208 | outro_dur_ms = self.get_outro_duration_ms() 209 | 210 | first = chapters[0] 211 | first["length_ms"] -= intro_dur_ms 212 | 213 | for chapter in chapters[1:]: 214 | chapter["start_offset_ms"] -= intro_dur_ms 215 | chapter["start_offset_sec"] -= round(chapter["start_offset_ms"] / 1000) 216 | 217 | last = chapters[-1] 218 | last["length_ms"] -= outro_dur_ms 219 | 220 | return chapters 221 | 222 | class FFMeta: 223 | SECTION = re.compile(r"\[(?P
[^]]+)\]") 224 | OPTION = re.compile(r"(?P