Compare commits

..

30 Commits

Author SHA1 Message Date
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
cf6164c438 docs: update keybindings 2025-12-16 02:59:02 +01:00
46fa15fcfe clean: remove dark/light toggle 2025-12-16 02:58:57 +01:00
4b457452d4 refactor: move table-related utilities to table_utils.py 2025-12-16 02:55:15 +01:00
0de0286992 fix: docstring 2025-12-16 02:50:42 +01:00
391b0360bd docs: update definition of roadmap item 2025-12-16 02:26:31 +01:00
b0dc15a018 refactor: table_helpers to table_utils 2025-12-16 01:55:18 +01:00
a6d74265ed docs: update roadmap 2025-12-16 01:47:03 +01:00
4f49a081c9 clean: keep one-line docstring for consistency 2025-12-15 21:06:55 +01:00
3a19db2cf0 refactor: extract table sorting logic and media info loading to new modules 2025-12-15 21:03:52 +01:00
fcb1524806 feat: standardize error handling patterns 2025-12-15 21:01:09 +01:00
18ffae7ac8 refactor: use unified time formatting method 2025-12-15 20:59:36 +01:00
d71c751bbc clean: remove obsolete private method 2025-12-15 20:59:26 +01:00
234b65c9d8 refactor: consolidate time formatting 2025-12-15 20:59:15 +01:00
2d9970c198 refactor: move constants to constants.py 2025-12-15 20:57:30 +01:00
10 changed files with 425 additions and 160 deletions

View File

@@ -1,14 +1,6 @@
# auditui
A terminal-based user interface (TUI) client for Audible, written in Python 3.
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.
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!
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.
@@ -36,7 +28,7 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
| Key | Action |
| ------------ | -------------------------- |
| `d` | Toggle dark/light mode |
| `?` | Show help screen |
| `n` | Sort by name |
| `p` | Sort by progress |
| `a` | Show all/unfinished |
@@ -46,6 +38,7 @@ Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/)
| `right` | Seek forward 30 seconds |
| `ctrl+left` | Go to the previous chapter |
| `ctrl+right` | Go to the next chapter |
| `d` | Download/delete from cache |
| `q` | Quit the application |
## Roadmap
@@ -59,15 +52,22 @@ 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 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] 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
- [ ] mark a book as finished or unfinished
- [ ] get your stats in a separated pane
- [ ] filter books on views
- [ ] search in your book library
And after that:
- [ ] search the marketplace for books
- [ ] add a book in your wishlist
- [ ] get your listening stats from Audible
- [ ] add multiple themes and theme selector
- [ ] installation setup
All of this, and of course:
- [ ] installation setup
- [ ] build a decent TUI interface using [Textual](https://textual.textualize.io/)
- [ ] code cleanup / organization

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import unicodedata
from typing import TYPE_CHECKING
from textual import work
@@ -11,34 +10,40 @@ from textual.events import Key
from textual.widgets import DataTable, Footer, Header, ProgressBar, Static
from textual.worker import get_current_worker
from .constants import TABLE_COLUMNS, TABLE_CSS
from .constants import PROGRESS_COLUMN_INDEX, SEEK_SECONDS, TABLE_CSS, TABLE_COLUMNS
from .downloads import DownloadManager
from .library import LibraryClient
from .playback import PlaybackController
from .table_utils import (
create_progress_sort_key,
create_title_sort_key,
filter_unfinished_items,
format_item_as_row,
)
from .ui import HelpScreen
if TYPE_CHECKING:
from textual.widgets._data_table import ColumnKey
AUTHOR_NAME_MAX_LENGTH = 40
AUTHOR_NAME_DISPLAY_LENGTH = 37
PROGRESS_COLUMN_INDEX = 3
SEEK_SECONDS = 30.0
class Auditui(App):
"""Main application class for the Audible TUI app."""
theme = "textual-dark"
SHOW_PALETTE = False
BINDINGS = [
("d", "toggle_dark", "Light/Dark mode"),
("?", "show_help", "Help"),
("n", "sort", "Sort by name"),
("p", "sort_by_progress", "Sort by progress"),
("a", "show_all", "All/unfinished"),
("a", "show_all", "All/Unfinished"),
("enter", "play_selected", "Play"),
("space", "toggle_playback", "Pause/Resume"),
("left", "seek_backward", "-30s"),
("right", "seek_forward", "+30s"),
("ctrl+left", "previous_chapter", "Previous chapter"),
("ctrl+right", "next_chapter", "Next chapter"),
("d", "toggle_download", "Download/Delete"),
("q", "quit", "Quit"),
]
@@ -73,7 +78,7 @@ class Auditui(App):
yield table
yield Static("", id="progress_info")
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:
"""Initialize the table and start fetching library data."""
@@ -171,33 +176,20 @@ class Auditui(App):
return
for item in items:
title = self.library_client.extract_title(item)
author_names = self.library_client.extract_authors(item)
if author_names and len(author_names) > AUTHOR_NAME_MAX_LENGTH:
author_names = f"{author_names[:AUTHOR_NAME_DISPLAY_LENGTH]}..."
minutes = self.library_client.extract_runtime_minutes(item)
runtime_str = self.library_client.format_duration(
minutes, unit="minutes", default_none="Unknown length"
)
percent_complete = self.library_client.extract_progress_info(item)
progress_str = (
f"{percent_complete:.1f}%"
if percent_complete and percent_complete > 0
else "0%"
)
table.add_row(
title,
author_names or "Unknown",
runtime_str or "Unknown",
progress_str,
key=title,
)
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)
self.current_items = items
mode = "all" if self.show_all_mode else "unfinished"
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:
"""Display all books in the table."""
if not self.all_items:
@@ -211,29 +203,16 @@ class Auditui(App):
return
self.show_all_mode = False
unfinished_items = [
item for item in self.all_items if not self.library_client.is_finished(item)
]
unfinished_items = filter_unfinished_items(
self.all_items, self.library_client)
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, toggling direction on each press."""
table = self.query_one(DataTable)
if table.row_count > 0 and self.title_column_key:
def title_key(row_values):
title_cell = row_values[0]
if isinstance(title_cell, str):
normalized = unicodedata.normalize('NFD', title_cell)
return normalized.encode('ascii', 'ignore').decode('ascii').lower()
return str(title_cell).lower()
table.sort(key=title_key, reverse=self.title_sort_reverse)
title_key, reverse = create_title_sort_key(self.title_sort_reverse)
table.sort(key=title_key, reverse=reverse)
self.title_sort_reverse = not self.title_sort_reverse
def action_sort_by_progress(self) -> None:
@@ -241,17 +220,9 @@ class Auditui(App):
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)
progress_key, reverse = create_progress_sort_key(
self.progress_column_index, self.progress_sort_reverse)
table.sort(key=progress_key, reverse=reverse)
def action_show_all(self) -> None:
"""Toggle between showing all and unfinished books."""
@@ -323,6 +294,10 @@ class Auditui(App):
"""Show message when no playback is active."""
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:
"""Check if playback process has finished and update state accordingly."""
message = self.playback.check_status()
@@ -352,8 +327,8 @@ class Auditui(App):
progress_percent = min(100.0, max(
0.0, (chapter_elapsed / chapter_total) * 100.0))
progress_bar.update(progress=progress_percent)
chapter_elapsed_str = self._format_time(chapter_elapsed)
chapter_total_str = self._format_time(chapter_total)
chapter_elapsed_str = LibraryClient.format_time(chapter_elapsed)
chapter_total_str = LibraryClient.format_time(chapter_total)
progress_info.update(
f"{chapter_name} | {chapter_elapsed_str} / {chapter_total_str}")
progress_info.display = True
@@ -366,21 +341,55 @@ class Auditui(App):
progress_info.display = False
progress_bar.display = False
def _format_time(self, seconds: float) -> str:
"""Format seconds as HH:MM:SS or MM:SS."""
total_seconds = int(seconds)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
secs = total_seconds % 60
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
def _save_position_periodically(self) -> None:
"""Periodically save playback position."""
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)
def _start_playback_async(self, asin: str) -> None:
"""Start playback asynchronously."""

View File

@@ -9,7 +9,12 @@ DEFAULT_CODEC = "LC_128_44100_stereo"
MIN_FILE_SIZE = 1024 * 1024
DEFAULT_CHUNK_SIZE = 8192
TABLE_COLUMNS = ("Title", "Author", "Length", "Progress")
TABLE_COLUMNS = ("Title", "Author", "Length", "Progress", "Downloaded")
AUTHOR_NAME_MAX_LENGTH = 40
AUTHOR_NAME_DISPLAY_LENGTH = 37
PROGRESS_COLUMN_INDEX = 3
SEEK_SECONDS = 30.0
TABLE_CSS = """
Screen {
@@ -25,10 +30,10 @@ Header {
Footer {
background: #181825;
color: #bac2de;
height: 1;
height: 2;
padding: 0 1;
scrollbar-size: 0 0;
overflow-x: hidden;
overflow-x: auto;
overflow-y: hidden;
}
@@ -44,7 +49,7 @@ FooterKey.-grouped,
Footer.-compact FooterKey {
background: #181825;
padding: 0;
margin: 0 2 0 0;
margin: 0 1 0 0;
}
FooterKey .footer-key--key {
@@ -135,6 +140,97 @@ ProgressBar#progress_bar > .progress-bar--track {
ProgressBar#progress_bar > .progress-bar--bar {
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

@@ -81,15 +81,46 @@ class DownloadManager:
if isinstance(activation_bytes, bytes):
return activation_bytes.hex()
return str(activation_bytes)
except Exception:
except (OSError, ValueError, KeyError, AttributeError):
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:
"""Validate that the URL is a valid HTTP/HTTPS URL."""
try:
parsed = urlparse(url)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
except Exception:
except (ValueError, AttributeError):
return False
def _sanitize_filename(self, filename: str) -> str:
@@ -105,7 +136,7 @@ class DownloadManager:
)
product = product_info.get("product", {})
return product.get("title") or "Unknown Title"
except Exception:
except (OSError, ValueError, KeyError):
return None
def _get_download_link(
@@ -141,7 +172,7 @@ class DownloadManager:
if notify:
notify(f"Download-link request failed: {exc!s}")
return None
except Exception as exc:
except (OSError, ValueError, KeyError, AttributeError) as exc:
if notify:
notify(f"Download-link error: {exc!s}")
return None
@@ -187,7 +218,7 @@ class DownloadManager:
except OSError:
pass
return None
except Exception as exc:
except (OSError, ValueError, KeyError) as exc:
if notify:
notify(f"Download error: {exc!s}")
try:

View File

@@ -157,7 +157,7 @@ class LibraryClient:
return float(position_ms) / 1000.0
return None
except Exception:
except (OSError, ValueError, KeyError):
return None
def _get_content_reference(self, asin: str) -> dict | None:
@@ -172,7 +172,7 @@ class LibraryClient:
if isinstance(content_reference, dict):
return content_reference
return None
except Exception:
except (OSError, ValueError, KeyError):
return None
def save_last_position(self, asin: str, position_seconds: float) -> bool:
@@ -203,7 +203,7 @@ class LibraryClient:
body=body,
)
return True
except Exception:
except (OSError, ValueError, KeyError):
return False
@staticmethod
@@ -227,3 +227,15 @@ class LibraryClient:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
return " ".join(parts) if parts else default_none
@staticmethod
def format_time(seconds: float) -> str:
"""Format seconds as HH:MM:SS or MM:SS."""
total_seconds = int(seconds)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
secs = total_seconds % 60
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"

42
auditui/media_info.py Normal file
View File

@@ -0,0 +1,42 @@
"""Media information loading for Audible content."""
import json
import shutil
import subprocess
from pathlib import Path
def load_media_info(path: Path, activation_hex: str | None = None) -> tuple[float | None, list[dict]]:
"""Load media information including duration and chapters using ffprobe."""
if not shutil.which("ffprobe"):
return None, []
try:
cmd = ["ffprobe", "-v", "quiet", "-print_format",
"json", "-show_format", "-show_chapters"]
if activation_hex:
cmd.extend(["-activation_bytes", activation_hex])
cmd.append(str(path))
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return None, []
data = json.loads(result.stdout)
format_info = data.get("format", {})
duration_str = format_info.get("duration")
duration = float(duration_str) if duration_str else None
chapters_data = data.get("chapters", [])
chapters = [
{
"start_time": float(ch.get("start_time", 0)),
"end_time": float(ch.get("end_time", 0)),
"title": ch.get("tags", {}).get("title", f"Chapter {idx + 1}"),
}
for idx, ch in enumerate(chapters_data)
]
return duration, chapters
except (json.JSONDecodeError, subprocess.TimeoutExpired, ValueError, KeyError):
return None, []

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import json
import os
import shutil
import signal
@@ -13,6 +12,7 @@ from typing import Callable
from .downloads import DownloadManager
from .library import LibraryClient
from .media_info import load_media_info
StatusCallback = Callable[[str], None]
@@ -89,11 +89,13 @@ class PlaybackController:
self.playback_start_time = time.time()
self.paused_duration = 0.0
self.pause_start_time = None
self._load_media_info(path, activation_hex)
duration, chapters = load_media_info(path, activation_hex)
self.total_duration = duration
self.chapters = chapters
notify(f"Playing: {path.name}")
return True
except Exception as exc:
except (OSError, ValueError, subprocess.SubprocessError) as exc:
notify(f"Error starting playback: {exc}")
return False
@@ -202,7 +204,7 @@ class PlaybackController:
self.notify("Process no longer exists")
except PermissionError:
self.notify(f"Permission denied: cannot {action} playback")
except Exception as exc:
except (OSError, ValueError) as exc:
self.notify(f"Error {action}ing playback: {exc}")
def is_alive(self) -> bool:
@@ -244,8 +246,8 @@ class PlaybackController:
if last_position is not None and last_position > 0:
start_position = last_position
notify(
f"Resuming from {self._format_position(start_position)}")
except Exception:
f"Resuming from {LibraryClient.format_time(start_position)}")
except (OSError, ValueError, KeyError):
pass
notify(f"Starting playback of {local_path.name}...")
@@ -358,41 +360,6 @@ class PlaybackController:
"""Seek backward by specified seconds. Returns True if action was taken."""
return self._seek(seconds, "backward")
def _load_media_info(self, path: Path, activation_hex: str | None) -> None:
"""Load media information including duration and chapters using ffprobe."""
if not shutil.which("ffprobe"):
return
try:
cmd = ["ffprobe", "-v", "quiet", "-print_format",
"json", "-show_format", "-show_chapters"]
if activation_hex:
cmd.extend(["-activation_bytes", activation_hex])
cmd.append(str(path))
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return
data = json.loads(result.stdout)
format_info = data.get("format", {})
duration_str = format_info.get("duration")
if duration_str:
self.total_duration = float(duration_str)
chapters_data = data.get("chapters", [])
self.chapters = [
{
"start_time": float(ch.get("start_time", 0)),
"end_time": float(ch.get("end_time", 0)),
"title": ch.get("tags", {}).get("title", f"Chapter {idx + 1}"),
}
for idx, ch in enumerate(chapters_data)
]
except (json.JSONDecodeError, subprocess.TimeoutExpired, ValueError, KeyError):
pass
def get_current_progress(self) -> tuple[str, float, float] | None:
"""Get current playback progress."""
if not self.is_playing or self.playback_start_time is None:
@@ -510,20 +477,9 @@ class PlaybackController:
try:
self.library_client.save_last_position(
self.current_asin, current_position)
except Exception:
except (OSError, ValueError, KeyError):
pass
def _format_position(self, seconds: float) -> str:
"""Format position in seconds as HH:MM:SS or MM:SS."""
total_seconds = int(seconds)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
secs = total_seconds % 60
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"
def update_position_if_needed(self) -> None:
"""Periodically save position if enough time has passed."""
if not (self.is_playing and self.library_client and self.current_asin):

87
auditui/table_utils.py Normal file
View File

@@ -0,0 +1,87 @@
"""Utils for table operations."""
import unicodedata
from typing import TYPE_CHECKING, Callable
from .constants import (
AUTHOR_NAME_DISPLAY_LENGTH,
AUTHOR_NAME_MAX_LENGTH,
PROGRESS_COLUMN_INDEX,
)
if TYPE_CHECKING:
from .downloads import DownloadManager
def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]:
"""Create a sort key function for sorting by title."""
def title_key(row_values):
title_cell = row_values[0]
if isinstance(title_cell, str):
normalized = unicodedata.normalize('NFD', title_cell)
return normalized.encode('ascii', 'ignore').decode('ascii').lower()
return str(title_cell).lower()
return title_key, reverse
def create_progress_sort_key(progress_column_index: int = PROGRESS_COLUMN_INDEX, reverse: bool = False) -> tuple[Callable, bool]:
"""Create a sort key function for sorting by progress percentage."""
def progress_key(row_values):
progress_cell = row_values[progress_column_index]
if isinstance(progress_cell, str):
try:
return float(progress_cell.rstrip("%"))
except (ValueError, AttributeError):
return 0.0
return 0.0
return progress_key, reverse
def truncate_author_name(author_names: str) -> str:
"""Truncate author name if it exceeds maximum length."""
if author_names and len(author_names) > AUTHOR_NAME_MAX_LENGTH:
return f"{author_names[:AUTHOR_NAME_DISPLAY_LENGTH]}..."
return author_names
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.
Returns:
Tuple of (title, author, runtime, progress, downloaded) strings
"""
title = library_client.extract_title(item)
author_names = library_client.extract_authors(item)
author_names = truncate_author_name(author_names)
author_display = author_names or "Unknown"
minutes = library_client.extract_runtime_minutes(item)
runtime_str = library_client.format_duration(
minutes, unit="minutes", default_none="Unknown length"
) or "Unknown"
percent_complete = library_client.extract_progress_info(item)
progress_str = (
f"{percent_complete:.1f}%"
if percent_complete and percent_complete > 0
else "0%"
)
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]:
"""Filter out finished items from the list."""
return [
item for item in items
if not library_client.is_finished(item)
]

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()

15
main.py
View File

@@ -2,7 +2,6 @@
"""Auditui entrypoint."""
import sys
from pathlib import Path
from auditui.app import Auditui
from auditui.auth import authenticate
@@ -20,23 +19,23 @@ def main() -> None:
print(f"Configuration error: {exc}")
sys.exit(1)
return
config_dir = AUTH_PATH.parent
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)
try:
auth, client = authenticate()
except Exception as exc:
print(f"Authentication error: {exc}")
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:
print("Please re-authenticate by running 'auditui configure'")
print("Please re-authenticate by running 'auditui configure'.")
sys.exit(1)
app = Auditui(auth=auth, client=client)
app.run()