Compare commits
3 Commits
43e41c2f9a
...
b44ba70b6d
| Author | SHA1 | Date | |
|---|---|---|---|
| b44ba70b6d | |||
| c3c3b083f9 | |||
| 1ead1d3e74 |
54
README.md
54
README.md
@@ -2,26 +2,50 @@
|
||||
|
||||
A terminal-based user interface (TUI) client for Audible, written in Python 3.
|
||||
|
||||
Listen to your audiobooks or podcasts, browse your library, search for new titles, add them to your wishlist, and more.
|
||||
Listen to your audiobooks or podcasts, browse your library, and more.
|
||||
|
||||
## What it does
|
||||
## What it does and where are we
|
||||
|
||||
For now, it can:
|
||||
`main.py` offers a TUI interface for browsing your Audible library, listing your books with progress information. You can sort by progress or title, show all books, or show only unfinished books which is the default.
|
||||
|
||||
- [x] list your entire library
|
||||
Now, I'm working on the "play" feature, which should allow you to play a book from the terminal by pressing `Enter` on a book in the list, and pause/unpause with `Space`.
|
||||
|
||||
Then, the next thing to add is a progress bar at the bottom of the interface, to show the progress of the book while it's playing.
|
||||
|
||||
It's still a work in progress, so expect bugs and missing features. By the way, the code is not yet organized as I'm currently experimenting.
|
||||
|
||||
Also:
|
||||
|
||||
- `tui-try.py` is where I test `textual` possibilities with fake data.
|
||||
- `audible-api-test.py` is where I test `audible` python bindings possibilities.
|
||||
|
||||
## How to run
|
||||
|
||||
Install the dependencies with:
|
||||
|
||||
```bash
|
||||
$ virtualenv venv && source venv/bin/activate && pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Run the main application with:
|
||||
|
||||
```bash
|
||||
$ python main.py
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] list your library
|
||||
- [x] list your unfinished books with progress information
|
||||
- [ ] play a book (start when it was last paused, mark the position when it's paused)
|
||||
- [ ] open a pdf if one's attached to the book
|
||||
- [ ] mark a book as finished
|
||||
- [ ] mark a book as unfinished
|
||||
- [ ] search for new titles
|
||||
- [ ] add a book to your wishlist
|
||||
- [ ] build a decent TUI interface using [Textual](https://textual.textualize.io/)
|
||||
- [ ] play/pause a book
|
||||
- [ ] print progress at the bottom of the app while a book is playing
|
||||
- [ ] add control to go to the previous/next chapter
|
||||
- [ ] add a controle to jump 30s earlier/later
|
||||
- [ ] mark a book as finished or unfinished
|
||||
- [ ] code cleanup / organization
|
||||
|
||||
Once it'll do all of this (more or less), I'll think of a better code structure and the TUI interface.
|
||||
|
||||
I'm still experimenting the `audible` library and its API.
|
||||
|
||||
## Credentials
|
||||
## Auth / credentials
|
||||
|
||||
Login is handled and credentials are stored in `~/.config/auditui/auth.json`.
|
||||
|
||||
|
||||
379
main.py
Normal file
379
main.py
Normal file
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
"""A terminal-based user interface (TUI) client for Audible"""
|
||||
|
||||
import sys
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
|
||||
import audible
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Footer, Header, DataTable, Static
|
||||
from textual.worker import get_current_worker
|
||||
from textual import work
|
||||
|
||||
|
||||
class AudituiApp(App):
|
||||
"""Main application class for the Audible TUI app."""
|
||||
BINDINGS = [
|
||||
("d", "toggle_dark", "Toggle dark mode"),
|
||||
("s", "sort", "Sort by title"),
|
||||
("r", "reverse_sort", "Reverse sort"),
|
||||
("p", "sort_by_progress", "Sort by progress"),
|
||||
("a", "show_all", "Show all books"),
|
||||
("u", "show_unfinished", "Show unfinished"),
|
||||
("q", "quit", "Quit application"),
|
||||
]
|
||||
|
||||
CSS = """
|
||||
DataTable {
|
||||
height: 1fr;
|
||||
}
|
||||
Static {
|
||||
height: 1;
|
||||
text-align: center;
|
||||
background: $primary;
|
||||
}
|
||||
"""
|
||||
|
||||
AUTH_PATH = Path.home() / ".config" / "auditui" / "auth.json"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.auth = None
|
||||
self.client = None
|
||||
self.all_items = []
|
||||
self.current_items = []
|
||||
self.show_all_mode = False
|
||||
self.progress_sort_reverse = False
|
||||
self.title_column_key = None
|
||||
self.progress_column_key = None
|
||||
self.progress_column_index = 3
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Static("Loading...", id="status")
|
||||
table = DataTable()
|
||||
table.zebra_stripes = True
|
||||
table.cursor_type = "row"
|
||||
yield table
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the table and start fetching library data."""
|
||||
table = self.query_one(DataTable)
|
||||
table.add_columns("Title", "Author", "Length", "Progress")
|
||||
column_keys = list(table.columns.keys())
|
||||
self.title_column_key = column_keys[0]
|
||||
self.progress_column_key = column_keys[3]
|
||||
if self.client:
|
||||
self.update_status("Fetching library...")
|
||||
self.fetch_library()
|
||||
else:
|
||||
self.update_status(
|
||||
"Not authenticated. Please restart and authenticate.")
|
||||
|
||||
@work(exclusive=True, thread=True)
|
||||
def fetch_library(self) -> None:
|
||||
"""Fetch all library items from Audible API in background thread."""
|
||||
worker = get_current_worker()
|
||||
if worker.is_cancelled:
|
||||
return
|
||||
|
||||
try:
|
||||
response_groups = (
|
||||
"contributors,media,product_attrs,product_desc,product_details,"
|
||||
"rating,is_finished,listening_status,percent_complete"
|
||||
)
|
||||
all_items = self._fetch_all_pages(response_groups)
|
||||
self.all_items = all_items
|
||||
self.call_from_thread(self.on_library_loaded, all_items)
|
||||
except (OSError, ValueError, KeyError) as e:
|
||||
self.call_from_thread(self.on_library_error, str(e))
|
||||
|
||||
def _fetch_all_pages(self, response_groups):
|
||||
"""Fetch all pages of library items from the API."""
|
||||
all_items = []
|
||||
page = 1
|
||||
page_size = 50
|
||||
|
||||
while True:
|
||||
library = self.client.get(
|
||||
path="library",
|
||||
num_results=page_size,
|
||||
page=page,
|
||||
response_groups=response_groups
|
||||
)
|
||||
|
||||
items = library.get("items", [])
|
||||
|
||||
if not items:
|
||||
break
|
||||
|
||||
all_items.extend(items)
|
||||
self.call_from_thread(
|
||||
self.update_status,
|
||||
f"Fetched page {page} ({len(items)} items)..."
|
||||
)
|
||||
|
||||
if len(items) < page_size:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
return all_items
|
||||
|
||||
def on_library_loaded(self, items) -> None:
|
||||
"""Handle successful library load."""
|
||||
self.all_items = items
|
||||
self.update_status(f"Loaded {len(items)} books")
|
||||
self.show_unfinished()
|
||||
|
||||
def on_library_error(self, error: str) -> None:
|
||||
"""Handle library fetch error."""
|
||||
self.update_status(f"Error fetching library: {error}")
|
||||
|
||||
def update_status(self, message: str) -> None:
|
||||
"""Update the status message in the UI."""
|
||||
status = self.query_one("#status", Static)
|
||||
status.update(message)
|
||||
|
||||
def format_duration(self, value, unit='minutes', default_none=None):
|
||||
"""Format duration value into human-readable string."""
|
||||
if value is None or value <= 0:
|
||||
return default_none
|
||||
|
||||
if unit == 'seconds':
|
||||
total_minutes = int(value) // 60
|
||||
else:
|
||||
total_minutes = int(value)
|
||||
|
||||
if total_minutes < 60:
|
||||
return f"{total_minutes} minute{'s' if total_minutes != 1 else ''}"
|
||||
|
||||
hours = total_minutes // 60
|
||||
mins = total_minutes % 60
|
||||
if mins == 0:
|
||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||
return f"{hours} hour{'s' if hours != 1 else ''} {mins} minute{'s' if mins != 1 else ''}"
|
||||
|
||||
def _extract_title(self, item):
|
||||
"""Extract title from library item."""
|
||||
product = item.get("product", {})
|
||||
return (product.get("title") or
|
||||
item.get("title") or
|
||||
product.get("asin", "Unknown Title"))
|
||||
|
||||
def _extract_authors(self, item):
|
||||
"""Extract author names from library item."""
|
||||
product = item.get("product", {})
|
||||
authors = product.get("authors") or product.get("contributors") or []
|
||||
if not authors and "authors" in item:
|
||||
authors = item.get("authors", [])
|
||||
return ", ".join([a.get("name", "") for a in authors if isinstance(a, dict)])
|
||||
|
||||
def _extract_runtime_minutes(self, item):
|
||||
"""Extract runtime in minutes from library item."""
|
||||
product = item.get("product", {})
|
||||
runtime_fields = [
|
||||
"runtime_length_min",
|
||||
"runtime_length",
|
||||
"vLength",
|
||||
"length",
|
||||
"duration"
|
||||
]
|
||||
|
||||
runtime = None
|
||||
for field in runtime_fields:
|
||||
runtime = product.get(field)
|
||||
if runtime is None:
|
||||
runtime = item.get(field)
|
||||
if runtime is not None:
|
||||
break
|
||||
|
||||
if runtime is None:
|
||||
return None
|
||||
|
||||
if isinstance(runtime, dict):
|
||||
if "min" in runtime:
|
||||
return int(runtime.get("min", 0))
|
||||
elif isinstance(runtime, (int, float)):
|
||||
return int(runtime)
|
||||
|
||||
return None
|
||||
|
||||
def _extract_progress_info(self, item):
|
||||
"""Extract progress percentage from library item."""
|
||||
percent_complete = item.get("percent_complete")
|
||||
listening_status = item.get("listening_status", {})
|
||||
|
||||
if isinstance(listening_status, dict):
|
||||
if percent_complete is None:
|
||||
percent_complete = listening_status.get("percent_complete")
|
||||
else:
|
||||
percent_complete = None
|
||||
|
||||
return percent_complete
|
||||
|
||||
def _populate_table(self, items):
|
||||
"""Populate the DataTable with library items."""
|
||||
table = self.query_one(DataTable)
|
||||
table.clear()
|
||||
|
||||
if not items:
|
||||
self.update_status("No books found.")
|
||||
return
|
||||
|
||||
for item in items:
|
||||
title = self._extract_title(item)
|
||||
author_names = self._extract_authors(item)
|
||||
minutes = self._extract_runtime_minutes(item)
|
||||
runtime_str = self.format_duration(
|
||||
minutes, unit='minutes', default_none="Unknown length")
|
||||
percent_complete = self._extract_progress_info(item)
|
||||
|
||||
progress_str = "0%"
|
||||
if percent_complete is not None and percent_complete > 0:
|
||||
progress_str = f"{percent_complete:.1f}%"
|
||||
|
||||
table.add_row(
|
||||
title,
|
||||
author_names or "Unknown",
|
||||
runtime_str or "Unknown",
|
||||
progress_str,
|
||||
key=title
|
||||
)
|
||||
|
||||
self.current_items = items
|
||||
mode = "all" if self.show_all_mode else "unfinished"
|
||||
self.update_status(f"Showing {len(items)} books ({mode})")
|
||||
|
||||
def show_all(self) -> None:
|
||||
"""Display all books in the table."""
|
||||
if not self.all_items:
|
||||
return
|
||||
self.show_all_mode = True
|
||||
self._populate_table(self.all_items)
|
||||
|
||||
def show_unfinished(self) -> None:
|
||||
"""Display only unfinished books in the table."""
|
||||
if not self.all_items:
|
||||
return
|
||||
self.show_all_mode = False
|
||||
|
||||
unfinished_items = []
|
||||
for item in self.all_items:
|
||||
is_finished_flag = item.get("is_finished")
|
||||
percent_complete = item.get("percent_complete")
|
||||
listening_status = item.get("listening_status")
|
||||
|
||||
if isinstance(listening_status, dict):
|
||||
is_finished_flag = is_finished_flag or listening_status.get(
|
||||
"is_finished", False)
|
||||
if percent_complete is None:
|
||||
percent_complete = listening_status.get(
|
||||
"percent_complete", 0)
|
||||
|
||||
is_finished = False
|
||||
if is_finished_flag is True:
|
||||
is_finished = True
|
||||
elif isinstance(percent_complete, (int, float)) and percent_complete >= 100:
|
||||
is_finished = True
|
||||
|
||||
if not is_finished:
|
||||
unfinished_items.append(item)
|
||||
|
||||
self._populate_table(unfinished_items)
|
||||
|
||||
def action_toggle_dark(self) -> None:
|
||||
"""Toggle between dark and light theme."""
|
||||
self.theme = (
|
||||
"textual-dark" if self.theme == "textual-light" else "textual-light"
|
||||
)
|
||||
|
||||
def action_sort(self) -> None:
|
||||
"""Sort table by title in ascending order."""
|
||||
table = self.query_one(DataTable)
|
||||
if table.row_count > 0:
|
||||
table.sort(self.title_column_key)
|
||||
|
||||
def action_reverse_sort(self) -> None:
|
||||
"""Sort table by title in descending order."""
|
||||
table = self.query_one(DataTable)
|
||||
if table.row_count > 0:
|
||||
table.sort(self.title_column_key, reverse=True)
|
||||
|
||||
def action_sort_by_progress(self) -> None:
|
||||
"""Sort table by progress percentage, toggling direction on each press."""
|
||||
table = self.query_one(DataTable)
|
||||
if table.row_count > 0:
|
||||
self.progress_sort_reverse = not self.progress_sort_reverse
|
||||
|
||||
def progress_key(row_values):
|
||||
progress_cell = row_values[self.progress_column_index]
|
||||
if isinstance(progress_cell, str):
|
||||
try:
|
||||
return float(progress_cell.rstrip("%"))
|
||||
except (ValueError, AttributeError):
|
||||
return 0.0
|
||||
return 0.0
|
||||
|
||||
table.sort(key=progress_key, reverse=self.progress_sort_reverse)
|
||||
|
||||
def action_show_all(self) -> None:
|
||||
"""Action handler to show all books."""
|
||||
self.show_all()
|
||||
|
||||
def action_show_unfinished(self) -> None:
|
||||
"""Action handler to show unfinished books."""
|
||||
self.show_unfinished()
|
||||
|
||||
|
||||
def authenticate():
|
||||
"""Authenticate with Audible and return auth and client objects."""
|
||||
auth_path = Path.home() / ".config" / "auditui" / "auth.json"
|
||||
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
authenticator = None
|
||||
if auth_path.exists():
|
||||
try:
|
||||
authenticator = audible.Authenticator.from_file(str(auth_path))
|
||||
audible_client = audible.Client(auth=authenticator)
|
||||
return authenticator, audible_client
|
||||
except (OSError, ValueError, KeyError) as e:
|
||||
print(f"Failed to load existing auth: {e}")
|
||||
print("Please re-authenticate.")
|
||||
|
||||
print("Please authenticate with your Audible account.")
|
||||
print("You will need to provide:")
|
||||
print(" - Your Audible email/username")
|
||||
print(" - Your password")
|
||||
print(" - Your marketplace locale (e.g., 'US', 'UK', 'DE', 'FR')")
|
||||
|
||||
email = input("\nEmail: ")
|
||||
password = getpass("Password: ")
|
||||
marketplace = input(
|
||||
"Marketplace locale (default: US): ").strip().upper() or "US"
|
||||
|
||||
try:
|
||||
authenticator = audible.Authenticator.from_login(
|
||||
username=email,
|
||||
password=password,
|
||||
locale=marketplace
|
||||
)
|
||||
|
||||
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
authenticator.to_file(str(auth_path))
|
||||
print("Authentication successful!")
|
||||
audible_client = audible.Client(auth=authenticator)
|
||||
return authenticator, audible_client
|
||||
except (OSError, ValueError, KeyError) as e:
|
||||
print(f"Authentication failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
auth, client = authenticate()
|
||||
app = AudituiApp()
|
||||
app.auth = auth
|
||||
app.client = client
|
||||
app.run()
|
||||
@@ -1,2 +1,26 @@
|
||||
anyio==4.11.0
|
||||
audible==0.8.2
|
||||
beautifulsoup4==4.14.2
|
||||
certifi==2025.11.12
|
||||
h11==0.14.0
|
||||
httpcore==0.16.3
|
||||
httpx==0.23.3
|
||||
idna==3.11
|
||||
linkify-it-py==2.0.3
|
||||
markdown-it-py==4.0.0
|
||||
mdit-py-plugins==0.5.0
|
||||
mdurl==0.1.2
|
||||
pbkdf2==1.3
|
||||
pillow==12.0.0
|
||||
platformdirs==4.5.0
|
||||
pyaes==1.6.1
|
||||
pyasn1==0.6.1
|
||||
Pygments==2.19.2
|
||||
rfc3986==1.5.0
|
||||
rich==14.2.0
|
||||
rsa==4.9.1
|
||||
sniffio==1.3.1
|
||||
soupsieve==2.8
|
||||
textual==6.6.0
|
||||
typing_extensions==4.15.0
|
||||
uc-micro-py==1.0.3
|
||||
|
||||
Reference in New Issue
Block a user