Compare commits
13 Commits
f528df49a9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c6edfa5572 | |||
| ac99643dbc | |||
| 889ac62a9a | |||
| 0bf6db7980 | |||
| 6aa4ebb33f | |||
| ca43ea8858 | |||
| 733e35b0d2 | |||
| f3573dfffc | |||
| d17cb6f4d2 | |||
| 6e3eb87f76 | |||
| b5f82d6e33 | |||
| 8bddca2f75 | |||
| bb8571df8a |
16
README.md
16
README.md
@@ -148,9 +148,23 @@ $ uv sync
|
|||||||
$ uv run python -m auditui.cli
|
$ uv run python -m auditui.cli
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Don't forget to run the tests.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
WIP.
|
As usual, tests are located in `tests` directory and use `pytest`.
|
||||||
|
|
||||||
|
Get the dev dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --extra dev
|
||||||
|
```
|
||||||
|
|
||||||
|
And run the tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Auditui package"""
|
"""Auditui package"""
|
||||||
|
|
||||||
__version__ = "0.1.1"
|
__version__ = "0.1.4"
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ class Auditui(App):
|
|||||||
table.cursor_type = "row"
|
table.cursor_type = "row"
|
||||||
yield table
|
yield table
|
||||||
yield Static("", id="progress_info")
|
yield Static("", id="progress_info")
|
||||||
yield ProgressBar(id="progress_bar", show_eta=False, show_percentage=False, total=100)
|
with Horizontal(id="progress_bar_container"):
|
||||||
|
yield ProgressBar(id="progress_bar", show_eta=False, show_percentage=False, total=100)
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
"""Initialize the table and start fetching library data."""
|
"""Initialize the table and start fetching library data."""
|
||||||
@@ -525,6 +526,8 @@ class Auditui(App):
|
|||||||
|
|
||||||
progress_info = self.query_one("#progress_info", Static)
|
progress_info = self.query_one("#progress_info", Static)
|
||||||
progress_bar = self.query_one("#progress_bar", ProgressBar)
|
progress_bar = self.query_one("#progress_bar", ProgressBar)
|
||||||
|
progress_bar_container = self.query_one(
|
||||||
|
"#progress_bar_container", Horizontal)
|
||||||
|
|
||||||
progress_percent = min(100.0, max(
|
progress_percent = min(100.0, max(
|
||||||
0.0, (chapter_elapsed / chapter_total) * 100.0))
|
0.0, (chapter_elapsed / chapter_total) * 100.0))
|
||||||
@@ -534,14 +537,15 @@ class Auditui(App):
|
|||||||
progress_info.update(
|
progress_info.update(
|
||||||
f"{chapter_name} | {chapter_elapsed_str} / {chapter_total_str}")
|
f"{chapter_name} | {chapter_elapsed_str} / {chapter_total_str}")
|
||||||
progress_info.display = True
|
progress_info.display = True
|
||||||
progress_bar.display = True
|
progress_bar_container.display = True
|
||||||
|
|
||||||
def _hide_progress(self) -> None:
|
def _hide_progress(self) -> None:
|
||||||
"""Hide the progress widget."""
|
"""Hide the progress widget."""
|
||||||
progress_info = self.query_one("#progress_info", Static)
|
progress_info = self.query_one("#progress_info", Static)
|
||||||
progress_bar = self.query_one("#progress_bar", ProgressBar)
|
progress_bar_container = self.query_one(
|
||||||
|
"#progress_bar_container", Horizontal)
|
||||||
progress_info.display = False
|
progress_info.display = False
|
||||||
progress_bar.display = False
|
progress_bar_container.display = False
|
||||||
|
|
||||||
def _save_position_periodically(self) -> None:
|
def _save_position_periodically(self) -> None:
|
||||||
"""Periodically save playback position."""
|
"""Periodically save playback position."""
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ Screen {
|
|||||||
DataTable {
|
DataTable {
|
||||||
height: 1fr;
|
height: 1fr;
|
||||||
background: #141622;
|
background: #141622;
|
||||||
color: #d6dbf2;
|
color: #c7cfe8;
|
||||||
border: solid #2b2f45;
|
border: solid #262a3f;
|
||||||
scrollbar-size-horizontal: 0;
|
scrollbar-size-horizontal: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +105,14 @@ Static#progress_info {
|
|||||||
text-style: bold;
|
text-style: bold;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#progress_bar_container {
|
||||||
|
align: center middle;
|
||||||
|
width: 100%;
|
||||||
|
height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
ProgressBar#progress_bar {
|
ProgressBar#progress_bar {
|
||||||
@@ -112,8 +120,10 @@ ProgressBar#progress_bar {
|
|||||||
background: #10131f;
|
background: #10131f;
|
||||||
border: none;
|
border: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 1;
|
padding: 0;
|
||||||
align: center middle;
|
width: auto;
|
||||||
|
min-width: 40;
|
||||||
|
max-width: 80;
|
||||||
}
|
}
|
||||||
|
|
||||||
ProgressBar#progress_bar > .progress-bar--track {
|
ProgressBar#progress_bar > .progress-bar--track {
|
||||||
@@ -124,21 +134,16 @@ ProgressBar#progress_bar > .progress-bar--bar {
|
|||||||
background: #8bd5ca;
|
background: #8bd5ca;
|
||||||
}
|
}
|
||||||
|
|
||||||
HelpScreen {
|
HelpScreen,
|
||||||
|
StatsScreen,
|
||||||
|
FilterScreen {
|
||||||
align: center middle;
|
align: center middle;
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
HelpScreen Static {
|
HelpScreen Static,
|
||||||
background: transparent;
|
StatsScreen Static,
|
||||||
}
|
FilterScreen Static {
|
||||||
|
|
||||||
StatsScreen {
|
|
||||||
align: center middle;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
StatsScreen Static {
|
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,25 +178,25 @@ StatsScreen .help_list > ListItem > Label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#help_container {
|
#help_container {
|
||||||
width: 88%;
|
width: 72%;
|
||||||
max-width: 120;
|
max-width: 90;
|
||||||
min-width: 48;
|
min-width: 44;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: 80%;
|
max-height: 80%;
|
||||||
min-height: 14;
|
min-height: 14;
|
||||||
background: #181a2a;
|
background: #181a2a;
|
||||||
border: heavy #7aa2f7;
|
border: heavy #7aa2f7;
|
||||||
padding: 1 2;
|
padding: 1 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#help_title {
|
#help_title {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 3;
|
height: 2;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-style: bold;
|
text-style: bold;
|
||||||
color: #7aa2f7;
|
color: #7aa2f7;
|
||||||
content-align: center middle;
|
content-align: center middle;
|
||||||
margin-bottom: 1;
|
margin-bottom: 0;
|
||||||
border-bottom: solid #4b5165;
|
border-bottom: solid #4b5165;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,19 +205,21 @@ StatsScreen .help_list > ListItem > Label {
|
|||||||
height: auto;
|
height: auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0 0 1 0;
|
margin: 0 0 1 0;
|
||||||
|
align: center middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help_list {
|
.help_list {
|
||||||
width: 1fr;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0 1;
|
padding: 0;
|
||||||
scrollbar-size: 0 0;
|
scrollbar-size: 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help_list > ListItem {
|
.help_list > ListItem {
|
||||||
background: #1b1f33;
|
background: #1b1f33;
|
||||||
padding: 0 1;
|
padding: 0 1;
|
||||||
|
height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help_list > ListItem:hover {
|
.help_list > ListItem:hover {
|
||||||
@@ -226,23 +233,14 @@ StatsScreen .help_list > ListItem > Label {
|
|||||||
|
|
||||||
#help_footer {
|
#help_footer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 3;
|
height: 2;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
content-align: center middle;
|
content-align: center middle;
|
||||||
color: #bac2de;
|
color: #b6bfdc;
|
||||||
margin-top: 1;
|
margin-top: 0;
|
||||||
border-top: solid #4b5165;
|
border-top: solid #4b5165;
|
||||||
}
|
}
|
||||||
|
|
||||||
FilterScreen {
|
|
||||||
align: center middle;
|
|
||||||
background: rgba(0, 0, 0, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
FilterScreen Static {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
#filter_container {
|
#filter_container {
|
||||||
width: 60;
|
width: 60;
|
||||||
height: auto;
|
height: auto;
|
||||||
@@ -271,7 +269,7 @@ FilterScreen Static {
|
|||||||
height: 2;
|
height: 2;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
content-align: center middle;
|
content-align: center middle;
|
||||||
color: #bac2de;
|
color: #b6bfdc;
|
||||||
margin-top: 1;
|
margin-top: 1;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Library helpers for fetching and formatting Audible data."""
|
"""Library helpers for fetching and formatting Audible data."""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
import audible
|
import audible
|
||||||
@@ -18,38 +19,103 @@ class LibraryClient:
|
|||||||
"""Fetch all library items from the API."""
|
"""Fetch all library items from the API."""
|
||||||
response_groups = (
|
response_groups = (
|
||||||
"contributors,media,product_attrs,product_desc,product_details,"
|
"contributors,media,product_attrs,product_desc,product_details,"
|
||||||
"rating,is_finished,listening_status,percent_complete"
|
"is_finished,listening_status,percent_complete"
|
||||||
)
|
)
|
||||||
return self._fetch_all_pages(response_groups, on_progress)
|
return self._fetch_all_pages(response_groups, on_progress)
|
||||||
|
|
||||||
|
def _fetch_page(
|
||||||
|
self, page: int, page_size: int, response_groups: str
|
||||||
|
) -> tuple[int, list[dict]]:
|
||||||
|
"""Fetch a single page of library items."""
|
||||||
|
library = self.client.get(
|
||||||
|
path="library",
|
||||||
|
num_results=page_size,
|
||||||
|
page=page,
|
||||||
|
response_groups=response_groups,
|
||||||
|
)
|
||||||
|
items = library.get("items", [])
|
||||||
|
return page, list(items)
|
||||||
|
|
||||||
def _fetch_all_pages(
|
def _fetch_all_pages(
|
||||||
self, response_groups: str, on_progress: ProgressCallback | None = None
|
self, response_groups: str, on_progress: ProgressCallback | None = None
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Fetch all pages of library items from the API."""
|
"""Fetch all pages of library items from the API using maximum parallel fetching."""
|
||||||
all_items: list[dict] = []
|
library_response = None
|
||||||
page = 1
|
page_size = 200
|
||||||
page_size = 50
|
|
||||||
|
|
||||||
while True:
|
for attempt_size in [200, 100, 50]:
|
||||||
library = self.client.get(
|
try:
|
||||||
path="library",
|
library_response = self.client.get(
|
||||||
num_results=page_size,
|
path="library",
|
||||||
page=page,
|
num_results=attempt_size,
|
||||||
response_groups=response_groups,
|
page=1,
|
||||||
)
|
response_groups=response_groups,
|
||||||
|
)
|
||||||
items = list(library.get("items", []))
|
page_size = attempt_size
|
||||||
if not items:
|
|
||||||
break
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
all_items.extend(items)
|
if not library_response:
|
||||||
if on_progress:
|
return []
|
||||||
on_progress(f"Fetched page {page} ({len(items)} items)...")
|
|
||||||
|
|
||||||
if len(items) < page_size:
|
first_page_items = library_response.get("items", [])
|
||||||
break
|
if not first_page_items:
|
||||||
|
return []
|
||||||
|
|
||||||
page += 1
|
all_items: list[dict] = list(first_page_items)
|
||||||
|
if on_progress:
|
||||||
|
on_progress(f"Fetched page 1 ({len(first_page_items)} items)...")
|
||||||
|
|
||||||
|
if len(first_page_items) < page_size:
|
||||||
|
return all_items
|
||||||
|
|
||||||
|
total_items_estimate = library_response.get(
|
||||||
|
"total_results") or library_response.get("total")
|
||||||
|
if total_items_estimate:
|
||||||
|
estimated_pages = (total_items_estimate +
|
||||||
|
page_size - 1) // page_size
|
||||||
|
estimated_pages = min(estimated_pages, 1000)
|
||||||
|
else:
|
||||||
|
estimated_pages = 500
|
||||||
|
|
||||||
|
max_workers = 50
|
||||||
|
page_results: dict[int, list[dict]] = {}
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
future_to_page: dict = {}
|
||||||
|
|
||||||
|
for page in range(2, estimated_pages + 1):
|
||||||
|
future = executor.submit(
|
||||||
|
self._fetch_page, page, page_size, response_groups
|
||||||
|
)
|
||||||
|
future_to_page[future] = page
|
||||||
|
|
||||||
|
completed_count = 0
|
||||||
|
total_items = len(first_page_items)
|
||||||
|
|
||||||
|
for future in as_completed(future_to_page):
|
||||||
|
page_num = future_to_page.pop(future)
|
||||||
|
try:
|
||||||
|
fetched_page, items = future.result()
|
||||||
|
if not items or len(items) < page_size:
|
||||||
|
for remaining_future in list(future_to_page.keys()):
|
||||||
|
remaining_future.cancel()
|
||||||
|
break
|
||||||
|
|
||||||
|
page_results[fetched_page] = items
|
||||||
|
total_items += len(items)
|
||||||
|
completed_count += 1
|
||||||
|
if on_progress and completed_count % 20 == 0:
|
||||||
|
on_progress(
|
||||||
|
f"Fetched {completed_count} pages ({total_items} items)..."
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for page_num in sorted(page_results.keys()):
|
||||||
|
all_items.extend(page_results[page_num])
|
||||||
|
|
||||||
return all_items
|
return all_items
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import date, datetime
|
|||||||
from typing import Any, Callable, Protocol, TYPE_CHECKING, cast
|
from typing import Any, Callable, Protocol, TYPE_CHECKING, cast
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Container, Horizontal, Vertical
|
from textual.containers import Container, Vertical
|
||||||
from textual.screen import ModalScreen
|
from textual.screen import ModalScreen
|
||||||
from textual.timer import Timer
|
from textual.timer import Timer
|
||||||
from textual.widgets import Input, Label, ListItem, ListView, Static
|
from textual.widgets import Input, Label, ListItem, ListView, Static
|
||||||
@@ -69,23 +69,18 @@ class HelpScreen(AppContextMixin, ModalScreen):
|
|||||||
"""Create a ListItem for a single binding."""
|
"""Create a ListItem for a single binding."""
|
||||||
key, description = self._parse_binding(binding)
|
key, description = self._parse_binding(binding)
|
||||||
key_display = self._format_key_display(key)
|
key_display = self._format_key_display(key)
|
||||||
text = f"[bold {KEY_COLOR}]{key_display:>12}[/] [{DESC_COLOR}]{description}[/]"
|
text = f"[bold {KEY_COLOR}]{key_display:>16}[/] [{DESC_COLOR}]{description:<25}[/]"
|
||||||
return ListItem(Label(text))
|
return ListItem(Label(text))
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
app = self._app()
|
app = self._app()
|
||||||
bindings = list(app.BINDINGS)
|
bindings = list(app.BINDINGS)
|
||||||
mid = (len(bindings) + 1) // 2
|
|
||||||
|
|
||||||
with Container(id="help_container"):
|
with Container(id="help_container"):
|
||||||
yield Static("Key Bindings", id="help_title")
|
yield Static("Keybindings", id="help_title")
|
||||||
with Horizontal(id="help_content"):
|
with Vertical(id="help_content"):
|
||||||
yield ListView(
|
yield ListView(
|
||||||
*[self._make_item(b) for b in bindings[:mid]],
|
*[self._make_item(b) for b in bindings],
|
||||||
classes="help_list",
|
|
||||||
)
|
|
||||||
yield ListView(
|
|
||||||
*[self._make_item(b) for b in bindings[mid:]],
|
|
||||||
classes="help_list",
|
classes="help_list",
|
||||||
)
|
)
|
||||||
yield Static(
|
yield Static(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "auditui"
|
name = "auditui"
|
||||||
version = "0.1.2"
|
version = "0.1.4"
|
||||||
description = "An Audible TUI client"
|
description = "An Audible TUI client"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10,<3.13"
|
requires-python = ">=3.10,<3.13"
|
||||||
|
|||||||
Reference in New Issue
Block a user