Compare commits

...

6 Commits

6 changed files with 48 additions and 59 deletions

View File

@@ -138,7 +138,7 @@ OTP is supported if you use a two-factor authentication device.
## Hacking
This project uses [uv](https://github.com/astral-sh/uv) for dependency management.
This project uses [uv](https://github.com/astral-sh/uv) for dependency management, the TUI is built with [Textual](https://textual.textualize.io/) (currently `textual>=8.0.0`).
```bash
# install dependencies (creates .venv)

View File

@@ -1,3 +1,3 @@
"""Auditui package"""
__version__ = "0.1.5"
__version__ = "0.1.6"

View File

@@ -31,13 +31,12 @@ from .search_utils import build_search_text, filter_items
from .ui import FilterScreen, HelpScreen, StatsScreen
if TYPE_CHECKING:
from textual.widgets._data_table import ColumnKey
from textual.widgets.data_table import ColumnKey
class Auditui(App):
"""Main application class for the Audible TUI app."""
theme = "textual-dark"
SHOW_PALETTE = False
BINDINGS = [
@@ -72,8 +71,7 @@ class Auditui(App):
self.download_manager = (
DownloadManager(auth, client) if auth and client else None
)
self.playback = PlaybackController(
self.update_status, self.library_client)
self.playback = PlaybackController(self.update_status, self.library_client)
self.all_items: list[dict] = []
self.current_items: list[dict] = []
@@ -105,6 +103,7 @@ class Auditui(App):
def on_mount(self) -> None:
"""Initialize the table and start fetching library data."""
self.theme = "textual-dark"
table = self.query_one(DataTable)
for column_name, _ratio in TABLE_COLUMN_DEFS:
table.add_column(column_name)
@@ -116,8 +115,7 @@ class Auditui(App):
self.update_status("Fetching library...")
self.fetch_library()
else:
self.update_status(
"Not authenticated. Please restart and authenticate.")
self.update_status("Not authenticated. Please restart and authenticate.")
self.set_interval(1.0, self._check_playback_status)
self.set_interval(0.5, self._update_progress)
@@ -210,8 +208,7 @@ class Auditui(App):
remainder = distributable - sum(widths)
if remainder > 0:
indices = sorted(
range(num_cols), key=lambda i: ratios[i], reverse=True)
indices = sorted(range(num_cols), key=lambda i: ratios[i], reverse=True)
for i in range(remainder):
widths[indices[i % num_cols]] += 1
@@ -233,8 +230,7 @@ class Auditui(App):
return
try:
all_items = self.library_client.fetch_all_items(
self._thread_status_update)
all_items = self.library_client.fetch_all_items(self._thread_status_update)
self.call_from_thread(self.on_library_loaded, all_items)
except (OSError, ValueError, KeyError) as exc:
self.call_from_thread(self.on_library_error, str(exc))
@@ -267,8 +263,7 @@ class Auditui(App):
title, author, runtime, progress, downloaded = format_item_as_row(
item, self.library_client, self.download_manager
)
table.add_row(title, author, runtime,
progress, downloaded, key=title)
table.add_row(title, author, runtime, progress, downloaded, key=title)
self.current_items = items
status = self.query_one("#status", Static)
@@ -330,8 +325,7 @@ class Auditui(App):
def action_play_selected(self) -> None:
"""Start playing the selected book."""
if not self.download_manager:
self.update_status(
"Not authenticated. Please restart and authenticate.")
self.update_status("Not authenticated. Please restart and authenticate.")
return
table = self.query_one(DataTable)
@@ -435,8 +429,7 @@ class Auditui(App):
is_currently_finished = self.library_client.is_finished(selected_item)
if is_currently_finished:
self.call_from_thread(self.update_status,
"Already marked as finished")
self.call_from_thread(self.update_status, "Already marked as finished")
return
success = self.library_client.mark_as_finished(asin, selected_item)
@@ -479,9 +472,9 @@ class Auditui(App):
self._refresh_filtered_view()
self.update_status("Filter cleared")
def _apply_filter(self, filter_text: str) -> None:
def _apply_filter(self, filter_text: str | None) -> None:
"""Apply the filter to the library."""
self.filter_text = filter_text
self.filter_text = filter_text or ""
self._refresh_filtered_view()
def _refresh_filtered_view(self) -> None:
@@ -492,11 +485,9 @@ class Auditui(App):
items = self.all_items
if self.filter_text:
items = filter_items(items, self.filter_text,
self._get_search_text)
items = filter_items(items, self.filter_text, self._get_search_text)
self._populate_table(items)
self.update_status(
f"Filter: '{self.filter_text}' ({len(items)} books)")
self.update_status(f"Filter: '{self.filter_text}' ({len(items)} books)")
return
if not self.show_all_mode and self.library_client:
@@ -544,8 +535,7 @@ class Auditui(App):
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_bar_container = self.query_one("#progress_bar_container", Horizontal)
progress_percent = min(
100.0, max(0.0, (chapter_elapsed / chapter_total) * 100.0)
@@ -562,8 +552,7 @@ class Auditui(App):
def _hide_progress(self) -> None:
"""Hide the progress widget."""
progress_info = self.query_one("#progress_info", Static)
progress_bar_container = self.query_one(
"#progress_bar_container", Horizontal)
progress_bar_container = self.query_one("#progress_bar_container", Horizontal)
progress_info.display = False
progress_bar_container.display = False
@@ -574,8 +563,7 @@ class Auditui(App):
def action_toggle_download(self) -> None:
"""Toggle download/remove for the selected book."""
if not self.download_manager:
self.update_status(
"Not authenticated. Please restart and authenticate.")
self.update_status("Not authenticated. Please restart and authenticate.")
return
table = self.query_one(DataTable)
@@ -608,11 +596,9 @@ class Auditui(App):
return
if self.download_manager.is_cached(asin):
self.download_manager.remove_cached(
asin, self._thread_status_update)
self.download_manager.remove_cached(asin, self._thread_status_update)
else:
self.download_manager.get_or_download(
asin, self._thread_status_update)
self.download_manager.get_or_download(asin, self._thread_status_update)
self.call_from_thread(self._refresh_table)

View File

@@ -38,11 +38,11 @@ KEY_COLOR = "#f9e2af"
DESC_COLOR = "#cdd6f4"
class AppContextMixin(ModalScreen):
class AppContextMixin:
"""Mixin to provide a typed app accessor."""
def _app(self) -> _AppContext:
return cast(_AppContext, self.app)
return cast(_AppContext, cast(Any, self).app)
class HelpScreen(AppContextMixin, ModalScreen):
@@ -165,8 +165,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
"""Check if stats contain any listening activity."""
monthly_stats = stats.get("aggregated_monthly_listening_stats", [])
return bool(
monthly_stats and any(s.get("aggregated_sum", 0)
> 0 for s in monthly_stats)
monthly_stats and any(s.get("aggregated_sum", 0) > 0 for s in monthly_stats)
)
def _get_listening_time(self, duration: int, start_date: str) -> int:
@@ -192,9 +191,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
app = self._app()
if not app.library_client or not app.all_items:
return 0
return sum(
1 for item in app.all_items if app.library_client.is_finished(item)
)
return sum(1 for item in app.all_items if app.library_client.is_finished(item))
def _get_account_info(self) -> dict:
"""Get account information including subscription details."""
@@ -220,8 +217,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
for endpoint, response_groups in endpoints:
try:
response = app.client.get(
endpoint, response_groups=response_groups)
response = app.client.get(endpoint, response_groups=response_groups)
account_info.update(response)
except Exception:
pass
@@ -414,7 +410,11 @@ class StatsScreen(AppContextMixin, ModalScreen):
if hasattr(locale_obj, "domain"):
return locale_obj.domain.upper()
if isinstance(locale_obj, str):
return locale_obj.split("_")[-1].upper() if "_" in locale_obj else locale_obj.upper()
return (
locale_obj.split("_")[-1].upper()
if "_" in locale_obj
else locale_obj.upper()
)
return str(locale_obj)
except Exception:
return "Unknown"
@@ -446,8 +446,10 @@ class StatsScreen(AppContextMixin, ModalScreen):
yield Static("Statistics", id="help_title")
with Vertical(id="help_content"):
yield ListView(
*[self._make_stat_item(label, value)
for label, value in stats_items],
*[
self._make_stat_item(label, value)
for label, value in stats_items
],
classes="help_list",
)
yield Static(
@@ -491,8 +493,9 @@ class StatsScreen(AppContextMixin, ModalScreen):
if email != "Unknown":
stats_items.append(("Email", email))
stats_items.append(("Country Store", country))
stats_items.append(("Signup Year", str(signup_year)
if signup_year > 0 else "Unknown"))
stats_items.append(
("Signup Year", str(signup_year) if signup_year > 0 else "Unknown")
)
if next_bill_date != "Unknown":
stats_items.append(("Next Credit", next_bill_date))
stats_items.append(("Next Bill", next_bill_date))
@@ -502,8 +505,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
stats_items.append(("Price", subscription_price))
stats_items.append(("This Month", self._format_time(month_time)))
stats_items.append(("This Year", self._format_time(year_time)))
stats_items.append(
("Books Finished", f"{finished_count} / {total_books}"))
stats_items.append(("Books Finished", f"{finished_count} / {total_books}"))
return stats_items
@@ -549,14 +551,15 @@ class FilterScreen(ModalScreen[str]):
self.dismiss(event.value)
def on_input_changed(self, event: Input.Changed) -> None:
if not self._on_change:
callback = self._on_change
if not callback:
return
if self._debounce_timer:
self._debounce_timer.stop()
value = event.value
self._debounce_timer = self.set_timer(
self._debounce_seconds,
lambda: self._on_change(value),
lambda: callback(value),
)
def action_cancel(self) -> None:

View File

@@ -1,10 +1,10 @@
[project]
name = "auditui"
version = "0.1.5"
version = "0.1.6"
description = "An Audible TUI client"
readme = "README.md"
requires-python = ">=3.10,<3.13"
dependencies = ["audible>=0.10.0", "httpx>=0.28.1", "textual>=6.7.1"]
dependencies = ["audible>=0.10.0", "httpx>=0.28.1", "textual>=8.0.0"]
[project.optional-dependencies]
dev = [

10
uv.lock generated
View File

@@ -35,7 +35,7 @@ wheels = [
[[package]]
name = "auditui"
version = "0.1.5"
version = "0.1.6"
source = { editable = "." }
dependencies = [
{ name = "audible" },
@@ -59,7 +59,7 @@ requires-dist = [
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "textual", specifier = ">=6.7.1" },
{ name = "textual", specifier = ">=8.0.0" },
]
provides-extras = ["dev"]
@@ -445,7 +445,7 @@ wheels = [
[[package]]
name = "textual"
version = "6.7.1"
version = "8.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
@@ -455,9 +455,9 @@ dependencies = [
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/00/9520327698acb6d8ae120b311ef1901840d55a6c41580e377f36261daf7a/textual-6.7.1.tar.gz", hash = "sha256:2a5acb0ab316a7ba9e74b0a291fab8933d681d7cf6f4e1eeb45c39a731b094cf", size = 1580916, upload-time = "2025-12-01T20:57:25.578Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f7/08/1e1f705825359590ddfaeda57653bd518c4ff7a96bb2c3239ba1b6fc4c51/textual-8.0.0.tar.gz", hash = "sha256:ce48f83a3d686c0fac0e80bf9136e1f8851c653aa6a4502e43293a151df18809", size = 1595895, upload-time = "2026-02-16T17:12:14.215Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2f/7a/7f3ea5e6f26d546ee4bd107df8fc9eef9f149dab0f6f15e1fc9f9413231f/textual-6.7.1-py3-none-any.whl", hash = "sha256:b92977ac5941dd37b6b7dc0ac021850ce8d9bf2e123c5bab7ff2016f215272e0", size = 713993, upload-time = "2025-12-01T20:57:23.698Z" },
{ url = "https://files.pythonhosted.org/packages/d3/be/e191c2a15da20530fde03564564e3e4b4220eb9d687d4014957e5c6a5e85/textual-8.0.0-py3-none-any.whl", hash = "sha256:8908f4ebe93a6b4f77ca7262197784a52162bc88b05f4ecf50ac93a92d49bb8f", size = 718904, upload-time = "2026-02-16T17:12:11.962Z" },
]
[[package]]