aa-meta 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aa_meta/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """aa-meta: search and download books from Anna's Archive."""
2
+
3
+ __version__ = "0.1.0"
aa_meta/api.py ADDED
@@ -0,0 +1,55 @@
1
+ """Client for the Anna's Archive fast-download JSON API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from .config import USER_AGENT, Settings
11
+
12
+
13
+ class ApiError(RuntimeError):
14
+ """Raised when the API returns an error or no download URL."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class FastDownload:
19
+ """Successful fast-download response."""
20
+
21
+ url: str
22
+ info: dict[str, Any]
23
+
24
+
25
+ def fast_download(
26
+ client: httpx.Client,
27
+ settings: Settings,
28
+ md5: str,
29
+ key: str,
30
+ path_index: int | None = None,
31
+ domain_index: int | None = None,
32
+ ) -> FastDownload:
33
+ """Resolve a member fast-download URL for ``md5``.
34
+
35
+ Raises:
36
+ ApiError: on a non-2xx response or a null ``download_url``.
37
+ """
38
+ params: dict[str, str | int] = {"md5": md5, "key": key}
39
+ if path_index is not None:
40
+ params["path_index"] = path_index
41
+ if domain_index is not None:
42
+ params["domain_index"] = domain_index
43
+ resp = client.get(
44
+ f"{settings.origin}/dyn/api/fast_download.json",
45
+ params=params,
46
+ headers={"User-Agent": USER_AGENT},
47
+ )
48
+ try:
49
+ data: dict[str, Any] = resp.json()
50
+ except ValueError as exc:
51
+ raise ApiError(f"HTTP {resp.status_code}: non-JSON response") from exc
52
+ url = data.get("download_url")
53
+ if resp.status_code not in (200, 204) or not url:
54
+ raise ApiError(data.get("error") or f"HTTP {resp.status_code}")
55
+ return FastDownload(url=url, info=data.get("account_fast_download_info") or {})
aa_meta/authors.py ADDED
@@ -0,0 +1,69 @@
1
+ """Colour-code the authors of search results with a ``rich_gradient`` Spectrum."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from rich.style import Style
8
+ from rich.text import Text
9
+ from rich_gradient.spectrum import COLOR_STOPS, Spectrum
10
+
11
+
12
+ def split_authors(raw: str) -> list[str]:
13
+ """Split a site author string into individual names (heuristic).
14
+
15
+ The site separates authors with ``;``. Some sources use commas instead, which
16
+ is ambiguous with ``Last, First``: a single comma is treated as one
17
+ ``Last, First`` name, two or more as a comma-separated list.
18
+ """
19
+ if ";" in raw:
20
+ parts = raw.split(";")
21
+ elif raw.count(",") >= 2:
22
+ parts = raw.split(",")
23
+ else:
24
+ parts = [raw]
25
+ return [p.strip() for p in parts if p.strip()]
26
+
27
+
28
+ def author_key(name: str) -> str:
29
+ """Normalise a name so ``Herbert, Frank`` and ``Frank Herbert`` match."""
30
+ if "," in name:
31
+ last, _, first = name.partition(",")
32
+ name = f"{first} {last}"
33
+ return " ".join(name.replace(".", "").casefold().split())
34
+
35
+
36
+ def format_authors(raw_authors: Sequence[str], *, seed: int = 0) -> list[Text]:
37
+ """Return one colour-coded :class:`~rich.text.Text` per author string.
38
+
39
+ Every distinct author (by :func:`author_key`) gets one colour from a
40
+ :class:`~rich_gradient.spectrum.Spectrum`, so the same person looks the same
41
+ in every row. ``Spectrum`` yields consecutive colours around the ring, so the
42
+ full ring is built and colours are picked evenly spaced to keep a handful of
43
+ authors visually distinct; with more authors than colours, colours repeat.
44
+
45
+ Args:
46
+ raw_authors: The author field of each result, in row order.
47
+ seed: Rotates where the colour ring starts; same seed, same colours.
48
+
49
+ Returns:
50
+ A list the same length as ``raw_authors``; empty authors render as a dim
51
+ ``Unknown``.
52
+ """
53
+ rows = [split_authors(raw) for raw in raw_authors]
54
+ keys = list(dict.fromkeys(author_key(n) for names in rows for n in names))
55
+ ring = Spectrum(hues=len(COLOR_STOPS), seed=seed).styles
56
+ step = max(len(ring) / max(len(keys), 1), 1.0)
57
+ style_of = {k: ring[int(i * step) % len(ring)] for i, k in enumerate(keys)}
58
+
59
+ texts: list[Text] = []
60
+ for names in rows:
61
+ text = Text(no_wrap=False)
62
+ if not names:
63
+ text.append("Unknown", style="dim")
64
+ for i, name in enumerate(names):
65
+ if i:
66
+ text.append("; ", style="dim")
67
+ text.append(name, style=style_of[author_key(name)] + Style(bold=True))
68
+ texts.append(text)
69
+ return texts
aa_meta/cli.py ADDED
@@ -0,0 +1,520 @@
1
+ """Typer command-line interface: ``aa search`` and ``aa download``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sys
8
+ from collections.abc import Callable, Iterator
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import httpx
14
+ import typer
15
+ from loguru import logger
16
+ from rich.box import ROUNDED
17
+ from rich.console import Console
18
+ from rich.markup import escape
19
+ from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
20
+ from rich.prompt import Confirm, IntPrompt
21
+ from rich.table import Table
22
+ from rich.text import Text as RichText
23
+ from rich_gradient import Text
24
+
25
+ from .api import ApiError, fast_download
26
+ from .authors import format_authors
27
+ from .config import (
28
+ DEFAULT_BASE_URL,
29
+ DEFAULT_OP_REF,
30
+ ConfigError,
31
+ Settings,
32
+ read_secret_key,
33
+ )
34
+ from .cookies import BROWSERS, CookieError, read_cookie_file, refresh_cookies
35
+ from .details import fetch_details
36
+ from .download import download_file
37
+ from .models import BookRecord
38
+ from .search import Book, SearchError, search_books
39
+
40
+ app = typer.Typer(
41
+ help="Search and download books from Anna's Archive.", no_args_is_help=True
42
+ )
43
+ console = Console()
44
+ err = Console(stderr=True)
45
+
46
+ BaseUrl = Annotated[
47
+ str, typer.Option("--base-url", envvar="AA_BASE_URL", help="Mirror domain.")
48
+ ]
49
+ OpRef = Annotated[
50
+ str,
51
+ typer.Option("--op-ref", envvar="AA_OP_REF", help="1Password secret reference."),
52
+ ]
53
+ Cookie = Annotated[
54
+ str | None,
55
+ typer.Option(
56
+ "--cookie",
57
+ envvar="AA_COOKIE",
58
+ help="Browser Cookie header to pass the site's browser check.",
59
+ ),
60
+ ]
61
+
62
+
63
+ BrowserOpt = Annotated[
64
+ str,
65
+ typer.Option(
66
+ "--browser",
67
+ envvar="AA_BROWSER",
68
+ help=f"Browser to pull cookies from: {', '.join(BROWSERS)}.",
69
+ ),
70
+ ]
71
+
72
+ DEFAULT_OUTPUT_DIR = Path().home() / "Downloads"
73
+
74
+ @contextmanager
75
+ def _busy(description: str) -> Iterator[Callable[[str], None]]:
76
+ """Show a spinner with elapsed time while a request runs.
77
+
78
+ Yields a function that replaces the spinner text (used to report a cookie
79
+ refresh). The spinner is transient, so it disappears before results print.
80
+ """
81
+ with Progress(
82
+ SpinnerColumn(),
83
+ TextColumn("[progress.description]{task.description}"),
84
+ TimeElapsedColumn(),
85
+ transient=True,
86
+ console=console,
87
+ ) as progress:
88
+ task = progress.add_task(description, total=None)
89
+ yield lambda text: progress.update(task, description=text)
90
+
91
+
92
+ def _fail(msg: str) -> typer.Exit:
93
+ err.print(f"[bold red]Error:[/] {msg}")
94
+ return typer.Exit(1)
95
+
96
+
97
+ def _with_cookies[T](
98
+ call: Callable[[httpx.Client, Settings], T],
99
+ base_url: str,
100
+ cookie: str | None,
101
+ browser: str,
102
+ refresh: bool,
103
+ status: Callable[[str], None] = lambda text: err.print(f"[yellow]{text}[/]"),
104
+ ) -> T:
105
+ """Run ``call``; on a blocked request re-read browser cookies and retry once.
106
+
107
+ ``status`` reports the refresh (it replaces the spinner text when one is shown).
108
+ """
109
+ settings = Settings(base_url=base_url, cookie=cookie or read_cookie_file())
110
+ with httpx.Client(timeout=30, follow_redirects=True) as client:
111
+ try:
112
+ return call(client, settings)
113
+ except SearchError:
114
+ if not (refresh and cookie is None):
115
+ raise
116
+ status("Cookies expired; refreshing from browser…")
117
+ fresh = refresh_cookies(client, settings, browser)
118
+ return call(client, Settings(base_url=base_url, cookie=fresh))
119
+
120
+
121
+ @app.callback()
122
+ def main(verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False) -> None:
123
+ """Configure logging."""
124
+ logger.remove()
125
+ if verbose:
126
+ logger.add(sys.stderr, level="DEBUG")
127
+
128
+
129
+ @app.command()
130
+ def search(
131
+ query: Annotated[str, typer.Argument(help="Title, author, or ISBN.")],
132
+ ext: Annotated[
133
+ str | None, typer.Option("--ext", "-e", help="File type, e.g. epub.")
134
+ ] = None,
135
+ limit: Annotated[int, typer.Option("--limit", "-n")] = 20,
136
+ lang: Annotated[
137
+ str | None, typer.Option("--lang", "-l", help="Language code, e.g. en.")
138
+ ] = None,
139
+ content: Annotated[
140
+ str | None,
141
+ typer.Option("--content", "-c", help="e.g. book_fiction, book_nonfiction."),
142
+ ] = None,
143
+ sort: Annotated[
144
+ str | None, typer.Option("--sort", help="e.g. newest, largest, oldest.")
145
+ ] = None,
146
+ base_url: BaseUrl = DEFAULT_BASE_URL,
147
+ cookie: Cookie = None,
148
+ browser: BrowserOpt = "chrome",
149
+ refresh: Annotated[
150
+ bool, typer.Option(help="Re-read browser cookies on a 403 and retry.")
151
+ ] = True,
152
+ prompt: Annotated[
153
+ bool,
154
+ typer.Option(
155
+ "--prompt/--no-prompt",
156
+ help="Offer to download a result (interactive terminals only).",
157
+ ),
158
+ ] = True,
159
+ output: Annotated[
160
+ Path, typer.Option("--output", "-o", help="Download directory.")
161
+ ] = DEFAULT_OUTPUT_DIR,
162
+ op_ref: OpRef = DEFAULT_OP_REF,
163
+ ) -> None:
164
+ """Search for books and print their MD5 hashes, then offer to download one."""
165
+ try:
166
+ with _busy(f"Searching for [bold]{escape(query)}[/]…") as status:
167
+ books = _with_cookies(
168
+ lambda c, s: search_books(
169
+ c, s, query, ext, limit, lang=lang, content=content, sort=sort
170
+ ),
171
+ base_url,
172
+ cookie,
173
+ browser,
174
+ refresh,
175
+ status,
176
+ )
177
+ except (SearchError, CookieError, httpx.HTTPError) as exc:
178
+ raise _fail(str(exc)) from exc
179
+ if not books:
180
+ console.print("[yellow]No results.[/]")
181
+ raise typer.Exit(1)
182
+ console.print(_results_table(books, console.width))
183
+ if prompt and _interactive():
184
+ _offer_download(books, output=output, base_url=base_url, op_ref=op_ref)
185
+
186
+
187
+ def _interactive() -> bool:
188
+ """Return whether we can prompt: both stdin and stdout are a terminal."""
189
+ return sys.stdin.isatty() and console.is_terminal
190
+
191
+
192
+ def _download_md5(
193
+ md5: str,
194
+ *,
195
+ output: Path,
196
+ base_url: str,
197
+ op_ref: str,
198
+ path_index: int | None = None,
199
+ domain_index: int | None = None,
200
+ fallback_name: str | None = None,
201
+ ) -> Path:
202
+ """Resolve a member fast-download URL for ``md5`` and save the file.
203
+
204
+ Raises:
205
+ ConfigError: if the API key is unavailable.
206
+ ApiError: if the API refuses the download.
207
+ httpx.HTTPError: on a network failure.
208
+ """
209
+ settings = Settings(base_url=base_url, op_ref=op_ref, download_dir=output)
210
+ key = read_secret_key(op_ref)
211
+ with httpx.Client(timeout=30, follow_redirects=True) as client:
212
+ fd = fast_download(client, settings, md5, key, path_index, domain_index)
213
+ if left := fd.info.get("downloads_left"):
214
+ console.print(f"[dim]Fast downloads left today: {left}/25[/]")
215
+ return download_file(client, fd.url, output, fallback_name or f"{md5}.bin")
216
+
217
+
218
+ def _offer_download(
219
+ books: list[Book], *, output: Path, base_url: str, op_ref: str
220
+ ) -> None:
221
+ """Ask whether to download a result and, if so, which one, then fetch it."""
222
+ try:
223
+ if not Confirm.ask(
224
+ "\nWould you like to download one of these results?",
225
+ console=console,
226
+ default=False,
227
+ ):
228
+ return
229
+ number = IntPrompt.ask(
230
+ f"Which result? [dim](1-{len(books)})[/]",
231
+ console=console,
232
+ choices=[str(i) for i in range(1, len(books) + 1)],
233
+ show_choices=False,
234
+ )
235
+ except EOFError: # stdin closed mid-prompt: treat as "no"
236
+ return
237
+ book = books[number - 1]
238
+ ext = book.record.extension if book.record and book.record.extension else "bin"
239
+ console.print(f"Downloading [bold]{escape(book.title)}[/]…")
240
+ try:
241
+ path = _download_md5(
242
+ book.md5,
243
+ output=output,
244
+ base_url=base_url,
245
+ op_ref=op_ref,
246
+ fallback_name=f"{book.md5}.{ext}",
247
+ )
248
+ except (ConfigError, ApiError, httpx.HTTPError) as exc:
249
+ raise _fail(str(exc)) from exc
250
+ console.print(f"[b i #00ff00]Saved[/] [b #af00ff]{escape(str(path))}[/]")
251
+
252
+
253
+ def format_ext(ext: str | None) -> RichText:
254
+ """Style the format of the book's extension.
255
+ Args:
256
+ ext (str | None): The file extension.
257
+ Returns:
258
+ RichText: The styled extension text.
259
+ """
260
+ if not ext:
261
+ return RichText("Unknown", style="dim")
262
+ _ext: str = ext.upper()
263
+ styles = {
264
+ "EPUB": "bold #99ff00",
265
+ "PDF": "bold #ff0000",
266
+ "MOBI": "bold #69C3FF",
267
+ "FB2": "bold #0000ff",
268
+ "TXT": "bold #FFFF00",
269
+ }
270
+ if _ext in styles:
271
+ return RichText(_ext, style=styles[_ext])
272
+ return RichText(_ext, style="none")
273
+
274
+
275
+ _UNIT_STYLES = {
276
+ "B": "#999999",
277
+ "KB": "#69C3FF",
278
+ "MB": "#99ff00",
279
+ "GB": "#ff9900",
280
+ "TB": "#ff0055",
281
+ }
282
+
283
+
284
+ def format_size(size: str | None) -> RichText:
285
+ """Style a size such as ``9.8MB``: the number bold white, the unit coloured.
286
+
287
+ The unit colour grows warmer with magnitude (KB blue, MB green, GB orange).
288
+ Unparseable or missing sizes render as a dim dash.
289
+
290
+ Args:
291
+ size (str | None): Size text like ``600.0KB``.
292
+ Returns:
293
+ RichText: ``<number> <unit>`` with each part in its own style.
294
+ """
295
+ match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([A-Za-z]+)\s*", size or "")
296
+ if not match:
297
+ return RichText("-", style="dim")
298
+ number, unit = match.group(1), match.group(2).upper()
299
+ # Plain rich Text: rich_gradient's Text would rainbow-colour the number.
300
+ text = RichText(number, style="bold #ffffff")
301
+ text.append(f" {unit}", style=f"bold {_UNIT_STYLES.get(unit, '#C2C2C2')}")
302
+ return text
303
+
304
+
305
+ # Header gradient, left to right: each column's start picks up where the previous
306
+ # column's end left off, so the headers read as one rainbow across the table.
307
+ _HEADER_GRADIENT: list[tuple[str, str, str]] = [
308
+ ("Extension", "#ff9900", "#ffcc00"),
309
+ ("Title", "#ffff00", "#00ff00"),
310
+ ("Author", "#00ff99", "#00ffff"),
311
+ ("Size", "#00ddff", "#00aaff"),
312
+ ("Language", "#0099ff", "#3380ff"),
313
+ ("Year", "#5066ff", "#8055ff"),
314
+ ("MD5", "#a044ff", "#ff00ff"),
315
+ ]
316
+
317
+
318
+ def _header(label: str, start: str, end: str) -> Text:
319
+ """Return a bold gradient column header running from ``start`` to ``end``."""
320
+ return Text(text=f"[b]{label}[/]", colors=[start, end])
321
+
322
+
323
+ def _plain(value: str) -> RichText:
324
+ """Return ``value`` in a neutral light grey, or a dim dash when empty."""
325
+ return RichText(value, style="#C2C2C2") if value else RichText("-", style="dim")
326
+
327
+
328
+ # Minimum content width of each column; used to decide what fits. Title and
329
+ # Author need a few characters to stay readable, the MD5 must never be cut.
330
+ _MIN_WIDTH = {
331
+ "#": 2,
332
+ "Extension": 9,
333
+ "Title": 16,
334
+ "Author": 14,
335
+ "Size": 8,
336
+ "Language": 8,
337
+ "Year": 4,
338
+ "MD5": 32,
339
+ }
340
+ # Columns dropped first when the terminal is too narrow, least important first.
341
+ _DROP_ORDER = ["Year", "Language", "Size", "Extension"]
342
+
343
+
344
+ def _visible_columns(width: int | None) -> list[str]:
345
+ """Return the column names that fit in ``width`` (all of them if ``None``).
346
+
347
+ Each column costs its content width plus 3 (padding and one border); the table
348
+ has one more border on the left. Columns are dropped in ``_DROP_ORDER`` until
349
+ the rest fit; Title, Author and MD5 are always kept.
350
+ """
351
+ shown = list(_MIN_WIDTH)
352
+ for name in _DROP_ORDER:
353
+ if width is None or 1 + sum(_MIN_WIDTH[c] + 3 for c in shown) <= width:
354
+ break
355
+ shown.remove(name)
356
+ return shown
357
+
358
+
359
+ def _results_table(books: list[Book], width: int | None = None) -> Table:
360
+ """Build the search results table: one row per book, full MD5 last.
361
+
362
+ ``width`` is the terminal width; on narrow terminals the least important
363
+ columns (Year, Language, Size, Extension) are left out rather than squeezing
364
+ Title and Author to a few characters. Title and author fold onto extra lines
365
+ when space is tight, so the MD5 (which must stay copy-pasteable) is never
366
+ truncated.
367
+ """
368
+ show = _visible_columns(width)
369
+ table = Table(
370
+ title=Text("Search Results", rainbow=True, style="bold"),
371
+ title_justify="center",
372
+ caption=f"{len(books)} result{'s' if len(books) != 1 else ''}",
373
+ box=ROUNDED,
374
+ row_styles=["on #2b2b2b", "on #444444"],
375
+ title_style="bold",
376
+ )
377
+ gradient = {
378
+ label: _header(label, start, end) for label, start, end in _HEADER_GRADIENT
379
+ }
380
+ table.add_column("[b #ff0000]#[/]", style="dim italic", justify="center")
381
+ if "Extension" in show:
382
+ table.add_column(
383
+ gradient["Extension"], justify="center", overflow="ignore", style="bold"
384
+ )
385
+ table.add_column(gradient["Title"], overflow="fold", style="bold #ffffff")
386
+ table.add_column(gradient["Author"], ratio=2, overflow="fold")
387
+ if "Size" in show:
388
+ table.add_column(gradient["Size"], justify="center", no_wrap=True)
389
+ if "Language" in show:
390
+ table.add_column(gradient["Language"], justify="center", no_wrap=True)
391
+ if "Year" in show:
392
+ table.add_column(gradient["Year"], justify="center", no_wrap=True)
393
+ table.add_column(gradient["MD5"], style="#00ccff", no_wrap=True, min_width=32)
394
+ authors = format_authors([r.author if (r := b.record) else b.meta for b in books])
395
+ for i, (b, author) in enumerate(zip(books, authors, strict=True), 1):
396
+ r = b.record # bare links have no record, only raw text in ``meta``
397
+ cells: dict[str, RichText | str] = {
398
+ "#": str(i),
399
+ "Extension": format_ext(r.extension if r else None),
400
+ "Title": f"[bold #ffffff]{escape(b.title)}",
401
+ "Author": author,
402
+ "Size": format_size(r.size_text if r else None),
403
+ "Language": _plain(r.language if r else ""),
404
+ "Year": _plain(str(r.year) if r and r.year else ""),
405
+ "MD5": b.md5,
406
+ }
407
+ table.add_row(*(cells[name] for name in _MIN_WIDTH if name in show))
408
+ return table
409
+
410
+
411
+ def _print_record(rec: BookRecord) -> None:
412
+ """Render a detailed record as rich tables."""
413
+ summary = Table(show_header=False, box=ROUNDED, title=escape(rec.title))
414
+ summary.add_column(style="bold #C2C2C2")
415
+ summary.add_column(overflow="fold")
416
+ rows = {
417
+ "Author": rec.author,
418
+ "Publisher": rec.publisher,
419
+ "Series": f"{rec.series} #{rec.series_index}" if rec.series else "",
420
+ "Year": str(rec.year or ""),
421
+ "Language": ", ".join(rec.languages),
422
+ "Format": f"{rec.extension} · {rec.size_text}" if rec.extension else "",
423
+ "Type": rec.content_type,
424
+ "Sources": ", ".join(rec.sources),
425
+ "MD5": rec.md5,
426
+ "File": rec.filename,
427
+ "Cover": rec.cover_url,
428
+ }
429
+ for label, value in rows.items():
430
+ if value:
431
+ summary.add_row(label, escape(value))
432
+ console.print(summary)
433
+ if rec.identifiers:
434
+ ids = Table(show_header=False, box=ROUNDED, title="Identifiers")
435
+ ids.add_column(style="bold #C2C2C2")
436
+ ids.add_column(overflow="fold")
437
+ for key, values in rec.identifiers.items():
438
+ ids.add_row(key, escape(", ".join(values)))
439
+ console.print(ids)
440
+ if rec.description:
441
+ console.print(f"\n[dim]{escape(rec.description[:600])}[/]")
442
+
443
+
444
+ @app.command()
445
+ def info(
446
+ md5: Annotated[str, typer.Argument(help="MD5 hash of the record.")],
447
+ as_json: Annotated[
448
+ bool, typer.Option("--json", help="Print the full record as JSON.")
449
+ ] = False,
450
+ base_url: BaseUrl = DEFAULT_BASE_URL,
451
+ cookie: Cookie = None,
452
+ browser: BrowserOpt = "chrome",
453
+ refresh: Annotated[
454
+ bool, typer.Option(help="Re-read browser cookies on a 403 and retry.")
455
+ ] = True,
456
+ ) -> None:
457
+ """Show everything parsed from a record's details page."""
458
+ try:
459
+ with _busy(f"Fetching [bold]{md5}[/]…") as status:
460
+ rec = _with_cookies(
461
+ lambda c, s: fetch_details(c, s, md5),
462
+ base_url,
463
+ cookie,
464
+ browser,
465
+ refresh,
466
+ status,
467
+ )
468
+ except (SearchError, CookieError, httpx.HTTPError) as exc:
469
+ raise _fail(str(exc)) from exc
470
+ if as_json:
471
+ typer.echo(json.dumps(rec.to_dict(), ensure_ascii=False, indent=2))
472
+ else:
473
+ _print_record(rec)
474
+
475
+
476
+ @app.command()
477
+ def cookies(
478
+ browser: BrowserOpt = "chrome",
479
+ base_url: BaseUrl = DEFAULT_BASE_URL,
480
+ ) -> None:
481
+ """Refresh cookies.sh from your browser's signed-in session."""
482
+ settings = Settings(base_url=base_url)
483
+ try:
484
+ with httpx.Client(timeout=30, follow_redirects=True) as client:
485
+ refresh_cookies(client, settings, browser)
486
+ except (CookieError, httpx.HTTPError) as exc:
487
+ raise _fail(str(exc)) from exc
488
+ console.print("[green]Cookies refreshed.[/]")
489
+
490
+
491
+ @app.command()
492
+ def download(
493
+ md5: Annotated[str, typer.Argument(help="MD5 hash of the file.")],
494
+ output: Annotated[
495
+ Path, typer.Option("--output", "-o", help="Destination directory.")
496
+ ] = Path("downloads"),
497
+ path_index: Annotated[int | None, typer.Option(help="Collection index.")] = None,
498
+ domain_index: Annotated[
499
+ int | None, typer.Option(help="Download server index.")
500
+ ] = None,
501
+ base_url: BaseUrl = DEFAULT_BASE_URL,
502
+ op_ref: OpRef = DEFAULT_OP_REF,
503
+ ) -> None:
504
+ """Download a book by MD5 using the member fast-download API."""
505
+ try:
506
+ path = _download_md5(
507
+ md5,
508
+ output=output,
509
+ base_url=base_url,
510
+ op_ref=op_ref,
511
+ path_index=path_index,
512
+ domain_index=domain_index,
513
+ )
514
+ except (ConfigError, ApiError, httpx.HTTPError) as exc:
515
+ raise _fail(str(exc)) from exc
516
+ console.print(f"[b i #00ff00]Saved[/] [b #af00ff]{escape(str(path))}[/]")
517
+
518
+
519
+ if __name__ == "__main__":
520
+ app()