aa-tui 0.1.0__tar.gz

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-0.1.0/PKG-INFO ADDED
@@ -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.
aa_tui-0.1.0/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # aa-tui
2
+
3
+ Textual front-end for [aa-meta](../aa-meta): search Anna's Archive, inspect a result, download it, and edit its metadata.
4
+
5
+ ```bash
6
+ uv sync
7
+ uv run aa-tui ["query"] [--base-url ...] [--cookie ...] [-o DIR]
8
+ ```
9
+
10
+ Results are shown with `aa-meta`'s columns: #, Ext, Title, Author (colour-coded per author), Size, Lang, Year and MD5.
11
+
12
+ 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).
13
+
14
+ ## Editing metadata
15
+
16
+ `e` opens a form for the highlighted book (title, author, publisher, series, year, language, ISBN, description). `ctrl+s` saves, `esc` cancels.
17
+ 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.
18
+
19
+ 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.
20
+
21
+ ## Errors and output
22
+
23
+ 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,50 @@
1
+ [project]
2
+ name = "aa-tui"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "aa-meta",
9
+ "httpx>=0.28.1",
10
+ "loguru>=0.7.3",
11
+ "mutagen>=1.48.1",
12
+ "pypdf>=6.20.0",
13
+ "rich>=15.0.0",
14
+ "textual>=8.2.8",
15
+ "typer>=0.27.3",
16
+ ]
17
+
18
+ [[project.authors]]
19
+ name = "Maxwell Ludden"
20
+ email = "maxludden@me.com"
21
+
22
+ [project.scripts]
23
+ aa-tui = "aa_tui.cli:app"
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.13.0,<0.14.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [tool.uv.sources.aa-meta]
30
+ path = "../aa-meta"
31
+ editable = true
32
+
33
+ [tool.pytest.ini_options]
34
+ asyncio_mode = "auto"
35
+ testpaths = ["tests"]
36
+
37
+ [tool.mypy]
38
+ strict = true
39
+
40
+ [[tool.mypy.overrides]]
41
+ module = "aa_tui.tags"
42
+ disallow_untyped_calls = false
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "mypy>=2.4.0",
47
+ "pytest>=9.1.1",
48
+ "pytest-asyncio>=1.4.0",
49
+ "ruff>=0.17.0",
50
+ ]
@@ -0,0 +1,48 @@
1
+ [project]
2
+ name = "aa-tui"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Maxwell Ludden", email = "maxludden@me.com" }
8
+ ]
9
+ requires-python = ">=3.14"
10
+ dependencies = [
11
+ "aa-meta",
12
+ "httpx>=0.28.1",
13
+ "loguru>=0.7.3",
14
+ "mutagen>=1.48.1",
15
+ "pypdf>=6.20.0",
16
+ "rich>=15.0.0",
17
+ "textual>=8.2.8",
18
+ "typer>=0.27.3",
19
+ ]
20
+
21
+ [project.scripts]
22
+ aa-tui = "aa_tui.cli:app"
23
+
24
+ [build-system]
25
+ requires = ["uv_build>=0.13.0,<0.14.0"]
26
+ build-backend = "uv_build"
27
+
28
+ [tool.uv.sources]
29
+ aa-meta = { path = "../aa-meta", editable = true }
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "mypy>=2.4.0",
34
+ "pytest>=9.1.1",
35
+ "pytest-asyncio>=1.4.0",
36
+ "ruff>=0.17.0",
37
+ ]
38
+
39
+ [tool.pytest.ini_options]
40
+ asyncio_mode = "auto"
41
+ testpaths = ["tests"]
42
+
43
+ [tool.mypy]
44
+ strict = true
45
+
46
+ [[tool.mypy.overrides]]
47
+ module = "aa_tui.tags" # mutagen is untyped
48
+ disallow_untyped_calls = false
@@ -0,0 +1 @@
1
+ """Textual front-end for aa-meta."""
@@ -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))
@@ -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)
@@ -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()