Compare commits
6 Commits
f2c9d683b6
...
b63525060a
| Author | SHA1 | Date | |
|---|---|---|---|
| b63525060a | |||
| 79355f3bdf | |||
| 7602638ffe | |||
| dd8e513063 | |||
| bdccc3a2eb | |||
| 3ab73de2aa |
@@ -138,7 +138,7 @@ OTP is supported if you use a two-factor authentication device.
|
|||||||
|
|
||||||
## Hacking
|
## 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
|
```bash
|
||||||
# install dependencies (creates .venv)
|
# install dependencies (creates .venv)
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Auditui package"""
|
"""Auditui package"""
|
||||||
|
|
||||||
__version__ = "0.1.5"
|
__version__ = "0.1.6"
|
||||||
|
|||||||
@@ -31,13 +31,12 @@ from .search_utils import build_search_text, filter_items
|
|||||||
from .ui import FilterScreen, HelpScreen, StatsScreen
|
from .ui import FilterScreen, HelpScreen, StatsScreen
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from textual.widgets._data_table import ColumnKey
|
from textual.widgets.data_table import ColumnKey
|
||||||
|
|
||||||
|
|
||||||
class Auditui(App):
|
class Auditui(App):
|
||||||
"""Main application class for the Audible TUI app."""
|
"""Main application class for the Audible TUI app."""
|
||||||
|
|
||||||
theme = "textual-dark"
|
|
||||||
SHOW_PALETTE = False
|
SHOW_PALETTE = False
|
||||||
|
|
||||||
BINDINGS = [
|
BINDINGS = [
|
||||||
@@ -72,8 +71,7 @@ class Auditui(App):
|
|||||||
self.download_manager = (
|
self.download_manager = (
|
||||||
DownloadManager(auth, client) if auth and client else None
|
DownloadManager(auth, client) if auth and client else None
|
||||||
)
|
)
|
||||||
self.playback = PlaybackController(
|
self.playback = PlaybackController(self.update_status, self.library_client)
|
||||||
self.update_status, self.library_client)
|
|
||||||
|
|
||||||
self.all_items: list[dict] = []
|
self.all_items: list[dict] = []
|
||||||
self.current_items: list[dict] = []
|
self.current_items: list[dict] = []
|
||||||
@@ -105,6 +103,7 @@ class Auditui(App):
|
|||||||
|
|
||||||
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."""
|
||||||
|
self.theme = "textual-dark"
|
||||||
table = self.query_one(DataTable)
|
table = self.query_one(DataTable)
|
||||||
for column_name, _ratio in TABLE_COLUMN_DEFS:
|
for column_name, _ratio in TABLE_COLUMN_DEFS:
|
||||||
table.add_column(column_name)
|
table.add_column(column_name)
|
||||||
@@ -116,8 +115,7 @@ class Auditui(App):
|
|||||||
self.update_status("Fetching library...")
|
self.update_status("Fetching library...")
|
||||||
self.fetch_library()
|
self.fetch_library()
|
||||||
else:
|
else:
|
||||||
self.update_status(
|
self.update_status("Not authenticated. Please restart and authenticate.")
|
||||||
"Not authenticated. Please restart and authenticate.")
|
|
||||||
|
|
||||||
self.set_interval(1.0, self._check_playback_status)
|
self.set_interval(1.0, self._check_playback_status)
|
||||||
self.set_interval(0.5, self._update_progress)
|
self.set_interval(0.5, self._update_progress)
|
||||||
@@ -210,8 +208,7 @@ class Auditui(App):
|
|||||||
|
|
||||||
remainder = distributable - sum(widths)
|
remainder = distributable - sum(widths)
|
||||||
if remainder > 0:
|
if remainder > 0:
|
||||||
indices = sorted(
|
indices = sorted(range(num_cols), key=lambda i: ratios[i], reverse=True)
|
||||||
range(num_cols), key=lambda i: ratios[i], reverse=True)
|
|
||||||
for i in range(remainder):
|
for i in range(remainder):
|
||||||
widths[indices[i % num_cols]] += 1
|
widths[indices[i % num_cols]] += 1
|
||||||
|
|
||||||
@@ -233,8 +230,7 @@ class Auditui(App):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
all_items = self.library_client.fetch_all_items(
|
all_items = self.library_client.fetch_all_items(self._thread_status_update)
|
||||||
self._thread_status_update)
|
|
||||||
self.call_from_thread(self.on_library_loaded, all_items)
|
self.call_from_thread(self.on_library_loaded, all_items)
|
||||||
except (OSError, ValueError, KeyError) as exc:
|
except (OSError, ValueError, KeyError) as exc:
|
||||||
self.call_from_thread(self.on_library_error, str(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(
|
title, author, runtime, progress, downloaded = format_item_as_row(
|
||||||
item, self.library_client, self.download_manager
|
item, self.library_client, self.download_manager
|
||||||
)
|
)
|
||||||
table.add_row(title, author, runtime,
|
table.add_row(title, author, runtime, progress, downloaded, key=title)
|
||||||
progress, downloaded, key=title)
|
|
||||||
|
|
||||||
self.current_items = items
|
self.current_items = items
|
||||||
status = self.query_one("#status", Static)
|
status = self.query_one("#status", Static)
|
||||||
@@ -330,8 +325,7 @@ class Auditui(App):
|
|||||||
def action_play_selected(self) -> None:
|
def action_play_selected(self) -> None:
|
||||||
"""Start playing the selected book."""
|
"""Start playing the selected book."""
|
||||||
if not self.download_manager:
|
if not self.download_manager:
|
||||||
self.update_status(
|
self.update_status("Not authenticated. Please restart and authenticate.")
|
||||||
"Not authenticated. Please restart and authenticate.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
table = self.query_one(DataTable)
|
table = self.query_one(DataTable)
|
||||||
@@ -435,8 +429,7 @@ class Auditui(App):
|
|||||||
is_currently_finished = self.library_client.is_finished(selected_item)
|
is_currently_finished = self.library_client.is_finished(selected_item)
|
||||||
|
|
||||||
if is_currently_finished:
|
if is_currently_finished:
|
||||||
self.call_from_thread(self.update_status,
|
self.call_from_thread(self.update_status, "Already marked as finished")
|
||||||
"Already marked as finished")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
success = self.library_client.mark_as_finished(asin, selected_item)
|
success = self.library_client.mark_as_finished(asin, selected_item)
|
||||||
@@ -479,9 +472,9 @@ class Auditui(App):
|
|||||||
self._refresh_filtered_view()
|
self._refresh_filtered_view()
|
||||||
self.update_status("Filter cleared")
|
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."""
|
"""Apply the filter to the library."""
|
||||||
self.filter_text = filter_text
|
self.filter_text = filter_text or ""
|
||||||
self._refresh_filtered_view()
|
self._refresh_filtered_view()
|
||||||
|
|
||||||
def _refresh_filtered_view(self) -> None:
|
def _refresh_filtered_view(self) -> None:
|
||||||
@@ -492,11 +485,9 @@ class Auditui(App):
|
|||||||
items = self.all_items
|
items = self.all_items
|
||||||
|
|
||||||
if self.filter_text:
|
if self.filter_text:
|
||||||
items = filter_items(items, self.filter_text,
|
items = filter_items(items, self.filter_text, self._get_search_text)
|
||||||
self._get_search_text)
|
|
||||||
self._populate_table(items)
|
self._populate_table(items)
|
||||||
self.update_status(
|
self.update_status(f"Filter: '{self.filter_text}' ({len(items)} books)")
|
||||||
f"Filter: '{self.filter_text}' ({len(items)} books)")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.show_all_mode and self.library_client:
|
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_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 = self.query_one("#progress_bar_container", Horizontal)
|
||||||
"#progress_bar_container", Horizontal)
|
|
||||||
|
|
||||||
progress_percent = min(
|
progress_percent = min(
|
||||||
100.0, max(0.0, (chapter_elapsed / chapter_total) * 100.0)
|
100.0, max(0.0, (chapter_elapsed / chapter_total) * 100.0)
|
||||||
@@ -562,8 +552,7 @@ class Auditui(App):
|
|||||||
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_container = self.query_one(
|
progress_bar_container = self.query_one("#progress_bar_container", Horizontal)
|
||||||
"#progress_bar_container", Horizontal)
|
|
||||||
progress_info.display = False
|
progress_info.display = False
|
||||||
progress_bar_container.display = False
|
progress_bar_container.display = False
|
||||||
|
|
||||||
@@ -574,8 +563,7 @@ class Auditui(App):
|
|||||||
def action_toggle_download(self) -> None:
|
def action_toggle_download(self) -> None:
|
||||||
"""Toggle download/remove for the selected book."""
|
"""Toggle download/remove for the selected book."""
|
||||||
if not self.download_manager:
|
if not self.download_manager:
|
||||||
self.update_status(
|
self.update_status("Not authenticated. Please restart and authenticate.")
|
||||||
"Not authenticated. Please restart and authenticate.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
table = self.query_one(DataTable)
|
table = self.query_one(DataTable)
|
||||||
@@ -608,11 +596,9 @@ class Auditui(App):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if self.download_manager.is_cached(asin):
|
if self.download_manager.is_cached(asin):
|
||||||
self.download_manager.remove_cached(
|
self.download_manager.remove_cached(asin, self._thread_status_update)
|
||||||
asin, self._thread_status_update)
|
|
||||||
else:
|
else:
|
||||||
self.download_manager.get_or_download(
|
self.download_manager.get_or_download(asin, self._thread_status_update)
|
||||||
asin, self._thread_status_update)
|
|
||||||
|
|
||||||
self.call_from_thread(self._refresh_table)
|
self.call_from_thread(self._refresh_table)
|
||||||
|
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ KEY_COLOR = "#f9e2af"
|
|||||||
DESC_COLOR = "#cdd6f4"
|
DESC_COLOR = "#cdd6f4"
|
||||||
|
|
||||||
|
|
||||||
class AppContextMixin(ModalScreen):
|
class AppContextMixin:
|
||||||
"""Mixin to provide a typed app accessor."""
|
"""Mixin to provide a typed app accessor."""
|
||||||
|
|
||||||
def _app(self) -> _AppContext:
|
def _app(self) -> _AppContext:
|
||||||
return cast(_AppContext, self.app)
|
return cast(_AppContext, cast(Any, self).app)
|
||||||
|
|
||||||
|
|
||||||
class HelpScreen(AppContextMixin, ModalScreen):
|
class HelpScreen(AppContextMixin, ModalScreen):
|
||||||
@@ -165,8 +165,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
|||||||
"""Check if stats contain any listening activity."""
|
"""Check if stats contain any listening activity."""
|
||||||
monthly_stats = stats.get("aggregated_monthly_listening_stats", [])
|
monthly_stats = stats.get("aggregated_monthly_listening_stats", [])
|
||||||
return bool(
|
return bool(
|
||||||
monthly_stats and any(s.get("aggregated_sum", 0)
|
monthly_stats and any(s.get("aggregated_sum", 0) > 0 for s in monthly_stats)
|
||||||
> 0 for s in monthly_stats)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def _get_listening_time(self, duration: int, start_date: str) -> int:
|
def _get_listening_time(self, duration: int, start_date: str) -> int:
|
||||||
@@ -192,9 +191,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
|||||||
app = self._app()
|
app = self._app()
|
||||||
if not app.library_client or not app.all_items:
|
if not app.library_client or not app.all_items:
|
||||||
return 0
|
return 0
|
||||||
return sum(
|
return sum(1 for item in app.all_items if app.library_client.is_finished(item))
|
||||||
1 for item in app.all_items if app.library_client.is_finished(item)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _get_account_info(self) -> dict:
|
def _get_account_info(self) -> dict:
|
||||||
"""Get account information including subscription details."""
|
"""Get account information including subscription details."""
|
||||||
@@ -220,8 +217,7 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
|||||||
|
|
||||||
for endpoint, response_groups in endpoints:
|
for endpoint, response_groups in endpoints:
|
||||||
try:
|
try:
|
||||||
response = app.client.get(
|
response = app.client.get(endpoint, response_groups=response_groups)
|
||||||
endpoint, response_groups=response_groups)
|
|
||||||
account_info.update(response)
|
account_info.update(response)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -414,7 +410,11 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
|||||||
if hasattr(locale_obj, "domain"):
|
if hasattr(locale_obj, "domain"):
|
||||||
return locale_obj.domain.upper()
|
return locale_obj.domain.upper()
|
||||||
if isinstance(locale_obj, str):
|
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)
|
return str(locale_obj)
|
||||||
except Exception:
|
except Exception:
|
||||||
return "Unknown"
|
return "Unknown"
|
||||||
@@ -446,8 +446,10 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
|||||||
yield Static("Statistics", id="help_title")
|
yield Static("Statistics", id="help_title")
|
||||||
with Vertical(id="help_content"):
|
with Vertical(id="help_content"):
|
||||||
yield ListView(
|
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",
|
classes="help_list",
|
||||||
)
|
)
|
||||||
yield Static(
|
yield Static(
|
||||||
@@ -491,8 +493,9 @@ class StatsScreen(AppContextMixin, ModalScreen):
|
|||||||
if email != "Unknown":
|
if email != "Unknown":
|
||||||
stats_items.append(("Email", email))
|
stats_items.append(("Email", email))
|
||||||
stats_items.append(("Country Store", country))
|
stats_items.append(("Country Store", country))
|
||||||
stats_items.append(("Signup Year", str(signup_year)
|
stats_items.append(
|
||||||
if signup_year > 0 else "Unknown"))
|
("Signup Year", str(signup_year) if signup_year > 0 else "Unknown")
|
||||||
|
)
|
||||||
if next_bill_date != "Unknown":
|
if next_bill_date != "Unknown":
|
||||||
stats_items.append(("Next Credit", next_bill_date))
|
stats_items.append(("Next Credit", next_bill_date))
|
||||||
stats_items.append(("Next Bill", 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(("Price", subscription_price))
|
||||||
stats_items.append(("This Month", self._format_time(month_time)))
|
stats_items.append(("This Month", self._format_time(month_time)))
|
||||||
stats_items.append(("This Year", self._format_time(year_time)))
|
stats_items.append(("This Year", self._format_time(year_time)))
|
||||||
stats_items.append(
|
stats_items.append(("Books Finished", f"{finished_count} / {total_books}"))
|
||||||
("Books Finished", f"{finished_count} / {total_books}"))
|
|
||||||
|
|
||||||
return stats_items
|
return stats_items
|
||||||
|
|
||||||
@@ -549,14 +551,15 @@ class FilterScreen(ModalScreen[str]):
|
|||||||
self.dismiss(event.value)
|
self.dismiss(event.value)
|
||||||
|
|
||||||
def on_input_changed(self, event: Input.Changed) -> None:
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
if not self._on_change:
|
callback = self._on_change
|
||||||
|
if not callback:
|
||||||
return
|
return
|
||||||
if self._debounce_timer:
|
if self._debounce_timer:
|
||||||
self._debounce_timer.stop()
|
self._debounce_timer.stop()
|
||||||
value = event.value
|
value = event.value
|
||||||
self._debounce_timer = self.set_timer(
|
self._debounce_timer = self.set_timer(
|
||||||
self._debounce_seconds,
|
self._debounce_seconds,
|
||||||
lambda: self._on_change(value),
|
lambda: callback(value),
|
||||||
)
|
)
|
||||||
|
|
||||||
def action_cancel(self) -> None:
|
def action_cancel(self) -> None:
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "auditui"
|
name = "auditui"
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
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"
|
||||||
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]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
10
uv.lock
generated
10
uv.lock
generated
@@ -35,7 +35,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "auditui"
|
name = "auditui"
|
||||||
version = "0.1.5"
|
version = "0.1.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "audible" },
|
{ name = "audible" },
|
||||||
@@ -59,7 +59,7 @@ requires-dist = [
|
|||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" },
|
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||||
{ name = "textual", specifier = ">=6.7.1" },
|
{ name = "textual", specifier = ">=8.0.0" },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
@@ -445,7 +445,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "textual"
|
name = "textual"
|
||||||
version = "6.7.1"
|
version = "8.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "markdown-it-py", extra = ["linkify"] },
|
{ name = "markdown-it-py", extra = ["linkify"] },
|
||||||
@@ -455,9 +455,9 @@ dependencies = [
|
|||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "typing-extensions" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user