Compare commits

..

19 Commits

Author SHA1 Message Date
553f5cb4f7 build: add script entrypoint 2025-12-20 22:53:00 +01:00
32b37a0834 docs: update readme 2025-12-20 22:52:30 +01:00
a2d2c7ce3a refactor: move main cli into package 2025-12-20 22:52:27 +01:00
4741080284 clean: shorter messages 2025-12-16 06:24:36 +01:00
737147b457 clean: remove unused import 2025-12-16 06:21:27 +01:00
123d35068f refactor: use ui.py and remove unused imports 2025-12-16 06:21:20 +01:00
258aabe10f refactor: future-proof ui components in ui.py 2025-12-16 06:21:09 +01:00
bc070c4162 feat: relooking of help screen 2025-12-16 06:02:33 +01:00
cbf6bff779 feat: help screen now is scrollable and looks better 2025-12-16 06:02:22 +01:00
080c731fd7 feat: add css for new help screen 2025-12-16 03:35:46 +01:00
1b6f1ff1f2 feat: add a help screen with all keybindings 2025-12-16 03:35:33 +01:00
aa5998c3e3 docs: update roadmap and main description 2025-12-16 03:35:16 +01:00
c65e949731 feat: improve margin 2025-12-16 03:25:12 +01:00
ab51e5506e feat: hide useless palette 2025-12-16 03:25:02 +01:00
3701b37f4c docs: update roadmap 2025-12-16 03:10:32 +01:00
1474302d7e feat: add downloaded status indicator to table rows 2025-12-16 03:10:13 +01:00
eeecaaf42e feat: add cache-related method to get, remove or check 2025-12-16 03:09:26 +01:00
f359dee194 feat: add a "downloaded" column in the UI 2025-12-16 03:09:06 +01:00
1e2655670d feat: add a toggle to download/remove a book from cache 2025-12-16 03:08:56 +01:00
8 changed files with 257 additions and 40 deletions

View File

@@ -1,14 +1,6 @@
# auditui # auditui
A terminal-based user interface (TUI) client for Audible, written in Python 3. A terminal-based user interface (TUI) client for Audible, written in Python 3 : listen to your audiobooks (even offline), browse and manage your library, and more!
Listen to your audiobooks or podcasts, browse your library, and more.
## What it does and where are we
The project 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.
You can also play a book by pressing `Enter` on a book in the list, and pause/resume the playback by pressing `Space`. `Left` and `Right` let you move 30 seconds backward/forward.
Currently, the only available theme is Catppuccin Mocha, following their [style guide](https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md), as it's my preferred theme across most of my tools. Currently, the only available theme is Catppuccin Mocha, following their [style guide](https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md), as it's my preferred theme across most of my tools.
@@ -25,7 +17,7 @@ This project uses [uv](https://github.com/astral-sh/uv) for dependency managemen
$ uv sync $ uv sync
# run the TUI # run the TUI
$ uv run main.py $ uv run python -m auditui.cli
``` ```
(`stats.py` is a playground for the stats functionality) (`stats.py` is a playground for the stats functionality)
@@ -36,6 +28,7 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
| Key | Action | | Key | Action |
| ------------ | -------------------------- | | ------------ | -------------------------- |
| `?` | Show help screen |
| `n` | Sort by name | | `n` | Sort by name |
| `p` | Sort by progress | | `p` | Sort by progress |
| `a` | Show all/unfinished | | `a` | Show all/unfinished |
@@ -45,6 +38,7 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
| `right` | Seek forward 30 seconds | | `right` | Seek forward 30 seconds |
| `ctrl+left` | Go to the previous chapter | | `ctrl+left` | Go to the previous chapter |
| `ctrl+right` | Go to the next chapter | | `ctrl+right` | Go to the next chapter |
| `d` | Download/delete from cache |
| `q` | Quit the application | | `q` | Quit the application |
## Roadmap ## Roadmap
@@ -58,9 +52,10 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
- [x] add a control to jump 30s earlier/later - [x] add a control to jump 30s earlier/later
- [x] add control to go to the previous/next chapter - [x] add control to go to the previous/next chapter
- [x] save/resume playback of a book from the last position, regardless of which device was used previously - [x] save/resume playback of a book from the last position, regardless of which device was used previously
- [ ] mark a book as finished or unfinished - [x] download/remove a book in the cache without having to play it
- [ ] download/remove a book in the cache without having to play it - [x] add a help screen with all the keybindings
- [ ] increase/decrease reading speed - [ ] increase/decrease reading speed
- [ ] mark a book as finished or unfinished
- [ ] get your stats in a separated pane - [ ] get your stats in a separated pane
- [ ] filter books on views - [ ] filter books on views
- [ ] search in your book library - [ ] search in your book library

View File

@@ -10,7 +10,7 @@ from textual.events import Key
from textual.widgets import DataTable, Footer, Header, ProgressBar, Static from textual.widgets import DataTable, Footer, Header, ProgressBar, Static
from textual.worker import get_current_worker from textual.worker import get_current_worker
from .constants import * from .constants import PROGRESS_COLUMN_INDEX, SEEK_SECONDS, TABLE_CSS, TABLE_COLUMNS
from .downloads import DownloadManager from .downloads import DownloadManager
from .library import LibraryClient from .library import LibraryClient
from .playback import PlaybackController from .playback import PlaybackController
@@ -20,6 +20,7 @@ from .table_utils import (
filter_unfinished_items, filter_unfinished_items,
format_item_as_row, format_item_as_row,
) )
from .ui import HelpScreen
if TYPE_CHECKING: if TYPE_CHECKING:
from textual.widgets._data_table import ColumnKey from textual.widgets._data_table import ColumnKey
@@ -29,17 +30,20 @@ class Auditui(App):
"""Main application class for the Audible TUI app.""" """Main application class for the Audible TUI app."""
theme = "textual-dark" theme = "textual-dark"
SHOW_PALETTE = False
BINDINGS = [ BINDINGS = [
("?", "show_help", "Help"),
("n", "sort", "Sort by name"), ("n", "sort", "Sort by name"),
("p", "sort_by_progress", "Sort by progress"), ("p", "sort_by_progress", "Sort by progress"),
("a", "show_all", "All/unfinished"), ("a", "show_all", "All/Unfinished"),
("enter", "play_selected", "Play"), ("enter", "play_selected", "Play"),
("space", "toggle_playback", "Pause/Resume"), ("space", "toggle_playback", "Pause/Resume"),
("left", "seek_backward", "-30s"), ("left", "seek_backward", "-30s"),
("right", "seek_forward", "+30s"), ("right", "seek_forward", "+30s"),
("ctrl+left", "previous_chapter", "Previous chapter"), ("ctrl+left", "previous_chapter", "Previous chapter"),
("ctrl+right", "next_chapter", "Next chapter"), ("ctrl+right", "next_chapter", "Next chapter"),
("d", "toggle_download", "Download/Delete"),
("q", "quit", "Quit"), ("q", "quit", "Quit"),
] ]
@@ -74,7 +78,7 @@ class Auditui(App):
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) yield ProgressBar(id="progress_bar", show_eta=False, show_percentage=False, total=100)
yield Footer() yield Footer(show_command_palette=False)
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."""
@@ -172,14 +176,20 @@ class Auditui(App):
return return
for item in items: for item in items:
title, author, runtime, progress = format_item_as_row( title, author, runtime, progress, downloaded = format_item_as_row(
item, self.library_client) item, self.library_client, self.download_manager)
table.add_row(title, author, runtime, progress, key=title) table.add_row(title, author, runtime,
progress, downloaded, key=title)
self.current_items = items self.current_items = items
mode = "all" if self.show_all_mode else "unfinished" mode = "all" if self.show_all_mode else "unfinished"
self.update_status(f"Showing {len(items)} books ({mode})") self.update_status(f"Showing {len(items)} books ({mode})")
def _refresh_table(self) -> None:
"""Refresh the table with current items."""
if self.current_items:
self._populate_table(self.current_items)
def show_all(self) -> None: def show_all(self) -> None:
"""Display all books in the table.""" """Display all books in the table."""
if not self.all_items: if not self.all_items:
@@ -284,6 +294,10 @@ class Auditui(App):
"""Show message when no playback is active.""" """Show message when no playback is active."""
self.update_status("No playback active. Press Enter to play a book.") self.update_status("No playback active. Press Enter to play a book.")
def action_show_help(self) -> None:
"""Show the help screen with all keybindings."""
self.push_screen(HelpScreen())
def _check_playback_status(self) -> None: def _check_playback_status(self) -> None:
"""Check if playback process has finished and update state accordingly.""" """Check if playback process has finished and update state accordingly."""
message = self.playback.check_status() message = self.playback.check_status()
@@ -331,6 +345,51 @@ class Auditui(App):
"""Periodically save playback position.""" """Periodically save playback position."""
self.playback.update_position_if_needed() self.playback.update_position_if_needed()
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.")
return
table = self.query_one(DataTable)
if table.row_count == 0:
self.update_status("No books available")
return
cursor_row = table.cursor_row
if cursor_row >= len(self.current_items):
self.update_status("Invalid selection")
return
if not self.library_client:
self.update_status("Library client not available")
return
selected_item = self.current_items[cursor_row]
asin = self.library_client.extract_asin(selected_item)
if not asin:
self.update_status("Could not get ASIN for selected book")
return
self._toggle_download_async(asin)
@work(exclusive=True, thread=True)
def _toggle_download_async(self, asin: str) -> None:
"""Toggle download/remove asynchronously."""
if not self.download_manager:
return
if self.download_manager.is_cached(asin):
self.download_manager.remove_cached(
asin, self._thread_status_update)
else:
self.download_manager.get_or_download(
asin, self._thread_status_update)
self.call_from_thread(self._refresh_table)
@work(exclusive=True, thread=True) @work(exclusive=True, thread=True)
def _start_playback_async(self, asin: str) -> None: def _start_playback_async(self, asin: str) -> None:
"""Start playback asynchronously.""" """Start playback asynchronously."""

View File

@@ -2,7 +2,6 @@
"""Auditui entrypoint.""" """Auditui entrypoint."""
import sys import sys
from pathlib import Path
from auditui.app import Auditui from auditui.app import Auditui
from auditui.auth import authenticate from auditui.auth import authenticate
@@ -24,7 +23,7 @@ def main() -> None:
config_dir = AUTH_PATH.parent config_dir = AUTH_PATH.parent
if not config_dir.exists(): if not config_dir.exists():
print("No configuration yet, please run 'auditui configure' to create it") print("No configuration yet, please run 'auditui configure'.")
sys.exit(1) sys.exit(1)
try: try:
@@ -32,9 +31,9 @@ def main() -> None:
except Exception as exc: except Exception as exc:
print(f"Authentication error: {exc}") print(f"Authentication error: {exc}")
if not AUTH_PATH.exists(): if not AUTH_PATH.exists():
print("No configuration yet, please run 'auditui configure' to create it") print("No configuration yet, please run 'auditui configure'.")
else: else:
print("Please re-authenticate by running 'auditui configure'") print("Please re-authenticate by running 'auditui configure'.")
sys.exit(1) sys.exit(1)
app = Auditui(auth=auth, client=client) app = Auditui(auth=auth, client=client)
@@ -43,3 +42,4 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@@ -9,7 +9,7 @@ DEFAULT_CODEC = "LC_128_44100_stereo"
MIN_FILE_SIZE = 1024 * 1024 MIN_FILE_SIZE = 1024 * 1024
DEFAULT_CHUNK_SIZE = 8192 DEFAULT_CHUNK_SIZE = 8192
TABLE_COLUMNS = ("Title", "Author", "Length", "Progress") TABLE_COLUMNS = ("Title", "Author", "Length", "Progress", "Downloaded")
AUTHOR_NAME_MAX_LENGTH = 40 AUTHOR_NAME_MAX_LENGTH = 40
AUTHOR_NAME_DISPLAY_LENGTH = 37 AUTHOR_NAME_DISPLAY_LENGTH = 37
@@ -30,10 +30,10 @@ Header {
Footer { Footer {
background: #181825; background: #181825;
color: #bac2de; color: #bac2de;
height: 1; height: 2;
padding: 0 1; padding: 0 1;
scrollbar-size: 0 0; scrollbar-size: 0 0;
overflow-x: hidden; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
} }
@@ -49,7 +49,7 @@ FooterKey.-grouped,
Footer.-compact FooterKey { Footer.-compact FooterKey {
background: #181825; background: #181825;
padding: 0; padding: 0;
margin: 0 2 0 0; margin: 0 1 0 0;
} }
FooterKey .footer-key--key { FooterKey .footer-key--key {
@@ -140,6 +140,97 @@ ProgressBar#progress_bar > .progress-bar--track {
ProgressBar#progress_bar > .progress-bar--bar { ProgressBar#progress_bar > .progress-bar--bar {
background: #a6e3a1; background: #a6e3a1;
width: auto; }
HelpScreen {
align: center middle;
background: rgba(0, 0, 0, 0.7);
}
#help_container {
width: 70;
height: auto;
max-height: 85%;
min-height: 20;
background: #1e1e2e;
border: thick #89b4fa;
padding: 2;
}
#help_title {
text-align: center;
text-style: bold;
color: #89b4fa;
margin-bottom: 2;
padding-bottom: 1;
border-bottom: solid #585b70;
height: 3;
align: center middle;
}
#help_content {
width: 100%;
height: 1fr;
padding: 1 0;
margin: 1 0;
overflow-y: auto;
scrollbar-size: 0 1;
}
#help_content > .scrollbar--vertical {
background: #313244;
}
#help_content > .scrollbar--vertical > .scrollbar--track {
background: #181825;
}
#help_content > .scrollbar--vertical > .scrollbar--handle {
background: #585b70;
}
#help_content > .scrollbar--vertical > .scrollbar--handle:hover {
background: #45475a;
}
.help_row {
height: 3;
margin: 0 0 1 0;
padding: 0 1;
background: #181825;
border: solid #313244;
align: left middle;
}
.help_row:hover {
background: #313244;
border: solid #45475a;
}
.help_key {
width: 20;
text-align: right;
padding: 0 2 0 0;
color: #f9e2af;
text-style: bold;
align: right middle;
}
.help_action {
width: 1fr;
text-align: left;
padding: 0 0 0 2;
color: #cdd6f4;
align: left middle;
}
#help_footer {
text-align: center;
color: #bac2de;
margin-top: 2;
padding-top: 1;
border-top: solid #585b70;
height: 3;
align: center middle;
} }
""" """

View File

@@ -84,6 +84,37 @@ class DownloadManager:
except (OSError, ValueError, KeyError, AttributeError): except (OSError, ValueError, KeyError, AttributeError):
return None return None
def get_cached_path(self, asin: str) -> Path | None:
"""Get the cached file path for a book if it exists."""
title = self._get_name_from_asin(asin) or asin
safe_title = self._sanitize_filename(title)
local_path = self.cache_dir / f"{safe_title}.aax"
if local_path.exists() and local_path.stat().st_size >= MIN_FILE_SIZE:
return local_path
return None
def is_cached(self, asin: str) -> bool:
"""Check if a book is already cached."""
return self.get_cached_path(asin) is not None
def remove_cached(self, asin: str, notify: StatusCallback | None = None) -> bool:
"""Remove a cached book file."""
cached_path = self.get_cached_path(asin)
if not cached_path:
if notify:
notify("Book is not cached")
return False
try:
cached_path.unlink()
if notify:
notify(f"Removed from cache: {cached_path.name}")
return True
except OSError as exc:
if notify:
notify(f"Failed to remove cache: {exc}")
return False
def _validate_download_url(self, url: str) -> bool: def _validate_download_url(self, url: str) -> bool:
"""Validate that the URL is a valid HTTP/HTTPS URL.""" """Validate that the URL is a valid HTTP/HTTPS URL."""
try: try:

View File

@@ -1,7 +1,7 @@
"""Utils for table operations.""" """Utils for table operations."""
import unicodedata import unicodedata
from typing import Callable from typing import TYPE_CHECKING, Callable
from .constants import ( from .constants import (
AUTHOR_NAME_DISPLAY_LENGTH, AUTHOR_NAME_DISPLAY_LENGTH,
@@ -9,6 +9,9 @@ from .constants import (
PROGRESS_COLUMN_INDEX, PROGRESS_COLUMN_INDEX,
) )
if TYPE_CHECKING:
from .downloads import DownloadManager
def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]: def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]:
"""Create a sort key function for sorting by title.""" """Create a sort key function for sorting by title."""
@@ -43,11 +46,11 @@ def truncate_author_name(author_names: str) -> str:
return author_names return author_names
def format_item_as_row(item: dict, library_client) -> tuple[str, str, str, str]: def format_item_as_row(item: dict, library_client, download_manager: "DownloadManager | None" = None) -> tuple[str, str, str, str, str]:
"""Format a library item into table row data. """Format a library item into table row data.
Returns: Returns:
Tuple of (title, author, runtime, progress) strings Tuple of (title, author, runtime, progress, downloaded) strings
""" """
title = library_client.extract_title(item) title = library_client.extract_title(item)
@@ -67,7 +70,13 @@ def format_item_as_row(item: dict, library_client) -> tuple[str, str, str, str]:
else "0%" else "0%"
) )
return (title, author_display, runtime_str, progress_str) downloaded_str = ""
if download_manager:
asin = library_client.extract_asin(item)
if asin and download_manager.is_cached(asin):
downloaded_str = ""
return (title, author_display, runtime_str, progress_str, downloaded_str)
def filter_unfinished_items(items: list[dict], library_client) -> list[dict]: def filter_unfinished_items(items: list[dict], library_client) -> list[dict]:

33
auditui/ui.py Normal file
View File

@@ -0,0 +1,33 @@
"""UI components for the Auditui application."""
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, ScrollableContainer
from textual.screen import ModalScreen
from textual.widgets import Static
class HelpScreen(ModalScreen):
"""Help screen displaying all available keybindings."""
BINDINGS = [("escape", "dismiss", "Close"), ("?", "dismiss", "Close")]
def compose(self) -> ComposeResult:
with Container(id="help_container"):
yield Static("Key Bindings", id="help_title")
with ScrollableContainer(id="help_content"):
bindings = self.app.BINDINGS
for binding in bindings:
if isinstance(binding, tuple):
key, action, description = binding
else:
key = binding.key
description = binding.description
key_display = key.replace(
"ctrl+", "^").replace("left", "").replace("right", "").replace("space", "Space").replace("enter", "Enter")
with Horizontal(classes="help_row"):
yield Static(f"[bold #f9e2af]{key_display}[/]", classes="help_key")
yield Static(description, classes="help_action")
yield Static("Press [bold #f9e2af]?[/] or [bold #f9e2af]Escape[/] to close", id="help_footer")
def action_dismiss(self) -> None:
self.dismiss()

View File

@@ -4,8 +4,7 @@ version = "0.1.0"
description = "An Audible TUI client" description = "An Audible TUI client"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = ["audible>=0.10.0", "httpx>=0.28.1", "textual>=6.7.1"]
"audible>=0.10.0",
"httpx>=0.28.1", [project.scripts]
"textual>=6.7.1", auditui = "auditui.cli:main"
]