aa-tui 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_tui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Textual front-end for aa-meta."""
aa_tui/app.py ADDED
@@ -0,0 +1,422 @@
1
+ """The Textual application: search box, results table, and detail pane."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import threading
7
+ import webbrowser
8
+ from dataclasses import replace
9
+ from pathlib import Path
10
+ from typing import Any, ClassVar
11
+ from urllib.parse import urlencode
12
+
13
+ from aa_meta.authors import format_authors
14
+ from aa_meta.cli import format_ext, format_size
15
+ from aa_meta.config import Settings
16
+ from aa_meta.models import BookRecord
17
+ from aa_meta.search import Book
18
+ from loguru import logger
19
+ from rich.text import Text
20
+ from textual import work
21
+ from textual.app import App, ComposeResult
22
+ from textual.binding import Binding, BindingType
23
+ from textual.containers import Horizontal
24
+ from textual.widgets import DataTable, Footer, Header, Input, Static
25
+ from textual.worker import get_current_worker
26
+
27
+ from . import service
28
+ from .capture import ToastStream
29
+ from .details_screen import DetailsScreen
30
+ from .edit_screen import EditScreen
31
+ from .error_screen import ErrorScreen
32
+ from .loading_screen import LoadingScreen
33
+ from .metadata import DEFAULT_STORE, EditableMeta, MetadataStore
34
+ from .tags import TagError, write_tags
35
+
36
+ # (label, width) of each results column; the MD5 stays last and full-width.
37
+ COLUMNS: tuple[tuple[str, int | None], ...] = (
38
+ ("#", 3),
39
+ ("Ext", 6),
40
+ ("Title", 44),
41
+ ("Author", 26),
42
+ ("Size", 9),
43
+ ("Lang", 5),
44
+ ("Year", 5),
45
+ ("MD5", 32),
46
+ )
47
+
48
+
49
+ def _plain(value: str) -> Text:
50
+ """Return ``value`` in light grey, or a dim dash when empty."""
51
+ return Text(value, style="#C2C2C2") if value else Text("-", style="dim")
52
+
53
+
54
+ def result_rows(books: list[Book]) -> list[tuple[Text | str, ...]]:
55
+ """Build one table row per book, splitting the parsed record into columns.
56
+
57
+ Books without a parsed record (bare links) fall back to their raw ``meta``
58
+ text in the Author column and dashes elsewhere.
59
+ """
60
+ authors = format_authors([b.record.author if b.record else b.meta for b in books])
61
+ rows: list[tuple[Text | str, ...]] = []
62
+ for i, (book, author) in enumerate(zip(books, authors, strict=True), 1):
63
+ rec = book.record
64
+ rows.append(
65
+ (
66
+ Text(str(i), style="dim italic"),
67
+ format_ext(rec.extension if rec else None),
68
+ Text(book.title, style="bold"),
69
+ author,
70
+ format_size(rec.size_text if rec else None),
71
+ _plain(rec.language if rec else ""),
72
+ _plain(str(rec.year) if rec and rec.year else ""),
73
+ Text(book.md5, style="#00ccff"),
74
+ )
75
+ )
76
+ return rows
77
+
78
+
79
+ def apply_meta(rec: BookRecord, meta: EditableMeta) -> BookRecord:
80
+ """Return ``rec`` with its editable fields replaced by ``meta``."""
81
+ year = int(meta.year) if meta.year.isdigit() else None
82
+ identifiers = rec.identifiers
83
+ if meta.isbn != (rec.isbns[0] if rec.isbns else ""):
84
+ # ``isbns`` derives from the identifiers, so an edited ISBN replaces both.
85
+ identifiers = {k: v for k, v in rec.identifiers.items() if k[:4] != "isbn"}
86
+ if meta.isbn:
87
+ identifiers["isbn13"] = (meta.isbn,)
88
+ return replace(
89
+ rec,
90
+ title=meta.title,
91
+ author=meta.author,
92
+ publisher=meta.publisher,
93
+ series=meta.series,
94
+ series_index=meta.series_index,
95
+ year=year,
96
+ languages=(meta.language,) if meta.language else (),
97
+ identifiers=identifiers,
98
+ description=meta.description,
99
+ )
100
+
101
+
102
+ def render_record(rec: BookRecord, *, edited: bool = False) -> Text:
103
+ """Format ``rec`` as styled text for the detail pane."""
104
+ text = Text()
105
+ if edited:
106
+ text.append("✎ edited locally\n", style="yellow")
107
+ text.append(f"{rec.title or rec.md5}\n", style="bold")
108
+ series = f"{rec.series} #{rec.series_index}" if rec.series_index else rec.series
109
+ fields = [
110
+ ("Author", rec.author),
111
+ ("Publisher", rec.publisher),
112
+ ("Series", series),
113
+ ("Year", str(rec.year) if rec.year else ""),
114
+ ("Language", ", ".join(rec.languages)),
115
+ ("Format", f"{rec.extension} {rec.size_text}".strip()),
116
+ ("ISBN", ", ".join(rec.isbns)),
117
+ ]
118
+ for label, value in fields:
119
+ if value:
120
+ text.append(f"{label}: ", style="cyan")
121
+ text.append(f"{value}\n")
122
+ if rec.description:
123
+ text.append(f"\n{rec.description}")
124
+ return text
125
+
126
+
127
+ class AaApp(App[None]):
128
+ """Search Anna's Archive, inspect a result, and download it."""
129
+
130
+ CSS = """
131
+ #main { height: 1fr; }
132
+ DataTable { width: 2fr; height: 100%; }
133
+ #detail { width: 1fr; height: 100%; border-left: solid $primary; padding: 0 1;
134
+ overflow-y: auto; }
135
+ """
136
+ BINDINGS: ClassVar[list[BindingType]] = [
137
+ Binding("/", "focus_search", "Search"),
138
+ Binding("d", "download", "Download"),
139
+ Binding("o", "open_browser", "Open in browser"),
140
+ Binding("e", "edit", "Edit metadata"),
141
+ Binding("w", "write_tags", "Write tags"),
142
+ Binding("q", "quit", "Quit"),
143
+ ]
144
+
145
+ def __init__(
146
+ self,
147
+ settings: Settings,
148
+ query: str | None = None,
149
+ store: MetadataStore | None = None,
150
+ ) -> None:
151
+ """Create the app with resolved ``settings``, a first query and edit store."""
152
+ super().__init__()
153
+ self._settings = settings
154
+ self._initial_query = query
155
+ self._store = store or MetadataStore(DEFAULT_STORE)
156
+ self._records: dict[str, BookRecord] = {}
157
+ self._last_query = ""
158
+ self._loading: LoadingScreen | None = None
159
+
160
+ def compose(self) -> ComposeResult:
161
+ """Lay out the widgets."""
162
+ yield Header()
163
+ yield Input(placeholder="Search Anna's Archive…", id="query")
164
+ with Horizontal(id="main"):
165
+ yield DataTable(id="results", cursor_type="row")
166
+ yield Static("Select a result to see its details.", id="detail")
167
+ yield Footer()
168
+
169
+ def on_mount(self) -> None:
170
+ """Route stray output to toasts, prepare the table and run any first query."""
171
+ self._capture_output()
172
+ table = self.query_one(DataTable)
173
+ for label, width in COLUMNS:
174
+ table.add_column(label, width=width)
175
+ if self._initial_query:
176
+ self.query_one(Input).value = self._initial_query
177
+ self._last_query = self._initial_query
178
+ self._search(self._initial_query)
179
+
180
+ def _capture_output(self) -> None:
181
+ """Send stdout, stderr and loguru output to toasts instead of the screen."""
182
+ self._saved_streams = (sys.stdout, sys.stderr)
183
+ self._out = ToastStream(self.toast, "information")
184
+ self._err = ToastStream(self.toast, "error")
185
+ sys.stdout, sys.stderr = self._out, self._err
186
+ logger.remove()
187
+ self._log_id = logger.add(self._log_sink, level="INFO", format="{message}")
188
+
189
+ def on_unmount(self) -> None:
190
+ """Restore the real streams and flush any partial line."""
191
+ self._out.flush_partial()
192
+ self._err.flush_partial()
193
+ sys.stdout, sys.stderr = self._saved_streams
194
+ logger.remove(self._log_id)
195
+
196
+ def _log_sink(self, message: Any) -> None:
197
+ """Show a loguru record as a toast at the matching severity."""
198
+ level = message.record["level"].no
199
+ severity = (
200
+ "error" if level >= 40 else "warning" if level >= 30 else "information"
201
+ )
202
+ self.toast(message.record["message"], severity)
203
+
204
+ def toast(self, message: str, severity: str = "information") -> None:
205
+ """Show a toast from any thread (errors linger longer)."""
206
+ timeout = 10.0 if severity == "error" else 5.0
207
+ if self._thread_id == threading.get_ident():
208
+ self.notify(message, severity=severity, timeout=timeout) # type: ignore[arg-type]
209
+ else:
210
+ self.call_from_thread(
211
+ self.notify, message, severity=severity, timeout=timeout
212
+ )
213
+
214
+ def _handle_exception(self, error: Exception) -> None:
215
+ """Show unexpected exceptions in a modal instead of exiting the app.
216
+
217
+ Overrides Textual's private hook, which would otherwise print the
218
+ traceback and quit. A failed background worker leaves the app fully
219
+ usable; an exception in a UI handler may leave that widget unresponsive,
220
+ so the modal says to restart if the app stops reacting.
221
+ """
222
+ from textual.worker import WorkerFailed
223
+
224
+ recoverable = isinstance(error, WorkerFailed)
225
+ cause = error.error if isinstance(error, WorkerFailed) else error
226
+ self._end_loading()
227
+ self.push_screen(ErrorScreen(cause, recoverable=recoverable))
228
+
229
+ def on_input_submitted(self, event: Input.Submitted) -> None:
230
+ """Search for the submitted text; ignore blank input."""
231
+ if query := event.value.strip():
232
+ self._last_query = query
233
+ self._search(query)
234
+
235
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
236
+ """Load details for the highlighted row."""
237
+ if event.row_key.value:
238
+ self._load_details(event.row_key.value)
239
+
240
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
241
+ """Pop up the full details for a clicked (or Enter-ed) row."""
242
+ md5 = event.row_key.value
243
+ if md5 is None:
244
+ return
245
+ if md5 not in self._records:
246
+ self.notify("Details are still loading…")
247
+ return
248
+ rec = self._records[md5]
249
+ edited = self._store_has_edits(md5)
250
+ if edited and (entry := self._store.get(md5)):
251
+ rec = apply_meta(rec, entry.meta)
252
+ self.push_screen(DetailsScreen(rec, edited=edited))
253
+
254
+ def action_open_browser(self) -> None:
255
+ """Open the current search's results page on the site in the browser."""
256
+ if not self._last_query:
257
+ self.notify("Run a search first.", severity="warning")
258
+ return
259
+ url = f"{self._settings.origin}/search?{urlencode({'q': self._last_query})}"
260
+ if not webbrowser.open(url):
261
+ self.notify(f"Could not open a browser. URL: {url}", severity="error")
262
+
263
+ def action_focus_search(self) -> None:
264
+ """Move focus to the search box."""
265
+ self.query_one(Input).focus()
266
+
267
+ def _highlighted_md5(self) -> str | None:
268
+ """MD5 of the highlighted row, or ``None`` when the table is empty."""
269
+ table = self.query_one(DataTable)
270
+ if not table.row_count:
271
+ return None
272
+ return table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
273
+
274
+ def action_download(self) -> None:
275
+ """Download the highlighted book."""
276
+ if md5 := self._highlighted_md5():
277
+ self._download(md5)
278
+
279
+ def action_edit(self) -> None:
280
+ """Open the metadata form for the highlighted book."""
281
+ md5 = self._highlighted_md5()
282
+ rec = self._records.get(md5 or "")
283
+ if md5 is None or rec is None:
284
+ self.notify("Wait for the details to load first.", severity="warning")
285
+ return
286
+ entry = self._store.get(md5)
287
+ meta = entry.meta if entry else EditableMeta.from_record(rec)
288
+
289
+ def done(new: EditableMeta | None) -> None:
290
+ if new is None:
291
+ return
292
+ entry = self._store.set_meta(md5, new)
293
+ self._show_record(md5)
294
+ if entry.file and entry.file.exists():
295
+ self._embed(entry.file, entry.meta)
296
+ else:
297
+ self.notify(
298
+ "Saved. Tags are written when the file is downloaded (or press w)."
299
+ )
300
+
301
+ self.push_screen(EditScreen(meta), done)
302
+
303
+ def action_write_tags(self) -> None:
304
+ """Embed the stored metadata into the downloaded file."""
305
+ md5 = self._highlighted_md5()
306
+ entry = self._store.get(md5) if md5 else None
307
+ if entry is None or entry.file is None or not entry.file.exists():
308
+ self.notify(
309
+ "Edit metadata and download the file first.", severity="warning"
310
+ )
311
+ return
312
+ self._embed(entry.file, entry.meta)
313
+
314
+ def _embed(self, path: Path, meta: EditableMeta) -> None:
315
+ """Write ``meta`` into ``path`` and notify the outcome."""
316
+ try:
317
+ write_tags(path, meta)
318
+ except TagError as exc:
319
+ self.notify(str(exc), severity="error")
320
+ else:
321
+ self.notify(f"Tagged {path.name}")
322
+
323
+ def _show_record(self, md5: str) -> None:
324
+ """Render the record for ``md5`` with any local edits applied."""
325
+ rec = self._records[md5]
326
+ edited = self._store_has_edits(md5)
327
+ if edited and (entry := self._store.get(md5)):
328
+ rec = apply_meta(rec, entry.meta)
329
+ self.query_one("#detail", Static).update(render_record(rec, edited=edited))
330
+
331
+ def _search(self, query: str) -> None:
332
+ """Show the loading modal and start the search worker."""
333
+ self._loading = LoadingScreen(query)
334
+ self.push_screen(self._loading, self._on_loading_closed)
335
+ self._run_search(query)
336
+
337
+ def _on_loading_closed(self, cancelled: bool | None) -> None:
338
+ """Abandon the in-flight search if the user pressed ``esc``."""
339
+ self._loading = None
340
+ if cancelled:
341
+ self.workers.cancel_group(self, "search")
342
+
343
+ def _end_loading(self) -> None:
344
+ """Close the loading modal if it is still showing."""
345
+ if self._loading is not None:
346
+ loading, self._loading = self._loading, None
347
+ loading.dismiss(False)
348
+
349
+ @work(thread=True, exclusive=True, group="search")
350
+ def _run_search(self, query: str) -> None:
351
+ """Run a search off the UI thread and show the results or the error."""
352
+ worker = get_current_worker()
353
+ try:
354
+ books = service.search(self._settings, query)
355
+ except service.SERVICE_ERRORS as exc:
356
+ if not worker.is_cancelled:
357
+ self.call_from_thread(self._search_failed, str(exc))
358
+ return
359
+ if not worker.is_cancelled:
360
+ self.call_from_thread(self._show_results, books)
361
+
362
+ def _search_failed(self, message: str) -> None:
363
+ """Close the loading modal and report ``message``."""
364
+ self._end_loading()
365
+ self.notify(message, severity="error")
366
+
367
+ def _show_results(self, books: list[Book]) -> None:
368
+ """Replace the table rows with ``books`` and focus the table."""
369
+ self._end_loading()
370
+ table = self.query_one(DataTable)
371
+ table.clear()
372
+ for book, row in zip(books, result_rows(books), strict=True):
373
+ table.add_row(*row, key=book.md5)
374
+ if books:
375
+ table.focus()
376
+ else:
377
+ self.notify("No results.")
378
+
379
+ @work(thread=True, exclusive=True, group="details")
380
+ def _load_details(self, md5: str) -> None:
381
+ """Fetch a record off the UI thread and render it unless superseded."""
382
+ worker = get_current_worker()
383
+ try:
384
+ rec = service.details(self._settings, md5)
385
+ except service.SERVICE_ERRORS as exc:
386
+ if not worker.is_cancelled:
387
+ self.call_from_thread(self.notify, str(exc), severity="error")
388
+ return
389
+ if not worker.is_cancelled:
390
+ self.call_from_thread(self._remember, rec)
391
+
392
+ def _remember(self, rec: BookRecord) -> None:
393
+ """Cache ``rec`` and show it unless the cursor has moved on."""
394
+ self._records[rec.md5] = rec
395
+ if self._highlighted_md5() == rec.md5:
396
+ self._show_record(rec.md5)
397
+
398
+ @work(thread=True, group="download")
399
+ def _download(self, md5: str) -> None:
400
+ """Download ``md5`` off the UI thread and report the outcome."""
401
+ self.call_from_thread(self.notify, f"Downloading {md5}…")
402
+ try:
403
+ path = service.download(self._settings, md5, f"{md5}.bin")
404
+ except service.SERVICE_ERRORS as exc:
405
+ self.call_from_thread(self.notify, str(exc), severity="error")
406
+ return
407
+ self.call_from_thread(self._downloaded, md5, path)
408
+
409
+ def _downloaded(self, md5: str, path: Path) -> None:
410
+ """Record the saved ``path`` and embed any stored edits into it."""
411
+ rec = self._records.get(md5)
412
+ seed = EditableMeta.from_record(rec) if rec else EditableMeta()
413
+ entry = self._store.set_file(md5, path, seed)
414
+ self.notify(f"Saved {path}")
415
+ if self._store_has_edits(md5):
416
+ self._embed(path, entry.meta)
417
+
418
+ def _store_has_edits(self, md5: str) -> bool:
419
+ """Whether the user edited ``md5`` (as opposed to just recording a path)."""
420
+ rec = self._records.get(md5)
421
+ entry = self._store.get(md5)
422
+ return bool(entry and rec and entry.meta != EditableMeta.from_record(rec))
aa_tui/capture.py ADDED
@@ -0,0 +1,47 @@
1
+ """Turn writes to stdout/stderr and loguru messages into app toasts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ from collections.abc import Callable
7
+
8
+ from rich.text import Text
9
+
10
+ Severity = str # a Textual notify severity: "information", "warning" or "error"
11
+ Emit = Callable[[str, Severity], None]
12
+
13
+
14
+ class ToastStream(io.TextIOBase):
15
+ """A text stream that emits each completed non-blank line to ``emit``.
16
+
17
+ ANSI colour codes (from Rich, loguru, ...) are stripped. A trailing partial
18
+ line is emitted by :meth:`flush_partial`, which the app calls on shutdown.
19
+ """
20
+
21
+ def __init__(self, emit: Emit, severity: Severity) -> None:
22
+ """Send lines to ``emit`` with the given ``severity``."""
23
+ self._emit = emit
24
+ self._severity = severity
25
+ self._buffer = ""
26
+
27
+ def writable(self) -> bool:
28
+ """This stream accepts writes."""
29
+ return True
30
+
31
+ def write(self, s: str) -> int:
32
+ """Buffer ``s`` and emit every complete line in it."""
33
+ self._buffer += s
34
+ *lines, self._buffer = self._buffer.split("\n")
35
+ for line in lines:
36
+ self._send(line)
37
+ return len(s)
38
+
39
+ def flush_partial(self) -> None:
40
+ """Emit whatever is left in the buffer."""
41
+ rest, self._buffer = self._buffer, ""
42
+ self._send(rest)
43
+
44
+ def _send(self, line: str) -> None:
45
+ """Emit ``line`` if it has visible content."""
46
+ if text := Text.from_ansi(line).plain.strip():
47
+ self._emit(text, self._severity)
aa_tui/cli.py ADDED
@@ -0,0 +1,37 @@
1
+ """Typer entry point: ``aa-tui [QUERY]``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from aa_meta.config import DEFAULT_BASE_URL, Settings
10
+ from aa_meta.cookies import read_cookie_file
11
+
12
+ from .app import AaApp
13
+
14
+ DEFAULT_OUTPUT_DIR = Path.home() / "Downloads"
15
+
16
+ app = typer.Typer(help="Terminal UI for searching and downloading from Anna's Archive.")
17
+
18
+
19
+ @app.command()
20
+ def main(
21
+ query: Annotated[str | None, typer.Argument(help="Search to run on start.")] = None,
22
+ base_url: Annotated[
23
+ str, typer.Option("--base-url", envvar="AA_BASE_URL", help="Mirror domain.")
24
+ ] = DEFAULT_BASE_URL,
25
+ cookie: Annotated[
26
+ str | None,
27
+ typer.Option("--cookie", envvar="AA_COOKIE", help="Browser Cookie header."),
28
+ ] = None,
29
+ output: Annotated[
30
+ Path, typer.Option("--output", "-o", help="Download directory.")
31
+ ] = DEFAULT_OUTPUT_DIR,
32
+ ) -> None:
33
+ """Launch the TUI, optionally running QUERY immediately."""
34
+ settings = Settings(
35
+ base_url=base_url, cookie=cookie or read_cookie_file(), download_dir=output
36
+ )
37
+ AaApp(settings, query).run()
@@ -0,0 +1,100 @@
1
+ """Modal popup showing every field of a book record."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import ClassVar
7
+
8
+ from aa_meta.models import BookRecord
9
+ from rich.text import Text
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding, BindingType
12
+ from textual.containers import VerticalScroll
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Static
15
+
16
+
17
+ def _section(text: Text, title: str, rows: Mapping[str, tuple[str, ...]]) -> None:
18
+ """Append a titled ``label: a, b`` block to ``text`` unless ``rows`` is empty."""
19
+ rows = {k: v for k, v in rows.items() if v}
20
+ if not rows:
21
+ return
22
+ text.append(f"\n{title}\n", style="bold underline")
23
+ for label, values in rows.items():
24
+ text.append(f"{label}: ", style="cyan")
25
+ text.append("; ".join(values) + "\n")
26
+
27
+
28
+ def render_full(rec: BookRecord, *, edited: bool = False) -> Text:
29
+ """Format every populated field of ``rec`` as styled text."""
30
+ text = Text()
31
+ if edited:
32
+ text.append("✎ edited locally\n", style="yellow")
33
+ text.append(f"{rec.title or rec.md5}\n", style="bold")
34
+ series = f"{rec.series} #{rec.series_index}" if rec.series_index else rec.series
35
+ fields = [
36
+ ("Author", rec.author),
37
+ ("Publisher", rec.publisher_line or rec.publisher),
38
+ ("Series", series),
39
+ ("Year", str(rec.year) if rec.year else ""),
40
+ ("Language", ", ".join(rec.languages)),
41
+ ("ISBN", ", ".join(rec.isbns)),
42
+ ("Format", f"{rec.extension} {rec.size_text}".strip()),
43
+ ("Type", rec.content_type),
44
+ ("Sources", ", ".join(rec.sources)),
45
+ ("Filename", rec.filename),
46
+ ("MD5", rec.md5),
47
+ ]
48
+ for label, value in fields:
49
+ if value:
50
+ text.append(f"{label}: ", style="cyan")
51
+ text.append(f"{value}\n")
52
+ if rec.description:
53
+ text.append(f"\n{rec.description}\n")
54
+ _section(text, "Identifiers", rec.identifiers)
55
+ _section(text, "Alternatives", rec.alternatives)
56
+ if rec.filepaths:
57
+ _section(text, "Paths", {"": rec.filepaths})
58
+ if not rec.detailed:
59
+ text.append("\n(search-card data only)\n", style="dim")
60
+ return text
61
+
62
+
63
+ class DetailsScreen(ModalScreen[None]):
64
+ """Read-only popup for one record; ``e`` and ``d`` act on it via the app."""
65
+
66
+ DEFAULT_CSS = """
67
+ DetailsScreen { align: center middle; }
68
+ #popup { width: 85%; max-width: 110; height: 85%; border: thick $primary;
69
+ background: $surface; padding: 1 2; }
70
+ """
71
+ BINDINGS: ClassVar[list[BindingType]] = [
72
+ Binding("escape,q,enter", "close", "Close"),
73
+ Binding("e", "edit", "Edit"),
74
+ Binding("d", "download", "Download"),
75
+ ]
76
+
77
+ def __init__(self, rec: BookRecord, *, edited: bool = False) -> None:
78
+ """Show ``rec``; ``edited`` adds the local-edit marker."""
79
+ super().__init__()
80
+ self._rec = rec
81
+ self._edited = edited
82
+
83
+ def compose(self) -> ComposeResult:
84
+ """A scrollable text body."""
85
+ with VerticalScroll(id="popup"):
86
+ yield Static(render_full(self._rec, edited=self._edited))
87
+
88
+ def action_close(self) -> None:
89
+ """Dismiss the popup."""
90
+ self.dismiss(None)
91
+
92
+ def action_edit(self) -> None:
93
+ """Close, then open the app's edit form."""
94
+ self.dismiss(None)
95
+ self.app.call_later(self.app.run_action, "edit")
96
+
97
+ def action_download(self) -> None:
98
+ """Close, then download via the app."""
99
+ self.dismiss(None)
100
+ self.app.call_later(self.app.run_action, "download")
aa_tui/edit_screen.py ADDED
@@ -0,0 +1,88 @@
1
+ """Modal form for editing a book's metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict, fields
6
+ from typing import ClassVar
7
+
8
+ from textual.app import ComposeResult
9
+ from textual.binding import Binding, BindingType
10
+ from textual.containers import Horizontal, VerticalScroll
11
+ from textual.screen import ModalScreen
12
+ from textual.widgets import Button, Input, Label
13
+
14
+ from .metadata import EditableMeta
15
+
16
+ LABELS = {
17
+ "title": "Title",
18
+ "author": "Author",
19
+ "publisher": "Publisher",
20
+ "series": "Series",
21
+ "series_index": "Series #",
22
+ "year": "Year",
23
+ "language": "Language",
24
+ "isbn": "ISBN",
25
+ "description": "Description",
26
+ }
27
+
28
+
29
+ class EditScreen(ModalScreen[EditableMeta | None]):
30
+ """Edit an :class:`EditableMeta`; dismisses with the new value, or ``None``."""
31
+
32
+ DEFAULT_CSS = """
33
+ EditScreen { align: center middle; }
34
+ #form { width: 80%; max-width: 100; height: 80%; border: thick $primary;
35
+ background: $surface; padding: 1 2; }
36
+ #form Label { margin-top: 1; }
37
+ #buttons { height: auto; margin-top: 1; }
38
+ #buttons Button { margin-right: 2; }
39
+ """
40
+ BINDINGS: ClassVar[list[BindingType]] = [
41
+ Binding("escape", "cancel", "Cancel"),
42
+ Binding("ctrl+s", "save", "Save"),
43
+ ]
44
+
45
+ def __init__(self, meta: EditableMeta) -> None:
46
+ """Start the form pre-filled from ``meta``."""
47
+ super().__init__()
48
+ self._meta = meta
49
+
50
+ def compose(self) -> ComposeResult:
51
+ """One labelled input per editable field, then Save/Cancel."""
52
+ values = asdict(self._meta)
53
+ with VerticalScroll(id="form"):
54
+ for f in fields(EditableMeta):
55
+ yield Label(LABELS[f.name])
56
+ yield Input(values[f.name], id=f"f-{f.name}")
57
+ with Horizontal(id="buttons"):
58
+ yield Button("Save (ctrl+s)", variant="primary", id="save")
59
+ yield Button("Cancel (esc)", id="cancel")
60
+
61
+ def _collect(self) -> EditableMeta:
62
+ """Read the inputs back into an :class:`EditableMeta`."""
63
+ return EditableMeta(
64
+ **{
65
+ f.name: self.query_one(f"#f-{f.name}", Input).value
66
+ for f in fields(EditableMeta)
67
+ }
68
+ ).normalized()
69
+
70
+ def action_save(self) -> None:
71
+ """Dismiss with the edited metadata."""
72
+ self.dismiss(self._collect())
73
+
74
+ def action_cancel(self) -> None:
75
+ """Dismiss without changes."""
76
+ self.dismiss(None)
77
+
78
+ def on_input_submitted(self, event: Input.Submitted) -> None:
79
+ """Enter in any field saves."""
80
+ event.stop()
81
+ self.action_save()
82
+
83
+ def on_button_pressed(self, event: Button.Pressed) -> None:
84
+ """Route the Save/Cancel buttons."""
85
+ if event.button.id == "save":
86
+ self.action_save()
87
+ else:
88
+ self.action_cancel()
aa_tui/error_screen.py ADDED
@@ -0,0 +1,56 @@
1
+ """Modal for unexpected errors, with the full traceback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from rich.console import Group, RenderableType
8
+ from rich.text import Text
9
+ from rich.traceback import Traceback
10
+ from textual.app import ComposeResult
11
+ from textual.binding import Binding, BindingType
12
+ from textual.containers import VerticalScroll
13
+ from textual.screen import ModalScreen
14
+ from textual.widgets import Static
15
+
16
+
17
+ class ErrorScreen(ModalScreen[None]):
18
+ """Show an unhandled exception without exiting the app."""
19
+
20
+ DEFAULT_CSS = """
21
+ ErrorScreen { align: center middle; }
22
+ #error { width: 90%; max-width: 120; height: 80%; border: thick $error;
23
+ background: $surface; padding: 1 2; }
24
+ """
25
+ BINDINGS: ClassVar[list[BindingType]] = [
26
+ Binding("escape,enter,q", "close", "Close")
27
+ ]
28
+
29
+ def __init__(self, error: BaseException, *, recoverable: bool = True) -> None:
30
+ """Show ``error`` and its traceback; ``recoverable`` softens the hint."""
31
+ super().__init__()
32
+ self._error = error
33
+ self._recoverable = recoverable
34
+
35
+ def compose(self) -> ComposeResult:
36
+ """Headline, traceback, and a close hint."""
37
+ body: RenderableType = Group(
38
+ Text(f"{type(self._error).__name__}: {self._error}", style="bold red"),
39
+ Text(""),
40
+ Traceback.from_exception(
41
+ type(self._error), self._error, self._error.__traceback__, width=100
42
+ ),
43
+ Text(
44
+ "\nPress esc to close."
45
+ + (
46
+ "" if self._recoverable else " Restart if the app stops responding."
47
+ ),
48
+ style="dim",
49
+ ),
50
+ )
51
+ with VerticalScroll(id="error"):
52
+ yield Static(body)
53
+
54
+ def action_close(self) -> None:
55
+ """Dismiss the modal."""
56
+ self.dismiss(None)
@@ -0,0 +1,39 @@
1
+ """Modal spinner shown while a search is in flight."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import ClassVar
6
+
7
+ from textual.app import ComposeResult
8
+ from textual.binding import Binding, BindingType
9
+ from textual.containers import Vertical
10
+ from textual.screen import ModalScreen
11
+ from textual.widgets import Label, LoadingIndicator
12
+
13
+
14
+ class LoadingScreen(ModalScreen[bool]):
15
+ """Spinner popup; dismisses with ``True`` if the user cancels with ``esc``."""
16
+
17
+ DEFAULT_CSS = """
18
+ LoadingScreen { align: center middle; }
19
+ #loading { width: 44; height: 9; border: thick $primary; background: $surface;
20
+ padding: 1 2; align: center middle; }
21
+ #loading LoadingIndicator { height: 3; }
22
+ #loading Label { width: 100%; content-align: center middle; }
23
+ """
24
+ BINDINGS: ClassVar[list[BindingType]] = [Binding("escape", "cancel", "Cancel")]
25
+
26
+ def __init__(self, query: str) -> None:
27
+ """Show ``query`` in the message."""
28
+ super().__init__()
29
+ self._query = query
30
+
31
+ def compose(self) -> ComposeResult:
32
+ """Spinner above a one-line message."""
33
+ with Vertical(id="loading"):
34
+ yield LoadingIndicator()
35
+ yield Label(f"Searching for “{self._query}”… (esc to cancel)")
36
+
37
+ def action_cancel(self) -> None:
38
+ """Dismiss, flagging a user cancel."""
39
+ self.dismiss(True)
aa_tui/metadata.py ADDED
@@ -0,0 +1,121 @@
1
+ """Editable metadata and the local store that remembers edits per MD5."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from dataclasses import asdict, dataclass, fields, replace
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from aa_meta.models import BookRecord
12
+
13
+ DEFAULT_STORE = (
14
+ Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
15
+ / "aa-tui"
16
+ / "metadata.json"
17
+ )
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class EditableMeta:
22
+ """The subset of a record the user can edit and embed in a file."""
23
+
24
+ title: str = ""
25
+ author: str = ""
26
+ publisher: str = ""
27
+ series: str = ""
28
+ series_index: str = ""
29
+ year: str = ""
30
+ language: str = ""
31
+ isbn: str = ""
32
+ description: str = ""
33
+
34
+ @classmethod
35
+ def from_record(cls, rec: BookRecord) -> EditableMeta:
36
+ """Seed editable fields from a scraped ``rec``."""
37
+ return cls(
38
+ title=rec.title,
39
+ author=rec.author,
40
+ publisher=rec.publisher,
41
+ series=rec.series,
42
+ series_index=rec.series_index,
43
+ year=str(rec.year) if rec.year else "",
44
+ language=rec.language,
45
+ isbn=rec.isbns[0] if rec.isbns else "",
46
+ description=rec.description,
47
+ )
48
+
49
+ @classmethod
50
+ def from_dict(cls, data: dict[str, Any]) -> EditableMeta:
51
+ """Build from stored JSON, ignoring unknown keys."""
52
+ names = {f.name for f in fields(cls)}
53
+ return cls(**{k: str(v) for k, v in data.items() if k in names})
54
+
55
+ def normalized(self) -> EditableMeta:
56
+ """Return a copy with every field stripped of surrounding whitespace."""
57
+ return replace(self, **{k: v.strip() for k, v in asdict(self).items()})
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Entry:
62
+ """A stored edit: the metadata plus the downloaded file it applies to."""
63
+
64
+ meta: EditableMeta
65
+ file: Path | None = None
66
+
67
+
68
+ class MetadataStore:
69
+ """JSON file mapping MD5 -> :class:`Entry`; written atomically on every change."""
70
+
71
+ def __init__(self, path: Path = DEFAULT_STORE) -> None:
72
+ """Use ``path`` as the backing file (created lazily)."""
73
+ self._path = path
74
+
75
+ def _load(self) -> dict[str, Any]:
76
+ """Read the whole file; a missing or corrupt file is an empty store."""
77
+ try:
78
+ data = json.loads(self._path.read_text(encoding="utf-8"))
79
+ except OSError, ValueError:
80
+ return {}
81
+ return data if isinstance(data, dict) else {}
82
+
83
+ def _save(self, data: dict[str, Any]) -> None:
84
+ """Atomically replace the backing file with ``data``."""
85
+ self._path.parent.mkdir(parents=True, exist_ok=True)
86
+ tmp = self._path.with_suffix(".tmp")
87
+ tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
88
+ tmp.replace(self._path)
89
+
90
+ def get(self, md5: str) -> Entry | None:
91
+ """Return the entry for ``md5`` or ``None``."""
92
+ raw = self._load().get(md5)
93
+ if not isinstance(raw, dict):
94
+ return None
95
+ file = raw.get("file")
96
+ return Entry(
97
+ EditableMeta.from_dict(raw.get("meta", {})), Path(file) if file else None
98
+ )
99
+
100
+ def put(self, md5: str, entry: Entry) -> None:
101
+ """Insert or replace the entry for ``md5``."""
102
+ data = self._load()
103
+ data[md5] = {
104
+ "meta": asdict(entry.meta),
105
+ "file": str(entry.file) if entry.file else None,
106
+ }
107
+ self._save(data)
108
+
109
+ def set_meta(self, md5: str, meta: EditableMeta) -> Entry:
110
+ """Store ``meta`` for ``md5``, keeping any recorded file."""
111
+ old = self.get(md5)
112
+ entry = Entry(meta.normalized(), old.file if old else None)
113
+ self.put(md5, entry)
114
+ return entry
115
+
116
+ def set_file(self, md5: str, file: Path, fallback: EditableMeta) -> Entry:
117
+ """Record the downloaded ``file``; ``fallback`` seeds meta if none stored."""
118
+ old = self.get(md5)
119
+ entry = Entry(old.meta if old else fallback, file)
120
+ self.put(md5, entry)
121
+ return entry
aa_tui/py.typed ADDED
File without changes
aa_tui/service.py ADDED
@@ -0,0 +1,83 @@
1
+ """Thin synchronous wrappers over aa-meta, so the app talks to one module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Generator
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+ from aa_meta.api import fast_download
11
+ from aa_meta.config import USER_AGENT, Settings, read_secret_key
12
+ from aa_meta.details import fetch_details
13
+ from aa_meta.download import filename_from_response
14
+ from aa_meta.models import BookRecord
15
+ from aa_meta.search import Book, search_books
16
+
17
+ # Everything the aa-meta layers raise (all ``RuntimeError`` subclasses) plus I/O.
18
+ SERVICE_ERRORS = (RuntimeError, httpx.HTTPError, OSError)
19
+
20
+
21
+ @contextmanager
22
+ def _session(client: httpx.Client | None) -> Generator[httpx.Client]:
23
+ """Yield ``client``, or a fresh one that is closed afterwards."""
24
+ if client is not None:
25
+ yield client
26
+ return
27
+ with httpx.Client(timeout=30, follow_redirects=True) as owned:
28
+ yield owned
29
+
30
+
31
+ def search(
32
+ settings: Settings,
33
+ query: str,
34
+ *,
35
+ limit: int = 20,
36
+ client: httpx.Client | None = None,
37
+ ) -> list[Book]:
38
+ """Search Anna's Archive for ``query``.
39
+
40
+ Raises:
41
+ SearchError: if the site blocks the request.
42
+ """
43
+ with _session(client) as c:
44
+ return search_books(c, settings, query, limit=limit)
45
+
46
+
47
+ def details(
48
+ settings: Settings, md5: str, *, client: httpx.Client | None = None
49
+ ) -> BookRecord:
50
+ """Fetch the full metadata record for ``md5``."""
51
+ with _session(client) as c:
52
+ return fetch_details(c, settings, md5)
53
+
54
+
55
+ def download(
56
+ settings: Settings,
57
+ md5: str,
58
+ fallback_name: str,
59
+ *,
60
+ client: httpx.Client | None = None,
61
+ ) -> Path:
62
+ """Download ``md5`` into ``settings.download_dir`` and return the saved path.
63
+
64
+ Streams without Rich progress output, which would corrupt the TUI screen.
65
+
66
+ Raises:
67
+ ConfigError: if the API key is unavailable.
68
+ ApiError: if the API refuses the download.
69
+ httpx.HTTPError: on a network failure.
70
+ """
71
+ key = read_secret_key(settings.op_ref)
72
+ with _session(client) as c:
73
+ fd = fast_download(c, settings, md5, key)
74
+ settings.download_dir.mkdir(parents=True, exist_ok=True)
75
+ with c.stream(
76
+ "GET", fd.url, headers={"User-Agent": USER_AGENT}, follow_redirects=True
77
+ ) as resp:
78
+ resp.raise_for_status()
79
+ path = settings.download_dir / filename_from_response(resp, fallback_name)
80
+ with path.open("wb") as fh:
81
+ for chunk in resp.iter_bytes(65536):
82
+ fh.write(chunk)
83
+ return path
aa_tui/tags.py ADDED
@@ -0,0 +1,219 @@
1
+ """Write :class:`~aa_tui.metadata.EditableMeta` into EPUB, PDF, MP4/M4B and MP3 files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import tempfile
7
+ import zipfile
8
+ from collections.abc import Callable
9
+ from pathlib import Path
10
+ from typing import IO
11
+ from xml.etree import ElementTree as ET
12
+
13
+ from mutagen.id3 import ID3, TALB, TDRC, TIT2, TPE1, TPUB, ID3NoHeaderError
14
+ from mutagen.mp4 import MP4
15
+ from pypdf import PdfReader, PdfWriter
16
+
17
+ from .metadata import EditableMeta
18
+
19
+ DC = "http://purl.org/dc/elements/1.1/"
20
+ OPF = "http://www.idpf.org/2007/opf"
21
+ CONTAINER = "urn:oasis:names:tc:opendocument:xmlns:container"
22
+
23
+
24
+ class TagError(RuntimeError):
25
+ """The file could not be tagged (unsupported type or malformed contents)."""
26
+
27
+
28
+ def write_tags(path: Path, meta: EditableMeta) -> None:
29
+ """Embed ``meta`` into ``path``, dispatching on its extension.
30
+ Args:
31
+ path: The file path to write tags into.
32
+ meta: The metadata to embed.
33
+ Raises:
34
+ TagError: for an unsupported extension or an unreadable file.
35
+ """
36
+ writers = {
37
+ ".epub": _write_epub,
38
+ ".pdf": _write_pdf,
39
+ ".m4b": _write_mp4,
40
+ ".m4a": _write_mp4,
41
+ ".mp4": _write_mp4,
42
+ ".mp3": _write_mp3,
43
+ }
44
+ writer = writers.get(path.suffix.lower())
45
+ if writer is None:
46
+ raise TagError(f"Cannot embed metadata in {path.suffix or 'this'} files.")
47
+ try:
48
+ writer(path, meta)
49
+ except TagError:
50
+ raise
51
+ except Exception as exc: # third-party parsers raise many unrelated types
52
+ raise TagError(f"{path.name}: {exc}") from exc
53
+
54
+
55
+ def _write_mp4(path: Path, meta: EditableMeta) -> None:
56
+ """Set iTunes-style atoms on an MP4/M4B/M4A file.
57
+ Args:
58
+ path: The path to the MP4/M4B/M4A file.
59
+ meta: The metadata to embed.
60
+ """
61
+ tags = MP4(path)
62
+ if tags.tags is None:
63
+ tags.add_tags()
64
+ assert tags.tags is not None
65
+ t = tags.tags
66
+ for key, value in (
67
+ ("\xa9nam", meta.title),
68
+ ("\xa9alb", meta.title),
69
+ ("\xa9ART", meta.author),
70
+ ("aART", meta.author),
71
+ ("\xa9day", meta.year),
72
+ ("desc", meta.description),
73
+ ):
74
+ if value:
75
+ t[key] = [value]
76
+ else:
77
+ t.pop(key, None)
78
+ if meta.series:
79
+ t["\xa9grp"] = [meta.series]
80
+ tags.save()
81
+
82
+
83
+ def _write_mp3(path: Path, meta: EditableMeta) -> None:
84
+ """Set ID3v2 frames on an MP3 file.
85
+ Args:
86
+ path: The path to the MP3 file.
87
+ meta: The metadata to embed.
88
+ """
89
+ try:
90
+ id3 = ID3(path)
91
+ except ID3NoHeaderError:
92
+ id3 = ID3()
93
+ for frame, value in (
94
+ (TIT2, meta.title),
95
+ (TALB, meta.title),
96
+ (TPE1, meta.author),
97
+ (TPUB, meta.publisher),
98
+ (TDRC, meta.year),
99
+ ):
100
+ id3.delall(frame.__name__)
101
+ if value:
102
+ id3.add(frame(encoding=3, text=[value]))
103
+ id3.save(path)
104
+
105
+
106
+ def _write_pdf(path: Path, meta: EditableMeta) -> None:
107
+ """Rewrite the PDF with an updated document-information dictionary.
108
+ Args:
109
+ path: The path to the PDF file.
110
+ meta: The metadata to embed.
111
+ """
112
+ reader = PdfReader(path)
113
+ writer = PdfWriter(clone_from=reader)
114
+ info = {"/Title": meta.title, "/Author": meta.author, "/Subject": meta.description}
115
+ writer.add_metadata({k: v for k, v in info.items() if v})
116
+ _replace_via_temp(path, lambda fh: writer.write(fh) and None)
117
+
118
+
119
+ def _replace_via_temp(path: Path, write: Callable[[IO[bytes]], None]) -> None:
120
+ """Run ``write(tmp_path)`` then atomically swap it over ``path``.
121
+ Args:
122
+ path: The path to the file to replace.
123
+ write: A function that writes to a file-like object.
124
+ """
125
+ with tempfile.NamedTemporaryFile(
126
+ dir=path.parent, suffix=path.suffix, delete=False
127
+ ) as tmp:
128
+ tmp_path = Path(tmp.name)
129
+ try:
130
+ with tmp_path.open("wb") as fh:
131
+ write(fh)
132
+ shutil.copymode(path, tmp_path)
133
+ tmp_path.replace(path)
134
+ finally:
135
+ tmp_path.unlink(missing_ok=True)
136
+
137
+
138
+ def _write_epub(path: Path, meta: EditableMeta) -> None:
139
+ """Update Dublin Core and calibre series entries in the EPUB's OPF package.
140
+ Args:
141
+ path: The path to the EPUB file.
142
+ meta: The metadata to embed.
143
+ """
144
+ with zipfile.ZipFile(path) as zin:
145
+ container = ET.fromstring(zin.read("META-INF/container.xml"))
146
+ rootfile = container.find(f".//{{{CONTAINER}}}rootfile")
147
+ if rootfile is None or not rootfile.get("full-path"):
148
+ raise TagError("EPUB has no package document.")
149
+ opf_name = rootfile.get("full-path", "")
150
+ opf_xml = _update_opf(zin.read(opf_name), meta)
151
+
152
+ def write(fh: IO[bytes]) -> None:
153
+ with zipfile.ZipFile(fh, "w") as zout:
154
+ # The mimetype entry must come first and be stored uncompressed.
155
+ for item in sorted(
156
+ zin.infolist(), key=lambda i: i.filename != "mimetype"
157
+ ):
158
+ data = (
159
+ opf_xml
160
+ if item.filename == opf_name
161
+ else zin.read(item.filename)
162
+ )
163
+ zout.writestr(item, data, compress_type=item.compress_type)
164
+
165
+ _replace_via_temp(path, write)
166
+
167
+
168
+ def _update_opf(xml: bytes, meta: EditableMeta) -> bytes:
169
+ """Return ``xml`` (an OPF package) with ``meta`` applied; empty fields are removed.
170
+ Args:
171
+ xml: The OPF package XML.
172
+ meta: The metadata to embed.
173
+ Returns:
174
+ The updated OPF package XML.
175
+ Raises:
176
+ TagError: If the EPUB package has no metadata block.
177
+ """
178
+ ET.register_namespace("dc", DC)
179
+ ET.register_namespace("opf", OPF)
180
+ root = ET.fromstring(xml)
181
+ md = root.find(f"{{{OPF}}}metadata")
182
+ if md is None:
183
+ raise TagError("EPUB package has no metadata block.")
184
+
185
+ def set_dc(tag: str, value: str) -> None:
186
+ for el in md.findall(f"{{{DC}}}{tag}"):
187
+ md.remove(el)
188
+ if value:
189
+ ET.SubElement(md, f"{{{DC}}}{tag}").text = value
190
+
191
+ set_dc("title", meta.title)
192
+ set_dc("creator", meta.author)
193
+ set_dc("publisher", meta.publisher)
194
+ set_dc("language", meta.language)
195
+ set_dc("description", meta.description)
196
+ set_dc("date", meta.year)
197
+ for el in md.findall(f"{{{DC}}}identifier"):
198
+ if (el.get(f"{{{OPF}}}scheme") or "").upper() == "ISBN":
199
+ md.remove(el)
200
+ if meta.isbn:
201
+ ET.SubElement(
202
+ md, f"{{{DC}}}identifier", {f"{{{OPF}}}scheme": "ISBN"}
203
+ ).text = meta.isbn
204
+
205
+ for el in md.findall(f"{{{OPF}}}meta"):
206
+ if el.get("name") in {"calibre:series", "calibre:series_index"}:
207
+ md.remove(el)
208
+ if meta.series:
209
+ ET.SubElement(
210
+ md, f"{{{OPF}}}meta", {"name": "calibre:series", "content": meta.series}
211
+ )
212
+ if meta.series_index:
213
+ ET.SubElement(
214
+ md,
215
+ f"{{{OPF}}}meta",
216
+ {"name": "calibre:series_index", "content": meta.series_index},
217
+ )
218
+ out: bytes = ET.tostring(root, encoding="utf-8", xml_declaration=True)
219
+ return out
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.3
2
+ Name: aa-tui
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: Maxwell Ludden
6
+ Author-email: Maxwell Ludden <maxludden@me.com>
7
+ Requires-Dist: aa-meta
8
+ Requires-Dist: httpx>=0.28.1
9
+ Requires-Dist: loguru>=0.7.3
10
+ Requires-Dist: mutagen>=1.48.1
11
+ Requires-Dist: pypdf>=6.20.0
12
+ Requires-Dist: rich>=15.0.0
13
+ Requires-Dist: textual>=8.2.8
14
+ Requires-Dist: typer>=0.27.3
15
+ Requires-Python: >=3.14
16
+ Description-Content-Type: text/markdown
17
+
18
+ # aa-tui
19
+
20
+ Textual front-end for [aa-meta](../aa-meta): search Anna's Archive, inspect a result, download it, and edit its metadata.
21
+
22
+ ```bash
23
+ uv sync
24
+ uv run aa-tui ["query"] [--base-url ...] [--cookie ...] [-o DIR]
25
+ ```
26
+
27
+ Results are shown with `aa-meta`'s columns: #, Ext, Title, Author (colour-coded per author), Size, Lang, Year and MD5.
28
+
29
+ Keys: `Enter` in the search box searches (a spinner popup shows while waiting; `esc` cancels); `/` focuses search; `↑/↓` selects (details load automatically); `Enter` or a click on a row opens a details popup (`e`/`d` work inside it, `esc` closes); `o` opens the current search on the site in your browser; `d` downloads (needs `AA_SECRET_KEY` or 1Password); `q` quits (when not typing).
30
+
31
+ ## Editing metadata
32
+
33
+ `e` opens a form for the highlighted book (title, author, publisher, series, year, language, ISBN, description). `ctrl+s` saves, `esc` cancels.
34
+ Edits are stored per MD5 in `~/.config/aa-tui/metadata.json` and shown in the detail pane. When the book has been downloaded, saving also embeds the tags in the file; otherwise they are embedded as soon as it downloads. `w` re-embeds on demand.
35
+
36
+ Supported formats: EPUB (OPF Dublin Core + calibre series), PDF (info dictionary), M4B/M4A/MP4 (iTunes atoms), MP3 (ID3v2). Other formats are left untouched with an error notice.
37
+
38
+ ## Errors and output
39
+
40
+ Nothing is printed over the TUI. Anything written to stdout/stderr or logged through `loguru` (including from `aa-meta`) appears as a toast: info for stdout and `INFO`, warning for `WARNING`, error (shown longer) for stderr and `ERROR`+. Expected failures (blocked search, API refusals, network errors, unsupported tag formats) are toasts too. An unexpected exception opens a modal with the full traceback instead of quitting; `esc` closes it.
@@ -0,0 +1,16 @@
1
+ aa_tui/__init__.py,sha256=GhZi1VKb4YRmqCeHzQi0l0_cvhQIJa3T5k7J8lWhk4Q,37
2
+ aa_tui/app.py,sha256=fVQQ7ygcTc4kduhpAEWCEZetEzuut6yR43bbZPch-yY,16744
3
+ aa_tui/capture.py,sha256=TnszhvAay8TeO00baxgBBYI1v4GEubscL9a35TRLbZ8,1512
4
+ aa_tui/cli.py,sha256=qiBV2lSz_BaRb-6VqWvLfaVOVYybI44L0szLCwvmxTY,1152
5
+ aa_tui/details_screen.py,sha256=b6eRXkfM2T4eTVKDCyLovq2KP60ppZrTOsPG0CAUeik,3599
6
+ aa_tui/edit_screen.py,sha256=mY9Lb7gje1oU_F26ovrdZ6S_elbr5RXNjoBJZq3WAbg,2856
7
+ aa_tui/error_screen.py,sha256=WtYTrrK5SRuPOei_84v2-xjcE5_QVpDB6rAcoKkrEYY,1897
8
+ aa_tui/loading_screen.py,sha256=KgA_080ScvNURNu24hGZ_ccYiGxF_ZyV_PRg1lIxDpo,1364
9
+ aa_tui/metadata.py,sha256=n_GPuOnUPLPUmO7lA1TCN4HmVomtnyTLrguRCsmhPSI,4045
10
+ aa_tui/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ aa_tui/service.py,sha256=_DfshPkHl2gc1EzvFA8GBxdEN1aR1IZ81pAFoxtQ5Gw,2600
12
+ aa_tui/tags.py,sha256=CdFdjNpYBdkysKlGPtkoXNqF-GSWBxMiLGscKQKE1dc,7133
13
+ aa_tui-0.1.0.dist-info/WHEEL,sha256=xTCBFIZIIyRSxyowOv5Wqg_jFU6KsdIffuulBB09Ph8,80
14
+ aa_tui-0.1.0.dist-info/entry_points.txt,sha256=RSrmSkw2AYYtcvOiqOvR3ilhaWlqb0XE1BqstM-_A6A,43
15
+ aa_tui-0.1.0.dist-info/METADATA,sha256=hF5i-rZEu_5_iCEJ6UOqbPP-PGxwNYEo4XQZ1xQDhwY,2238
16
+ aa_tui-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ aa-tui = aa_tui.cli:app
3
+