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 +40 -0
- aa_tui-0.1.0/README.md +23 -0
- aa_tui-0.1.0/pyproject.toml +50 -0
- aa_tui-0.1.0/pyproject.toml.orig +48 -0
- aa_tui-0.1.0/src/aa_tui/__init__.py +1 -0
- aa_tui-0.1.0/src/aa_tui/app.py +422 -0
- aa_tui-0.1.0/src/aa_tui/capture.py +47 -0
- aa_tui-0.1.0/src/aa_tui/cli.py +37 -0
- aa_tui-0.1.0/src/aa_tui/details_screen.py +100 -0
- aa_tui-0.1.0/src/aa_tui/edit_screen.py +88 -0
- aa_tui-0.1.0/src/aa_tui/error_screen.py +56 -0
- aa_tui-0.1.0/src/aa_tui/loading_screen.py +39 -0
- aa_tui-0.1.0/src/aa_tui/metadata.py +121 -0
- aa_tui-0.1.0/src/aa_tui/py.typed +0 -0
- aa_tui-0.1.0/src/aa_tui/service.py +83 -0
- aa_tui-0.1.0/src/aa_tui/tags.py +219 -0
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()
|