Compare commits
30 Commits
5e3b33570d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4741080284 | |||
| 737147b457 | |||
| 123d35068f | |||
| 258aabe10f | |||
| bc070c4162 | |||
| cbf6bff779 | |||
| 080c731fd7 | |||
| 1b6f1ff1f2 | |||
| aa5998c3e3 | |||
| c65e949731 | |||
| ab51e5506e | |||
| 3701b37f4c | |||
| 1474302d7e | |||
| eeecaaf42e | |||
| f359dee194 | |||
| 1e2655670d | |||
| cf6164c438 | |||
| 46fa15fcfe | |||
| 4b457452d4 | |||
| 0de0286992 | |||
| 391b0360bd | |||
| b0dc15a018 | |||
| a6d74265ed | |||
| 4f49a081c9 | |||
| 3a19db2cf0 | |||
| fcb1524806 | |||
| 18ffae7ac8 | |||
| d71c751bbc | |||
| 234b65c9d8 | |||
| 2d9970c198 |
26
README.md
26
README.md
@@ -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
|
||||
|
||||
|
||||
155
auditui/app.py
155
auditui/app.py
@@ -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."""
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
42
auditui/media_info.py
Normal 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, []
|
||||
@@ -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
87
auditui/table_utils.py
Normal 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
33
auditui/ui.py
Normal 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()
|
||||
7
main.py
7
main.py
@@ -2,7 +2,6 @@
|
||||
"""Auditui entrypoint."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from auditui.app import Auditui
|
||||
from auditui.auth import authenticate
|
||||
@@ -24,7 +23,7 @@ def main() -> None:
|
||||
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:
|
||||
@@ -32,9 +31,9 @@ def main() -> None:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user