diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a012e4..1f51b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,36 @@ 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 @@ -91,7 +120,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - 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 tiltes with hyphens (#96) +- 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) @@ -99,7 +128,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `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) +- 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 @@ -162,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 releases in onedir mode +- build macOS releases in `onedir` mode ### Bugfix diff --git a/README.md b/README.md index 4664543..5b6a0f9 100644 --- a/README.md +++ b/README.md @@ -154,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 @@ -162,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 diff --git a/plugin_cmds/cmd_decrypt.py b/plugin_cmds/cmd_decrypt.py index 411a398..8a09304 100644 --- a/plugin_cmds/cmd_decrypt.py +++ b/plugin_cmds/cmd_decrypt.py @@ -19,7 +19,6 @@ import typing as t from enum import Enum from functools import reduce from glob import glob -from shlex import quote from shutil import which import click @@ -64,7 +63,7 @@ def _get_input_files( and '*' not in filename and not SupportedFiles.is_supported_file(filename) ): - raise(click.BadParameter("{filename}: file not found or supported.")) + raise click.BadParameter("{filename}: file not found or supported.") expanded_filter = filter( lambda x: SupportedFiles.is_supported_file(x), expanded @@ -131,7 +130,7 @@ class ApiChapterInfo: def count_chapters(self): return len(self.get_chapters()) - def get_chapters(self, separate_intro_outro=False): + 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"] @@ -148,6 +147,8 @@ class ApiChapterInfo: if separate_intro_outro: return self._separate_intro_outro(chapters) + elif remove_intro_outro: + return self._remove_intro_outro(chapters) return chapters @@ -199,6 +200,24 @@ class ApiChapterInfo: 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
[^]]+)\]") @@ -272,7 +291,8 @@ class FFMeta: self, chapter_info: ApiChapterInfo, force_rebuild_chapters: bool = False, - separate_intro_outro: bool = False + separate_intro_outro: bool = False, + remove_intro_outro: bool = False ) -> None: if not chapter_info.is_accurate(): echo("Metadata from API is not accurate. Skip.") @@ -286,7 +306,7 @@ class FFMeta: echo(f"Found {chapter_info.count_chapters()} chapters to prepare.") - api_chapters = chapter_info.get_chapters(separate_intro_outro) + api_chapters = chapter_info.get_chapters(separate_intro_outro, remove_intro_outro) num_chap = 0 new_chapters = {} @@ -301,6 +321,20 @@ class FFMeta: "title": chapter["title"], } self._ffmeta_parsed["CHAPTER"] = new_chapters + + def get_start_end_without_intro_outro( + self, + chapter_info: ApiChapterInfo, + ): + intro_dur_ms = chapter_info.get_intro_duration_ms() + outro_dur_ms = chapter_info.get_outro_duration_ms() + total_runtime_ms = chapter_info.get_runtime_length_ms() + + start_new = intro_dur_ms + duration_new = total_runtime_ms - intro_dur_ms - outro_dur_ms + + return start_new, duration_new + def _get_voucher_filename(file: pathlib.Path) -> pathlib.Path: @@ -329,7 +363,8 @@ class FfmpegFileDecrypter: rebuild_chapters: bool, force_rebuild_chapters: bool, skip_rebuild_chapters: bool, - separate_intro_outro: bool + separate_intro_outro: bool, + remove_intro_outro: bool ) -> None: file_type = SupportedFiles(file.suffix) @@ -354,6 +389,7 @@ class FfmpegFileDecrypter: self._force_rebuild_chapters = force_rebuild_chapters self._skip_rebuild_chapters = skip_rebuild_chapters self._separate_intro_outro = separate_intro_outro + self._remove_intro_outro = remove_intro_outro self._api_chapter: t.Optional[ApiChapterInfo] = None self._ffmeta: t.Optional[FFMeta] = None self._is_rebuilded: bool = False @@ -385,20 +421,20 @@ class FfmpegFileDecrypter: key, iv = self._credentials credentials_cmd = [ "-audible_key", - quote(key), + key, "-audible_iv", - quote(iv), + iv, ] else: credentials_cmd = [ "-activation_bytes", - quote(self._credentials), + self._credentials, ] base_cmd.extend(credentials_cmd) extract_cmd = [ "-i", - quote(str(self._source)), + str(self._source), "-f", "ffmetadata", str(metafile), @@ -413,7 +449,7 @@ class FfmpegFileDecrypter: def rebuild_chapters(self) -> None: if not self._is_rebuilded: self.ffmeta.update_chapters_from_chapter_info( - self.api_chapter, self._force_rebuild_chapters, self._separate_intro_outro + self.api_chapter, self._force_rebuild_chapters, self._separate_intro_outro, self._remove_intro_outro ) self._is_rebuilded = True @@ -440,22 +476,16 @@ class FfmpegFileDecrypter: key, iv = self._credentials credentials_cmd = [ "-audible_key", - quote(key), + key, "-audible_iv", - quote(iv), + iv, ] else: credentials_cmd = [ "-activation_bytes", - quote(self._credentials), + self._credentials, ] base_cmd.extend(credentials_cmd) - base_cmd.extend( - [ - "-i", - quote(str(self._source)), - ] - ) if self._rebuild_chapters: metafile = _get_ffmeta_file(self._source, self._tempdir) @@ -468,22 +498,51 @@ class FfmpegFileDecrypter: else: raise else: - base_cmd.extend( - [ - "-i", - quote(str(metafile)), - "-map_metadata", - "0", - "-map_chapters", - "1", - ] - ) + if self._remove_intro_outro: + start_new, duration_new = self.ffmeta.get_start_end_without_intro_outro(self.api_chapter) + + base_cmd.extend( + [ + "-ss", + f"{start_new}ms", + "-t", + f"{duration_new}ms", + "-i", + str(self._source), + "-i", + str(metafile), + "-map_metadata", + "0", + "-map_chapters", + "1", + ] + ) + else: + base_cmd.extend( + [ + "-i", + str(self._source), + "-i", + str(metafile), + "-map_metadata", + "0", + "-map_chapters", + "1", + ] + ) + else: + base_cmd.extend( + [ + "-i", + str(self._source), + ] + ) base_cmd.extend( [ "-c", "copy", - quote(str(outfile)), + str(outfile), ] ) @@ -544,6 +603,15 @@ class FfmpegFileDecrypter: "Only use with `--rebuild-chapters`." ), ) +@click.option( + "--remove-intro-outro", + "-c", + is_flag=True, + help=( + "Remove Audible Brand Intro and Outro. " + "Only use with `--rebuild-chapters`." + ), +) @pass_session def cli( session, @@ -555,6 +623,7 @@ def cli( force_rebuild_chapters: bool, skip_rebuild_chapters: bool, separate_intro_outro: bool, + remove_intro_outro: bool, ): """Decrypt audiobooks downloaded with audible-cli. @@ -568,21 +637,30 @@ def cli( ctx = click.get_current_context() ctx.fail("ffmpeg not found") - if (force_rebuild_chapters or skip_rebuild_chapters or separate_intro_outro) and not rebuild_chapters: + if (force_rebuild_chapters or skip_rebuild_chapters or separate_intro_outro or remove_intro_outro) and not rebuild_chapters: raise click.BadOptionUsage( - "`--force-rebuild-chapters`, `--skip-rebuild-chapters` and `--separate-intro-outro` can " - "only be used together with `--rebuild-chapters`" + "", + "`--force-rebuild-chapters`, `--skip-rebuild-chapters`, `--separate-intro-outro` " + "and `--remove-intro-outro` can only be used together with `--rebuild-chapters`" ) if force_rebuild_chapters and skip_rebuild_chapters: raise click.BadOptionUsage( + "", "`--force-rebuild-chapters` and `--skip-rebuild-chapters` can " "not be used together" ) + if separate_intro_outro and remove_intro_outro: + raise click.BadOptionUsage( + "", + "`--separate-intro-outro` and `--remove-intro-outro` can not be used together" + ) + if all_: if files: raise click.BadOptionUsage( + "", "If using `--all`, no FILES arguments can be used." ) files = [f"*{suffix}" for suffix in SupportedFiles.get_supported_list()] @@ -599,6 +677,7 @@ def cli( rebuild_chapters=rebuild_chapters, force_rebuild_chapters=force_rebuild_chapters, skip_rebuild_chapters=skip_rebuild_chapters, - separate_intro_outro=separate_intro_outro + separate_intro_outro=separate_intro_outro, + remove_intro_outro=remove_intro_outro ) decrypter.run() diff --git a/setup.py b/setup.py index 7e0ca65..cb7563b 100644 --- a/setup.py +++ b/setup.py @@ -49,7 +49,7 @@ setup( "audible>=0.8.2", "click>=8", "colorama; platform_system=='Windows'", - "httpx>=0.23.3,<0.26.0", + "httpx>=0.23.3,<0.28.0", "packaging", "Pillow", "tabulate", diff --git a/src/audible_cli/_version.py b/src/audible_cli/_version.py index 416f55b..644bcfe 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.3.0" +__version__ = "0.3.2b3" __author__ = "mkb79" __author_email__ = "mkb79@hackitall.de" __license__ = "AGPL" diff --git a/src/audible_cli/cli.py b/src/audible_cli/cli.py index d896443..4f70d64 100644 --- a/src/audible_cli/cli.py +++ b/src/audible_cli/cli.py @@ -17,9 +17,9 @@ from .exceptions import AudibleCliException from ._logging import click_basic_config from . import plugins -try: +if sys.version_info >= (3, 10): from importlib.metadata import entry_points -except ImportError: # Python < 3.10 (backport) +else: # Python < 3.10 (backport) from importlib_metadata import entry_points diff --git a/src/audible_cli/cmds/cmd_download.py b/src/audible_cli/cmds/cmd_download.py index 20282c4..3b7a2b1 100644 --- a/src/audible_cli/cmds/cmd_download.py +++ b/src/audible_cli/cmds/cmd_download.py @@ -203,7 +203,7 @@ async def download_pdf( async def download_chapters( - output_dir, base_filename, item, quality, overwrite_existing + output_dir, base_filename, item, quality, overwrite_existing, chapter_type ): if not output_dir.is_dir(): raise DirectoryDoesNotExists(output_dir) @@ -217,7 +217,7 @@ async def download_chapters( return True try: - metadata = await item.get_content_metadata(quality) + metadata = await item.get_content_metadata(quality, chapter_type=chapter_type) except NotFoundError: logger.info( f"No chapters found for {item.full_title}." @@ -226,7 +226,7 @@ async def download_chapters( metadata = json.dumps(metadata, indent=4) async with aiofiles.open(file, "w") as f: await f.write(metadata) - logger.info(f"Chapter file saved to {file}.") + logger.info(f"Chapter file saved in style '{chapter_type.upper()}' to {file}.") counter.count_chapter() @@ -291,6 +291,7 @@ async def _add_audioparts_to_queue( get_pdf=None, get_annotation=None, get_chapters=None, + chapter_type=None, get_aax=get_aax, get_aaxc=get_aaxc, client=client, @@ -521,6 +522,7 @@ def queue_job( filename_mode, item, cover_sizes, + chapter_type, quality, overwrite_existing, aax_fallback @@ -558,7 +560,8 @@ def queue_job( "base_filename": base_filename, "item": item, "quality": quality, - "overwrite_existing": overwrite_existing + "overwrite_existing": overwrite_existing, + "chapter_type": chapter_type } QUEUE.put_nowait((cmd, kwargs)) @@ -686,7 +689,13 @@ def display_counter(): @click.option( "--chapter", is_flag=True, - help="saves chapter metadata as JSON file" + help="Saves chapter metadata as JSON file." +) +@click.option( + "--chapter-type", + default="config", + type=click.Choice(["Flat", "Tree", "config"], case_sensitive=False), + help="The chapter type." ) @click.option( "--annotation", @@ -749,8 +758,10 @@ async def cli(session, api_client, **params): asins = params.get("asin") titles = params.get("title") if get_all and (asins or titles): - logger.error("Do not mix *asin* or *title* option with *all* option.") - click.Abort() + raise click.BadOptionUsage( + "--all", + "`--all` can not be used together with `--asin` or `--title`" + ) # what to download get_aax = params.get("aax") @@ -771,8 +782,10 @@ async def cli(session, api_client, **params): 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.") - raise click.Abort() + raise click.BadOptionUsage( + "", + "Please select an option what you want download." + ) # additional options sim_jobs = params.get("jobs") @@ -781,15 +794,22 @@ async def cli(session, api_client, **params): overwrite_existing = params.get("overwrite") ignore_errors = params.get("ignore_errors") no_confirm = params.get("no_confirm") - resolve_podcats = params.get("resolve_podcasts") + resolve_podcasts = params.get("resolve_podcasts") ignore_podcasts = params.get("ignore_podcasts") + if all([resolve_podcasts, ignore_podcasts]): + raise click.BadOptionUsage( + "", + "Do not mix *ignore-podcasts* with *resolve-podcasts* option." + ) bunch_size = session.params.get("bunch_size") start_date = session.params.get("start_date") end_date = session.params.get("end_date") if all([start_date, end_date]) and start_date > end_date: - logger.error("start date must be before or equal the end date") - raise click.Abort() + raise click.BadOptionUsage( + "", + "start date must be before or equal the end date" + ) if start_date is not None: logger.info( @@ -800,6 +820,11 @@ async def cli(session, api_client, **params): f"Selected end date: {end_date.strftime('%Y-%m-%dT%H:%M:%S.%fZ')}" ) + chapter_type = params.get("chapter_type") + if chapter_type == "config": + chapter_type = session.config.get_profile_option( + session.selected_profile, "chapter_type") or "Tree" + filename_mode = params.get("filename_mode") if filename_mode == "config": filename_mode = session.config.get_profile_option( @@ -819,8 +844,9 @@ async def cli(session, api_client, **params): status="Active", ) - if resolve_podcats: - await library.resolve_podcats(start_date=start_date, end_date=end_date) + if resolve_podcasts: + await library.resolve_podcasts(start_date=start_date, end_date=end_date) + [library.data.remove(i) for i in library if i.is_parent_podcast()] # collect jobs jobs = [] @@ -837,7 +863,7 @@ async def cli(session, api_client, **params): else: if not ignore_errors: logger.error(f"Asin {asin} not found in library.") - click.Abort() + raise click.Abort() logger.error( f"Skip asin {asin}: Not found in library" ) @@ -878,7 +904,10 @@ async def cli(session, api_client, **params): items = [item] odir = pathlib.Path(output_dir) - if not ignore_podcasts and item.is_parent_podcast(): + if item.is_parent_podcast(): + if ignore_podcasts: + continue + items.remove(item) if item._children is None: await item.get_child_items( @@ -907,6 +936,7 @@ async def cli(session, api_client, **params): filename_mode=filename_mode, item=item, cover_sizes=cover_sizes, + chapter_type=chapter_type, quality=quality, overwrite_existing=overwrite_existing, aax_fallback=aax_fallback diff --git a/src/audible_cli/cmds/cmd_library.py b/src/audible_cli/cmds/cmd_library.py index b38699f..956a529 100644 --- a/src/audible_cli/cmds/cmd_library.py +++ b/src/audible_cli/cmds/cmd_library.py @@ -45,7 +45,7 @@ async def _get_library(session, client, resolve_podcasts): ) if resolve_podcasts: - await library.resolve_podcats(start_date=start_date, end_date=end_date) + await library.resolve_podcasts(start_date=start_date, end_date=end_date) return library diff --git a/src/audible_cli/decorators.py b/src/audible_cli/decorators.py index 3f8397b..b2f1c5f 100644 --- a/src/audible_cli/decorators.py +++ b/src/audible_cli/decorators.py @@ -95,7 +95,7 @@ def version_option(func=None, **kwargs): response.raise_for_status() except Exception as e: logger.error(e) - click.Abort() + raise click.Abort() content = response.json() diff --git a/src/audible_cli/downloader.py b/src/audible_cli/downloader.py index 61b4dbe..2e04d58 100644 --- a/src/audible_cli/downloader.py +++ b/src/audible_cli/downloader.py @@ -322,18 +322,31 @@ class Downloader: async def get_head_response(self, force_recreate: bool = False) -> ResponseInfo: if self._head_request is None or force_recreate: - head_response = await self._client.head( - self._source, headers=self._additional_headers, follow_redirects=True, - ) - self._head_request = ResponseInfo(head_response) + # switched from HEAD to GET request without loading the body + # HEAD request to cds.audible.de will responded in 1 - 2 minutes + # a GET request to the same URI will take ~4-6 seconds + async with self._client.stream( + "GET", self._source, headers=self._additional_headers, + follow_redirects=True, + ) as head_response: + if head_response.request.url != self._source: + self._source = head_response.request.url + self._head_request = ResponseInfo(head_response) return self._head_request async def _determine_resume_file(self, target_file: File) -> File: head_response = await self.get_head_response() etag = head_response.etag - resume_name = target_file.path if etag is None else etag.parsed_etag - resume_file = pathlib.Path(resume_name).with_suffix(self.RESUME_SUFFIX) + + if etag is None: + resume_name = target_file.path + else: + parsed_etag = etag.parsed_etag + resume_name = target_file.path.with_name(parsed_etag) + + resume_file = resume_name.with_suffix(self.RESUME_SUFFIX) + return File(resume_file) def _determine_tmp_file(self, target_file: File) -> File: diff --git a/src/audible_cli/models.py b/src/audible_cli/models.py index f9d2c44..b8dc839 100644 --- a/src/audible_cli/models.py +++ b/src/audible_cli/models.py @@ -6,6 +6,7 @@ import unicodedata from datetime import datetime from math import ceil from typing import List, Optional, Union +from warnings import warn import audible import httpx @@ -391,15 +392,21 @@ class LibraryItem(BaseItem): return lr - async def get_content_metadata(self, quality: str = "high"): + async def get_content_metadata( + self, quality: str = "high", chapter_type: str = "Tree", **request_kwargs + ): + chapter_type = chapter_type.capitalize() assert quality in ("best", "high", "normal",) + assert chapter_type in ("Flat", "Tree") url = f"content/{self.asin}/metadata" params = { "response_groups": "last_position_heard, content_reference, " "chapter_info", "quality": "High" if quality in ("best", "high") else "Normal", - "drm_type": "Adrm" + "drm_type": "Adrm", + "chapter_titles_type": chapter_type, + **request_kwargs } metadata = await self._client.get(url, params=params) @@ -597,6 +604,18 @@ class Library(BaseList): self, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None + ): + warn( + "resolve_podcats is deprecated, use resolve_podcasts instead", + DeprecationWarning, + stacklevel=2 + ) + return self.resolve_podcasts(start_date, end_date) + + async def resolve_podcasts( + self, + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None ): podcast_items = await asyncio.gather( *[i.get_child_items(start_date=start_date, end_date=end_date) @@ -662,7 +681,7 @@ class Catalog(BaseList): return cls(resp, api_client=api_client) - async def resolve_podcats(self): + async def resolve_podcasts(self): podcast_items = await asyncio.gather( *[i.get_child_items() for i in self if i.is_parent_podcast()] )