Coverage for odmpy/odm.py: 84.8%
387 statements
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-14 08:51 +0000
« prev ^ index » next coverage.py v7.3.1, created at 2023-09-14 08:51 +0000
1# Copyright (C) 2018 github.com/ping
2#
3# This file is part of odmpy.
4#
5# odmpy is free software: you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation, either version 3 of the License, or
8# (at your option) any later version.
9#
10# odmpy is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with odmpy. If not, see <http://www.gnu.org/licenses/>.
17#
19import argparse
20import io
21import json
22import logging
23import os
24import sys
25import time
26from http.client import HTTPConnection
27from pathlib import Path
28from typing import Dict, List, Optional
30from termcolor import colored
32from .cli_utils import (
33 OdmpyCommands,
34 OdmpyNoninteractiveOptions,
35 positive_int,
36 valid_book_folder_file_format,
37 DEFAULT_FORMAT_FIELDS,
38)
39from .errors import LibbyNotConfiguredError, OdmpyRuntimeError
40from .libby import LibbyClient, LibbyFormats
41from .libby_errors import ClientBadRequestError, ClientError
42from .overdrive import OverDriveClient
43from .processing import (
44 process_odm,
45 process_audiobook_loan,
46 process_odm_return,
47 process_ebook_loan,
48)
49from .processing.shared import (
50 generate_names,
51 generate_cover,
52 get_best_cover_url,
53 init_session,
54 extract_authors_from_openbook,
55)
56from .utils import slugify, plural_or_singular_noun as ps
58#
59# Orchestrates the interaction between the CLI, APIs and the processing bits
60#
62logger = logging.getLogger(__name__)
63requests_logger = logging.getLogger("urllib3")
64ch = logging.StreamHandler(io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
65ch.setLevel(logging.DEBUG)
66logger.addHandler(ch)
67logger.setLevel(logging.INFO)
68requests_logger.addHandler(ch)
69requests_logger.setLevel(logging.ERROR)
70requests_logger.propagate = True
72__version__ = "0.8.1" # also update ../setup.py
73TAGS_ENDPOINT = "https://api.github.com/repos/ping/odmpy/tags"
74REPOSITORY_URL = "https://github.com/ping/odmpy"
75OLD_SETTINGS_FOLDER_DEFAULT = Path("./odmpy_settings")
78def check_version(timeout: int, max_retries: int) -> None:
79 sess = init_session(max_retries)
80 # noinspection PyBroadException
81 try:
82 res = sess.get(TAGS_ENDPOINT, timeout=timeout)
83 res.raise_for_status()
84 curr_version = res.json()[0].get("name", "")
85 if curr_version and curr_version != __version__:
86 logger.warning(
87 f"⚠️ A new version {curr_version} is available at {REPOSITORY_URL}."
88 )
89 except: # noqa: E722, pylint: disable=bare-except
90 pass
93def add_common_libby_arguments(parser_libby: argparse.ArgumentParser) -> None:
94 parser_libby.add_argument(
95 "--settings",
96 dest="settings_folder",
97 type=str,
98 default="",
99 metavar="SETTINGS_FOLDER",
100 help="Settings folder to store odmpy required settings, e.g. Libby authentication.",
101 )
102 parser_libby.add_argument(
103 "--ebooks",
104 dest="include_ebooks",
105 default=False,
106 action="store_true",
107 help=(
108 "Include ebook (EPUB/PDF) loans (experimental). An EPUB/PDF (DRM) loan will be downloaded as an .acsm file"
109 "\nwhich can be opened in Adobe Digital Editions for offline reading."
110 "\nRefer to https://help.overdrive.com/en-us/0577.html and "
111 "\nhttps://help.overdrive.com/en-us/0005.html for more information."
112 "\nAn open EPUB/PDF (no DRM) loan will be downloaded as an .epub/.pdf file which can be opened"
113 "\nin any EPUB/PDF-compatible reader."
114 if parser_libby.prog == f"odmpy {OdmpyCommands.Libby}"
115 else "Include ebook (EPUB/PDF) loans."
116 ),
117 )
118 parser_libby.add_argument(
119 "--magazines",
120 dest="include_magazines",
121 default=False,
122 action="store_true",
123 help=(
124 "Include magazines loans (experimental)."
125 if parser_libby.prog == f"odmpy {OdmpyCommands.Libby}"
126 else "Include magazines loans."
127 ),
128 )
129 parser_libby.add_argument(
130 "--noaudiobooks",
131 dest="exclude_audiobooks",
132 default=False,
133 action="store_true",
134 help="Exclude audiobooks.",
135 )
138def add_common_download_arguments(parser_dl: argparse.ArgumentParser) -> None:
139 """
140 Add common arguments needed for downloading
142 :param parser_dl:
143 :return:
144 """
145 parser_dl.add_argument(
146 "-d",
147 "--downloaddir",
148 dest="download_dir",
149 default=".",
150 help="Download folder path.",
151 )
152 parser_dl.add_argument(
153 "-c",
154 "--chapters",
155 dest="add_chapters",
156 action="store_true",
157 help="Add chapter marks (experimental). For audiobooks.",
158 )
159 parser_dl.add_argument(
160 "-m",
161 "--merge",
162 dest="merge_output",
163 action="store_true",
164 help="Merge into 1 file (experimental, requires ffmpeg). For audiobooks.",
165 )
166 parser_dl.add_argument(
167 "--mergeformat",
168 dest="merge_format",
169 choices=["mp3", "m4b"],
170 default="mp3",
171 help="Merged file format (m4b is slow, experimental, requires ffmpeg). For audiobooks.",
172 )
173 parser_dl.add_argument(
174 "--mergecodec",
175 dest="merge_codec",
176 choices=["aac", "libfdk_aac"],
177 default="aac",
178 help="Audio codec of merged m4b file. (requires ffmpeg; using libfdk_aac requires ffmpeg compiled with libfdk_aac support). For audiobooks. Has no effect if mergeformat is not set to m4b.",
179 )
180 parser_dl.add_argument(
181 "-k",
182 "--keepcover",
183 dest="always_keep_cover",
184 action="store_true",
185 help="Always generate the cover image file (cover.jpg).",
186 )
187 parser_dl.add_argument(
188 "-f",
189 "--keepmp3",
190 dest="keep_mp3",
191 action="store_true",
192 help="Keep downloaded mp3 files (after merging). For audiobooks.",
193 )
194 parser_dl.add_argument(
195 "--nobookfolder",
196 dest="no_book_folder",
197 action="store_true",
198 help="Don't create a book subfolder.",
199 )
201 available_fields_help = [
202 "%%(Title)s : Title",
203 "%%(Author)s: Comma-separated Author names",
204 "%%(Series)s: Series",
205 ]
206 if parser_dl.prog == "odmpy libby":
207 available_fields_help.append("%%(ReadingOrder)s: Series Reading Order")
208 available_fields_help.extend(["%%(Edition)s: Edition", "%%(ID)s: Title/Loan ID"])
209 available_fields_help_text = "Available fields:\n " + "\n ".join(
210 available_fields_help
211 )
213 available_fields = list(DEFAULT_FORMAT_FIELDS)
214 if parser_dl.prog != "odmpy libby":
215 available_fields.remove("ReadingOrder")
217 parser_dl.add_argument(
218 "--bookfolderformat",
219 dest="book_folder_format",
220 type=lambda v: valid_book_folder_file_format(v, tuple(available_fields)),
221 default="%(Title)s - %(Author)s",
222 help=f'Book folder format string. Default "%%(Title)s - %%(Author)s".\n{available_fields_help_text}',
223 )
224 parser_dl.add_argument(
225 "--bookfileformat",
226 dest="book_file_format",
227 type=lambda v: valid_book_folder_file_format(v, tuple(available_fields)),
228 default="%(Title)s - %(Author)s",
229 help=(
230 'Book file format string (without extension). Default "%%(Title)s - %%(Author)s".\n'
231 f"This applies to only merged audiobooks, ebooks, and magazines.\n{available_fields_help_text}"
232 ),
233 )
234 parser_dl.add_argument(
235 "--removefrompaths",
236 dest="remove_from_paths",
237 metavar="ILLEGAL_CHARS",
238 type=str,
239 help=r'Remove characters in string specified from folder and file names, example "<>:"/\|?*"',
240 )
241 parser_dl.add_argument(
242 "--overwritetags",
243 dest="overwrite_tags",
244 action="store_true",
245 help=(
246 "Always overwrite ID3 tags.\n"
247 "By default odmpy tries to non-destructively tag audiofiles.\n"
248 "This option forces odmpy to overwrite tags where possible. For audiobooks."
249 ),
250 )
251 parser_dl.add_argument(
252 "--tagsdelimiter",
253 dest="tag_delimiter",
254 metavar="DELIMITER",
255 type=str,
256 default=";",
257 help=(
258 "For ID3 tags with multiple values, this defines the delimiter.\n"
259 'For example, with the default delimiter ";", authors are written\n'
260 'to the artist tag as "Author A;Author B;Author C". For audiobooks.'
261 ),
262 )
263 parser_dl.add_argument(
264 "--id3v2version",
265 dest="id3v2_version",
266 type=int,
267 default=4,
268 choices=[3, 4],
269 help="ID3 v2 version. 3 = v2.3, 4 = v2.4",
270 )
271 parser_dl.add_argument(
272 "--opf",
273 dest="generate_opf",
274 action="store_true",
275 help="Generate an OPF file for the downloaded audiobook/magazine/ebook.",
276 )
277 parser_dl.add_argument(
278 "-r",
279 "--retry",
280 dest="obsolete_retries",
281 type=int,
282 default=0,
283 help="Obsolete. Do not use.",
284 )
285 parser_dl.add_argument(
286 "-j",
287 "--writejson",
288 dest="write_json",
289 action="store_true",
290 help="Generate a meta json file (for debugging).",
291 )
292 parser_dl.add_argument(
293 "--hideprogress",
294 dest="hide_progress",
295 action="store_true",
296 help="Hide the download progress bar (e.g. during testing).",
297 )
300def extract_bundled_contents(
301 libby_client: LibbyClient,
302 overdrive_client: OverDriveClient,
303 selected_loan: Dict,
304 cards: List[Dict],
305 args: argparse.Namespace,
306):
307 format_id = libby_client.get_loan_format(selected_loan)
308 format_info: Dict = next(
309 iter([f for f in selected_loan.get("formats", []) if f["id"] == format_id]),
310 {},
311 )
312 card: Dict = next(
313 iter([c for c in cards if c["cardId"] == selected_loan["cardId"]]), {}
314 )
315 if format_info.get("isBundleParent") and format_info.get("bundledContent", []):
316 bundled_contents_ids = list(
317 set([bc["titleId"] for bc in format_info["bundledContent"]])
318 )
319 for bundled_content_id in bundled_contents_ids:
320 bundled_media = overdrive_client.library_media(
321 card["advantageKey"], bundled_content_id
322 )
323 if not libby_client.is_downloadable_ebook_loan(bundled_media):
324 continue
325 try:
326 # patch in cardId from parent loan details
327 bundled_media["cardId"] = selected_loan["cardId"]
328 extract_loan_file(libby_client, bundled_media, args)
329 except ClientError:
330 # Oddly having a valid loan to the audiobook does not always mean
331 # having access to the bundled content? Ref https://github.com/ping/odmpy/issues/54
332 # Let this fail without affecting the main download since it's not critical
333 logger.exception(
334 'Unable to download bundled content for "%s"',
335 bundled_media.get("title", ""),
336 )
339def extract_loan_file(
340 libby_client: LibbyClient, selected_loan: Dict, args: argparse.Namespace
341) -> Optional[Path]:
342 """
343 Extracts the ODM / ACSM / EPUB(open) file
345 :param libby_client:
346 :param selected_loan:
347 :param args:
348 :return: The path to the ODM file
349 """
350 try:
351 format_id = LibbyClient.get_loan_format(selected_loan)
352 except ValueError as err:
353 err_msg = str(err)
354 if "kindle" in str(err):
355 logger.error(
356 "You may have already sent the loan to your Kindle device: %s",
357 colored(err_msg, "red"),
358 )
359 else:
360 logger.error(colored(err_msg, "red"))
361 return None
363 file_ext = "odm"
364 file_name = f'{selected_loan["title"]} {selected_loan["id"]}'
365 loan_file_path = Path(
366 args.download_dir, f"{slugify(file_name, allow_unicode=True)}.{file_ext}"
367 )
368 if (
369 args.libby_direct
370 and libby_client.has_format(selected_loan, LibbyFormats.EBookOverdrive)
371 and not (
372 # don't do direct downloads for PDF loans because these turn out badly
373 libby_client.has_format(selected_loan, LibbyFormats.EBookPDFAdobe)
374 or libby_client.has_format(selected_loan, LibbyFormats.EBookPDFOpen)
375 )
376 ):
377 format_id = LibbyFormats.EBookOverdrive
379 openbook: Dict = {}
380 rosters: List[Dict] = []
381 # pre-extract openbook first so that we can use it to create the book folder
382 # with the creator names (needed to place the cover.jpg download)
383 if format_id in (LibbyFormats.EBookOverdrive, LibbyFormats.MagazineOverDrive):
384 _, openbook, rosters = libby_client.process_ebook(selected_loan)
386 cover_path = None
387 if format_id in (
388 LibbyFormats.EBookEPubAdobe,
389 LibbyFormats.EBookEPubOpen,
390 LibbyFormats.EBookOverdrive,
391 LibbyFormats.MagazineOverDrive,
392 LibbyFormats.EBookPDFAdobe,
393 LibbyFormats.EBookPDFOpen,
394 ):
395 file_ext = (
396 "acsm"
397 if format_id in (LibbyFormats.EBookEPubAdobe, LibbyFormats.EBookPDFAdobe)
398 else "pdf"
399 if format_id == LibbyFormats.EBookPDFOpen
400 else "epub"
401 )
402 book_folder, book_file_name = generate_names(
403 title=selected_loan["title"],
404 series=selected_loan.get("series") or "",
405 series_reading_order=selected_loan.get("detailedSeries", {}).get(
406 "readingOrder", ""
407 ),
408 authors=extract_authors_from_openbook(openbook)
409 or (
410 [selected_loan["firstCreatorName"]]
411 if selected_loan.get("firstCreatorName")
412 else []
413 ), # for open-epub
414 edition=selected_loan.get("edition") or "",
415 title_id=selected_loan["id"],
416 args=args,
417 logger=logger,
418 )
419 loan_file_path = book_file_name.with_suffix(f".{file_ext}")
420 if (
421 format_id
422 in (
423 LibbyFormats.EBookOverdrive,
424 LibbyFormats.MagazineOverDrive,
425 )
426 and not loan_file_path.exists()
427 ):
428 # we need the cover for embedding
429 cover_path, _ = generate_cover(
430 book_folder=book_folder,
431 cover_url=get_best_cover_url(selected_loan),
432 session=init_session(args.retries),
433 timeout=args.timeout,
434 logger=logger,
435 force_square=False,
436 )
438 # don't re-download odm if it already exists so that we don't
439 # needlessly use up the fulfillment limits
440 if not loan_file_path.exists():
441 if format_id in (LibbyFormats.EBookOverdrive, LibbyFormats.MagazineOverDrive):
442 process_ebook_loan(
443 loan=selected_loan,
444 cover_path=cover_path,
445 openbook=openbook,
446 rosters=rosters,
447 libby_client=libby_client,
448 args=args,
449 logger=logger,
450 )
451 else:
452 # formats: odm, acsm, open-epub, open-pdf
453 try:
454 odm_res_content = libby_client.fulfill_loan_file(
455 selected_loan["id"], selected_loan["cardId"], format_id
456 )
457 with loan_file_path.open("wb") as f:
458 f.write(odm_res_content)
459 logger.info(
460 'Downloaded %s to "%s"',
461 file_ext,
462 colored(str(loan_file_path), "magenta"),
463 )
464 except ClientError as ce:
465 if ce.http_status == 400 and libby_client.is_downloadable_ebook_loan(
466 selected_loan
467 ):
468 logger.error(
469 "%s %s",
470 colored(
471 f"Unable to download {file_ext}.", "red", attrs=["bold"]
472 ),
473 colored("You may have sent the loan to a Kindle.", "red"),
474 )
475 return None
476 raise
477 else:
478 logger.info(
479 "Already downloaded %s file %s",
480 file_ext,
481 colored(str(loan_file_path), "magenta"),
482 )
484 if cover_path and cover_path.exists() and not args.always_keep_cover:
485 # clean up
486 cover_path.unlink()
488 return loan_file_path
491def run(custom_args: Optional[List[str]] = None, be_quiet: bool = False) -> None:
492 """
494 :param custom_args: Used by unittests
495 :param be_quiet: Used by unittests
496 :return:
497 """
498 parser = argparse.ArgumentParser(
499 prog="odmpy",
500 description="Manage your OverDrive loans",
501 epilog=(
502 f"Version {__version__}. "
503 f"[Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}-{sys.platform}] "
504 f"Source at {REPOSITORY_URL}"
505 ),
506 fromfile_prefix_chars="@",
507 )
508 parser.add_argument(
509 "--version",
510 action="version",
511 version=(
512 f"%(prog)s {__version__} "
513 f"[Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}-{sys.platform}]"
514 ),
515 )
516 parser.add_argument(
517 "-v",
518 "--verbose",
519 dest="verbose",
520 action="store_true",
521 help="Enable more verbose messages for debugging.",
522 )
523 parser.add_argument(
524 "-t",
525 "--timeout",
526 dest="timeout",
527 type=int,
528 default=10,
529 help="Timeout (seconds) for network requests. Default 10.",
530 )
531 parser.add_argument(
532 "-r",
533 "--retry",
534 dest="retries",
535 type=int,
536 default=1,
537 help="Number of retries if a network request fails. Default 1.",
538 )
539 parser.add_argument(
540 "--noversioncheck",
541 dest="dont_check_version",
542 default=False,
543 action="store_true",
544 help="Do not check if newer version is available.",
545 )
547 subparsers = parser.add_subparsers(
548 title="Available commands",
549 dest="command_name",
550 help="To get more help, use the -h option with the command.",
551 )
553 # libby download parser
554 parser_libby = subparsers.add_parser(
555 OdmpyCommands.Libby,
556 description="Interactive Libby Interface for downloading loans.",
557 help="Download audiobook/ebook/magazine loans via Libby.",
558 formatter_class=argparse.RawTextHelpFormatter,
559 )
560 add_common_libby_arguments(parser_libby)
561 add_common_download_arguments(parser_libby)
562 parser_libby.add_argument(
563 "--direct",
564 dest="libby_direct",
565 action="store_true",
566 help=(
567 "Process the download directly from Libby without "
568 "\ndownloading an odm/acsm file. For audiobooks/eBooks."
569 ),
570 )
571 parser_libby.add_argument(
572 "--keepodm",
573 action="store_true",
574 help="Keep the downloaded odm and license files. For audiobooks.",
575 )
576 parser_libby.add_argument(
577 "--latest",
578 dest=OdmpyNoninteractiveOptions.DownloadLatestN,
579 type=positive_int,
580 default=0,
581 metavar="N",
582 help="Non-interactive mode that downloads the latest N number of loans.",
583 )
584 parser_libby.add_argument(
585 "--select",
586 dest=OdmpyNoninteractiveOptions.DownloadSelectedN,
587 type=positive_int,
588 nargs="+",
589 metavar="N",
590 help=(
591 "Non-interactive mode that downloads loans by the index entered.\n"
592 'For example, "--select 1 5" will download the first and fifth loans in '
593 "order of the checked out date.\n"
594 "If the 5th loan does not exist, it will be skipped."
595 ),
596 )
597 parser_libby.add_argument(
598 "--selectid",
599 dest=OdmpyNoninteractiveOptions.DownloadSelectedId,
600 type=positive_int,
601 nargs="+",
602 metavar="ID",
603 help=(
604 "Non-interactive mode that downloads loans by the loan ID entered.\n"
605 'For example, "--selectid 12345" will download the loan with the ID 12345.\n'
606 "If the loan with the ID does not exist, it will be skipped."
607 ),
608 )
609 parser_libby.add_argument(
610 "--exportloans",
611 dest=OdmpyNoninteractiveOptions.ExportLoans,
612 metavar="LOANS_JSON_FILEPATH",
613 type=str,
614 help="Non-interactive mode that exports loan information into a json file at the path specified.",
615 )
616 parser_libby.add_argument(
617 "--reset",
618 dest="reset_settings",
619 action="store_true",
620 help="Remove previously saved odmpy Libby settings.",
621 )
622 parser_libby.add_argument(
623 "--check",
624 dest=OdmpyNoninteractiveOptions.Check,
625 action="store_true",
626 help="Non-interactive mode that displays Libby signed-in status and token if authenticated.",
627 )
628 parser_libby.add_argument(
629 "--debug",
630 dest="is_debug_mode",
631 action="store_true",
632 help="Debug switch for use during development. Please do not use.",
633 )
635 # libby return parser
636 parser_libby_return = subparsers.add_parser(
637 OdmpyCommands.LibbyReturn,
638 description="Interactive Libby Interface for returning loans.",
639 help="Return loans via Libby.",
640 formatter_class=argparse.RawTextHelpFormatter,
641 )
642 add_common_libby_arguments(parser_libby_return)
644 # libby renew parser
645 parser_libby_renew = subparsers.add_parser(
646 OdmpyCommands.LibbyRenew,
647 description="Interactive Libby Interface for renewing loans.",
648 help="Renew loans via Libby.",
649 formatter_class=argparse.RawTextHelpFormatter,
650 )
651 add_common_libby_arguments(parser_libby_renew)
653 # odm download parser
654 parser_dl = subparsers.add_parser(
655 OdmpyCommands.Download,
656 description="Download from an audiobook loan file (odm).",
657 help="Download from an audiobook loan file (odm).",
658 formatter_class=argparse.RawTextHelpFormatter,
659 )
660 parser_dl.add_argument("odm_file", type=str, help="ODM file path.")
661 add_common_download_arguments(parser_dl)
663 # odm return parser
664 parser_ret = subparsers.add_parser(
665 OdmpyCommands.Return,
666 description="Return an audiobook loan file (odm).",
667 help="Return an audiobook loan file (odm).",
668 )
669 parser_ret.add_argument("odm_file", type=str, help="ODM file path.")
671 # odm info parser
672 parser_info = subparsers.add_parser(
673 OdmpyCommands.Information,
674 description="Get information about an audiobook loan file (odm).",
675 help="Get information about an audiobook loan file (odm).",
676 )
677 parser_info.add_argument(
678 "-f",
679 "--format",
680 dest="format",
681 choices=["text", "json"],
682 default="text",
683 help="Format for output.",
684 )
685 parser_info.add_argument("odm_file", type=str, help="ODM file path.")
687 args = parser.parse_args(custom_args)
689 if be_quiet:
690 # in test mode
691 ch.setLevel(logging.ERROR)
692 elif args.verbose:
693 logger.setLevel(logging.DEBUG)
694 requests_logger.setLevel(logging.DEBUG)
695 HTTPConnection.debuglevel = 1
697 if hasattr(args, "download_dir") and args.download_dir:
698 download_dir = Path(args.download_dir)
699 if not download_dir.exists():
700 # prevents FileNotFoundError when using libby odm-based downloads
701 # because the odm is first downloaded into the download dir
702 # without a book folder
703 download_dir.mkdir(parents=True, exist_ok=True)
704 args.download_dir = str(download_dir.expanduser())
706 if hasattr(args, "settings_folder"):
707 default_config_folder = (
708 Path(
709 os.environ.get("APPDATA")
710 or os.environ.get("XDG_CONFIG_HOME")
711 or Path(os.environ.get("HOME", "./")).joinpath(".config")
712 )
713 .joinpath("odmpy")
714 .expanduser()
715 )
716 if args.settings_folder:
717 args.settings_folder = str(Path(args.settings_folder).expanduser())
718 elif OLD_SETTINGS_FOLDER_DEFAULT.joinpath("libby.json").exists():
719 # handle backward-compat for versions <= 0.8.1
720 args.settings_folder = str(OLD_SETTINGS_FOLDER_DEFAULT)
721 else:
722 args.settings_folder = str(default_config_folder)
724 if hasattr(args, "export_loans_path") and args.export_loans_path:
725 args.export_loans_path = str(Path(args.export_loans_path).expanduser())
727 # suppress warnings
728 logging.getLogger("eyed3").setLevel(
729 logging.WARNING if logger.level == logging.DEBUG else logging.ERROR
730 )
732 if not args.dont_check_version:
733 check_version(args.timeout, args.retries)
735 if hasattr(args, "obsolete_retries") and args.obsolete_retries:
736 # retire --retry on the subcommands, after v0.6.7
737 logger.warning(
738 f"{'*' * 60}\n⚠️ The %s option for the %s command is no longer valid, and\n"
739 f"has been moved to the base odmpy command, example: 'odmpy --retry {args.obsolete_retries}'.\n"
740 f"Please change your command to '%s' instead\n{'*' * 60}",
741 colored("--retry/-r", "red"),
742 colored(args.command_name, "red"),
743 colored(
744 f"odmpy --retry {args.obsolete_retries} {args.command_name}",
745 attrs=["bold"],
746 ),
747 )
748 time.sleep(3)
750 try:
751 # Libby-based commands
752 if args.command_name in (
753 OdmpyCommands.Libby,
754 OdmpyCommands.LibbyReturn,
755 OdmpyCommands.LibbyRenew,
756 ):
757 logger.info(
758 "%s Interactive Client for Libby", colored("odmpy", attrs=["bold"])
759 )
760 logger.info("-" * 70)
762 token = os.environ.get("LIBBY_TOKEN")
763 if token:
764 # use token auth if available
765 libby_client = LibbyClient(
766 identity_token=token,
767 max_retries=args.retries,
768 timeout=args.timeout,
769 logger=logger,
770 )
771 else:
772 libby_client = LibbyClient(
773 settings_folder=args.settings_folder,
774 max_retries=args.retries,
775 timeout=args.timeout,
776 logger=logger,
777 )
779 overdrive_client = OverDriveClient(
780 user_agent=libby_client.user_agent,
781 timeout=args.timeout,
782 retry=args.retries,
783 )
785 if args.command_name == OdmpyCommands.Libby and args.reset_settings:
786 libby_client.clear_settings()
787 logger.info("Cleared settings.")
788 return
790 if args.command_name == OdmpyCommands.Libby and args.check_signed_in:
791 if not libby_client.get_token():
792 raise LibbyNotConfiguredError("Libby has not been setup.")
793 if not libby_client.is_logged_in():
794 raise LibbyNotConfiguredError("Libby is not signed-in.")
795 logger.info(
796 "Libby is signed-in with token:\n%s", libby_client.get_token()
797 )
798 return
800 # detect if non-interactive command options are selected before setup
801 if not libby_client.get_token():
802 if [
803 opt_name
804 for opt_name in OdmpyNoninteractiveOptions
805 if hasattr(args, opt_name) and getattr(args, opt_name)
806 ]:
807 raise OdmpyRuntimeError(
808 'Libby has not been setup. Please run "odmpy libby" first.'
809 )
811 if not libby_client.get_token():
812 instructions = (
813 "A Libby setup code is needed to allow odmpy to interact with Libby.\n"
814 "To get a Libby code, see https://help.libbyapp.com/en-us/6070.htm\n"
815 )
816 logger.info(instructions)
817 while True:
818 sync_code = input(
819 "Enter the 8-digit Libby code and press enter: "
820 ).strip()
821 if not sync_code:
822 return
823 if not LibbyClient.is_valid_sync_code(sync_code):
824 logger.warning("Invalid code: %s", colored(sync_code, "red"))
825 continue
826 break
828 try:
829 libby_client.get_chip()
830 libby_client.clone_by_code(sync_code)
831 if not libby_client.is_logged_in():
832 libby_client.clear_settings()
833 raise OdmpyRuntimeError(
834 "Could not log in with code.\n"
835 "Make sure that you have entered the right code and within the time limit.\n"
836 "You also need to have at least 1 registered library card."
837 )
838 logger.info("Login successful.\n")
839 except ClientError as ce:
840 libby_client.clear_settings()
841 raise OdmpyRuntimeError(
842 "Could not log in with code.\n"
843 "Make sure that you have entered the right code and within the time limit."
844 ) from ce
846 synced_state = libby_client.sync()
847 cards = synced_state.get("cards", [])
848 # sort by checkout date so that recent most is at the bottom
849 libby_loans = sorted(
850 [
851 book
852 for book in synced_state.get("loans", [])
853 if (
854 (not args.exclude_audiobooks)
855 and libby_client.is_downloadable_audiobook_loan(book)
856 )
857 or (
858 args.include_ebooks
859 and libby_client.is_downloadable_ebook_loan(book)
860 )
861 or (
862 args.include_magazines
863 and libby_client.is_downloadable_magazine_loan(book)
864 )
865 ],
866 key=lambda ln: ln["checkoutDate"], # type: ignore[no-any-return]
867 )
869 if args.command_name == OdmpyCommands.Libby and args.export_loans_path:
870 logger.info(
871 "Non-interactive mode. Exporting loans json to %s...",
872 colored(args.export_loans_path, "magenta"),
873 )
874 with open(args.export_loans_path, "w", encoding="utf-8") as f:
875 json.dump(libby_loans, f)
876 logger.info(
877 'Saved loans as "%s"',
878 colored(args.export_loans_path, "magenta", attrs=["bold"]),
879 )
880 return
882 if args.command_name == OdmpyCommands.LibbyRenew:
883 libby_loans = [
884 loan for loan in libby_loans if libby_client.is_renewable(loan)
885 ]
886 if not libby_loans:
887 logger.info("No renewable loans found.")
888 return
890 if not libby_loans:
891 logger.info("No downloadable loans found.")
892 return
894 if args.command_name == OdmpyCommands.Libby and (
895 args.selected_loans_indices
896 or args.download_latest_n
897 or args.selected_loans_ids
898 ):
899 # Non-interactive selection
900 selected_loans_indices = []
901 total_loans_count = len(libby_loans)
902 if args.selected_loans_indices:
903 selected_loans_indices.extend(
904 [
905 j
906 for j in args.selected_loans_indices
907 if j <= total_loans_count
908 ]
909 )
910 logger.info(
911 "Non-interactive mode. Downloading selected %s %s...",
912 ps(len(selected_loans_indices), "loan"),
913 colored(
914 ", ".join([str(i) for i in selected_loans_indices]),
915 "blue",
916 attrs=["bold"],
917 ),
918 )
919 if args.download_latest_n:
920 logger.info(
921 "Non-interactive mode. Downloading latest %s %s...",
922 colored(str(args.download_latest_n), "blue"),
923 ps(args.download_latest_n, "loan"),
924 )
925 selected_loans_indices.extend(
926 list(range(1, len(libby_loans) + 1))[-args.download_latest_n :]
927 )
928 if args.selected_loans_ids:
929 selected_loans_ids = [str(i) for i in args.selected_loans_ids]
930 logger.info(
931 "Non-interactive mode. Downloading loans with %s %s...",
932 ps(len(selected_loans_ids), "ID"),
933 ", ".join([colored(i, "blue") for i in selected_loans_ids]),
934 )
935 for n, loan in enumerate(libby_loans, start=1):
936 if loan["id"] in selected_loans_ids:
937 selected_loans_indices.append(n)
938 selected_loans_indices = sorted(list(set(selected_loans_indices)))
939 selected_loans: List[Dict] = [
940 libby_loans[j - 1] for j in selected_loans_indices
941 ]
942 if args.libby_direct:
943 for selected_loan in selected_loans:
944 logger.info(
945 'Opening %s "%s"...',
946 selected_loan.get("type", {}).get("id"),
947 colored(selected_loan["title"], "blue"),
948 )
949 if libby_client.is_downloadable_audiobook_loan(selected_loan):
950 openbook, toc = libby_client.process_audiobook(
951 selected_loan
952 )
953 process_audiobook_loan(
954 selected_loan,
955 openbook,
956 toc,
957 libby_client.libby_session,
958 args,
959 logger,
960 )
961 extract_bundled_contents(
962 libby_client,
963 overdrive_client,
964 selected_loan,
965 cards,
966 args,
967 )
968 continue
969 elif libby_client.is_downloadable_ebook_loan(
970 selected_loan
971 ) or libby_client.is_downloadable_magazine_loan(selected_loan):
972 extract_loan_file(libby_client, selected_loan, args)
973 continue
974 return
976 for selected_loan in selected_loans:
977 logger.info(
978 'Opening %s "%s"...',
979 selected_loan.get("type", {}).get("id"),
980 colored(selected_loan["title"], "blue"),
981 )
982 if libby_client.is_downloadable_audiobook_loan(selected_loan):
983 process_odm(
984 extract_loan_file(libby_client, selected_loan, args),
985 selected_loan,
986 args,
987 logger,
988 cleanup_odm_license=not args.keepodm,
989 )
990 extract_bundled_contents(
991 libby_client,
992 overdrive_client,
993 selected_loan,
994 cards,
995 args,
996 )
998 elif libby_client.is_downloadable_ebook_loan(
999 selected_loan
1000 ) or libby_client.is_downloadable_magazine_loan(selected_loan):
1001 extract_loan_file(libby_client, selected_loan, args)
1002 continue
1004 return # non-interactive libby downloads
1006 # Interactive mode
1007 holds = synced_state.get("holds", [])
1008 logger.info(
1009 "Found %s %s.",
1010 colored(str(len(libby_loans)), "blue"),
1011 ps(len(libby_loans), "loan"),
1012 )
1013 for index, loan in enumerate(libby_loans, start=1):
1014 expiry_date = LibbyClient.parse_datetime(loan["expireDate"])
1015 hold = next(
1016 iter(
1017 [
1018 h
1019 for h in holds
1020 if h["cardId"] == loan["cardId"] and h["id"] == loan["id"]
1021 ]
1022 ),
1023 None,
1024 )
1025 hold_date = (
1026 LibbyClient.parse_datetime(hold["placedDate"]) if hold else None
1027 )
1029 logger.info(
1030 "%s: %-55s %s %-25s \n * %s %s%s",
1031 colored(f"{index:2d}", attrs=["bold"]),
1032 colored(loan["title"], attrs=["bold"]),
1033 "📰"
1034 if args.include_magazines
1035 and libby_client.is_downloadable_magazine_loan(loan)
1036 else "📕"
1037 if args.include_ebooks
1038 and libby_client.is_downloadable_ebook_loan(loan)
1039 else "🎧"
1040 if args.include_ebooks or args.include_magazines
1041 else "",
1042 loan["firstCreatorName"]
1043 if loan.get("firstCreatorName")
1044 else loan.get("edition", ""),
1045 f"Expires: {colored(f'{expiry_date:%Y-%m-%d}','blue' if libby_client.is_renewable(loan) else None)}",
1046 next(
1047 iter(
1048 [
1049 c["library"]["name"]
1050 for c in cards
1051 if c["cardId"] == loan["cardId"]
1052 ]
1053 )
1054 ),
1055 ""
1056 if not libby_client.is_renewable(loan)
1057 else (
1058 f'\n * {loan.get("availableCopies", 0)} '
1059 f'{ps(loan.get("availableCopies", 0), "copy", "copies")} available'
1060 )
1061 + (f" (hold placed: {hold_date:%Y-%m-%d})" if hold else ""),
1062 )
1063 loan_choices: List[str] = []
1065 # Loans display and user choice prompt
1066 libby_mode = "download"
1067 if args.command_name == OdmpyCommands.LibbyReturn:
1068 libby_mode = "return"
1069 elif args.command_name == OdmpyCommands.LibbyRenew:
1070 libby_mode = "renew"
1071 while True:
1072 user_loan_choice_input = input(
1073 f'\n{colored(libby_mode.title(), "magenta", attrs=["bold"])}. '
1074 f'Choose from {colored(f"1-{len(libby_loans)}", attrs=["bold"])} '
1075 "(separate choices with a space or leave blank to quit), \n"
1076 "then press enter: "
1077 ).strip()
1078 if not user_loan_choice_input:
1079 # abort choice if user enters blank
1080 break
1082 loan_choices = list(set(user_loan_choice_input.split(" ")))
1083 loan_choices_isvalid = True
1084 for loan_index_selected in loan_choices:
1085 if (
1086 (not loan_index_selected.isdigit())
1087 or int(loan_index_selected) < 0
1088 or int(loan_index_selected) > len(libby_loans)
1089 ):
1090 logger.warning(f"Invalid choice: {loan_index_selected}")
1091 loan_choices_isvalid = False
1092 loan_choices = []
1093 break
1094 if loan_choices_isvalid:
1095 break
1097 if not loan_choices:
1098 # abort if no choices made
1099 return
1101 loan_choices = sorted(loan_choices, key=int)
1103 if args.command_name == OdmpyCommands.LibbyReturn:
1104 # do returns
1105 for c in loan_choices:
1106 selected_loan = libby_loans[int(c) - 1]
1107 logger.info(
1108 'Returning loan "%s"...',
1109 colored(selected_loan["title"], "blue"),
1110 )
1111 libby_client.return_loan(selected_loan)
1112 logger.info(
1113 'Returned "%s".',
1114 colored(selected_loan["title"], "blue"),
1115 )
1116 return # end libby return command
1118 if args.command_name == OdmpyCommands.LibbyRenew:
1119 # do renewals
1120 for c in loan_choices:
1121 selected_loan = libby_loans[int(c) - 1]
1122 logger.info(
1123 'Renewing loan "%s"...',
1124 colored(selected_loan["title"], "blue"),
1125 )
1126 try:
1127 _ = libby_client.renew_loan(selected_loan)
1128 logger.info(
1129 'Renewed "%s".',
1130 colored(selected_loan["title"], "blue"),
1131 )
1132 except ClientBadRequestError as badreq_err:
1133 logger.warning(
1134 'Error encountered while renewing "%s": %s',
1135 selected_loan["title"],
1136 colored(badreq_err.msg, "red"),
1137 )
1138 if selected_loan.get("availableCopies", 0) == 0 and not [
1139 h
1140 for h in holds
1141 if h["cardId"] == selected_loan["cardId"]
1142 and h["id"] == selected_loan["id"]
1143 ]:
1144 # offer to make a hold
1145 make_hold = input(
1146 "Do you wish to place a hold instead? (y/n): "
1147 ).strip()
1148 if make_hold == "y":
1149 hold = libby_client.create_hold(
1150 selected_loan["id"], selected_loan["cardId"]
1151 )
1152 logger.info(
1153 "Hold successfully created for %s. You are #%s in line. %s %s in use. Available in ~%s %s.",
1154 colored(hold["title"], attrs=["bold"]),
1155 hold.get("holdListPosition", 0),
1156 hold.get("ownedCopies"),
1157 ps(hold.get("ownedCopies", 0), "copy", "copies"),
1158 hold.get("estimatedWaitDays", 0),
1159 ps(hold.get("estimatedWaitDays", 0), "day"),
1160 )
1162 return # end libby renew command
1164 if args.command_name == OdmpyCommands.Libby:
1165 # do downloads
1166 if args.libby_direct:
1167 for c in loan_choices:
1168 selected_loan = libby_loans[int(c) - 1]
1169 logger.info(
1170 'Opening %s "%s"...',
1171 selected_loan.get("type", {}).get("id"),
1172 colored(selected_loan["title"], "blue"),
1173 )
1174 if libby_client.is_downloadable_audiobook_loan(selected_loan):
1175 openbook, toc = libby_client.process_audiobook(
1176 selected_loan
1177 )
1178 process_audiobook_loan(
1179 selected_loan,
1180 openbook,
1181 toc,
1182 libby_client.libby_session,
1183 args,
1184 logger,
1185 )
1186 extract_bundled_contents(
1187 libby_client,
1188 overdrive_client,
1189 selected_loan,
1190 cards,
1191 args,
1192 )
1193 continue
1194 elif libby_client.is_downloadable_ebook_loan(
1195 selected_loan
1196 ) or libby_client.is_downloadable_magazine_loan(selected_loan):
1197 extract_loan_file(libby_client, selected_loan, args)
1198 continue
1200 return
1202 for c in loan_choices:
1203 selected_loan = libby_loans[int(c) - 1]
1204 logger.info(
1205 'Opening %s "%s"...',
1206 selected_loan.get("type", {}).get("id"),
1207 colored(selected_loan["title"], "blue"),
1208 )
1209 if libby_client.is_downloadable_audiobook_loan(selected_loan):
1210 process_odm(
1211 extract_loan_file(libby_client, selected_loan, args),
1212 selected_loan,
1213 args,
1214 logger,
1215 cleanup_odm_license=not args.keepodm,
1216 )
1217 extract_bundled_contents(
1218 libby_client,
1219 overdrive_client,
1220 selected_loan,
1221 cards,
1222 args,
1223 )
1224 continue
1225 elif libby_client.is_downloadable_ebook_loan(
1226 selected_loan
1227 ) or libby_client.is_downloadable_magazine_loan(selected_loan):
1228 extract_loan_file(libby_client, selected_loan, args)
1229 continue
1230 return
1232 return # end libby commands
1234 # Legacy ODM-based commands from here on
1236 # because py<=3.6 does not support `add_subparsers(required=True)`
1237 try:
1238 # test for odm file
1239 args.odm_file
1240 except AttributeError:
1241 parser.print_help()
1242 return
1244 # Return Book
1245 if args.command_name == OdmpyCommands.Return:
1246 process_odm_return(args, logger)
1247 return
1249 if args.command_name in (OdmpyCommands.Download, OdmpyCommands.Information):
1250 if args.command_name == OdmpyCommands.Download:
1251 logger.info(
1252 'Opening odm "%s"...',
1253 colored(args.odm_file, "blue"),
1254 )
1255 process_odm(Path(args.odm_file), {}, args, logger)
1256 return
1258 except OdmpyRuntimeError as run_err:
1259 logger.error(
1260 "%s %s",
1261 colored("Error:", attrs=["bold"]),
1262 colored(str(run_err), "red"),
1263 )
1264 raise
1266 except Exception: # noqa: E722, pylint: disable=broad-except
1267 logger.exception(colored("An unexpected error has occurred", "red"))
1268 raise
1270 # we shouldn't get this error
1271 logger.error("Unknown command: %s", colored(args.command_name, "red"))