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 +3 -0
- aa_meta/api.py +55 -0
- aa_meta/authors.py +69 -0
- aa_meta/cli.py +520 -0
- aa_meta/config.py +58 -0
- aa_meta/cookies.py +125 -0
- aa_meta/details.py +30 -0
- aa_meta/download.py +53 -0
- aa_meta/models.py +114 -0
- aa_meta/parse.py +210 -0
- aa_meta/py.typed +0 -0
- aa_meta/search.py +156 -0
- aa_meta-0.1.0.dist-info/METADATA +16 -0
- aa_meta-0.1.0.dist-info/RECORD +16 -0
- aa_meta-0.1.0.dist-info/WHEEL +4 -0
- aa_meta-0.1.0.dist-info/entry_points.txt +3 -0
aa_meta/__init__.py
ADDED
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()
|