From 8e73e45e2d1fb344e78c87f0f1026d37b1a404c1 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 02:43:45 +0100 Subject: [PATCH 01/35] refactor: restructure into package layout and split large modules --- README.md | 2 +- auditui/__init__.py | 2 +- auditui/app.py | 614 ------------------ auditui/app/__init__.py | 27 + auditui/app/actions.py | 159 +++++ auditui/app/bindings.py | 23 + auditui/app/layout.py | 108 +++ auditui/app/library.py | 36 + auditui/app/progress.py | 94 +++ auditui/app/state.py | 31 + auditui/app/table.py | 106 +++ auditui/{auth.py => auth/__init__.py} | 12 +- auditui/cli/__init__.py | 5 + auditui/{cli.py => cli/main.py} | 8 +- .../{configure.py => configure/__init__.py} | 9 +- .../{constants.py => constants/__init__.py} | 2 +- auditui/downloads/__init__.py | 5 + .../{downloads.py => downloads/manager.py} | 32 +- auditui/library/__init__.py | 22 + auditui/{library.py => library/client.py} | 72 +- .../{search_utils.py => library/search.py} | 18 +- auditui/{table_utils.py => library/table.py} | 25 +- auditui/playback.py | 513 --------------- auditui/playback/__init__.py | 6 + auditui/playback/chapters.py | 30 + auditui/playback/constants.py | 5 + auditui/playback/controller.py | 14 + auditui/playback/controller_lifecycle.py | 183 ++++++ auditui/playback/controller_seek_speed.py | 127 ++++ auditui/playback/controller_state.py | 124 ++++ auditui/playback/elapsed.py | 23 + auditui/{ => playback}/media_info.py | 4 +- auditui/playback/process.py | 68 ++ auditui/playback/seek.py | 19 + auditui/stats/__init__.py | 5 + auditui/stats/account.py | 71 ++ auditui/stats/aggregator.py | 85 +++ auditui/stats/email.py | 155 +++++ auditui/stats/format.py | 22 + auditui/stats/listening.py | 75 +++ auditui/types/__init__.py | 8 + auditui/ui.py | 570 ---------------- auditui/ui/__init__.py | 7 + auditui/ui/common.py | 30 + auditui/ui/filter_screen.py | 66 ++ auditui/ui/help_screen.py | 54 ++ auditui/ui/stats_screen.py | 54 ++ tests/test_app_filter.py | 2 +- tests/test_downloads.py | 10 +- tests/test_table_utils.py | 22 +- tests/test_ui_email.py | 54 +- 51 files changed, 1970 insertions(+), 1848 deletions(-) delete mode 100644 auditui/app.py create mode 100644 auditui/app/__init__.py create mode 100644 auditui/app/actions.py create mode 100644 auditui/app/bindings.py create mode 100644 auditui/app/layout.py create mode 100644 auditui/app/library.py create mode 100644 auditui/app/progress.py create mode 100644 auditui/app/state.py create mode 100644 auditui/app/table.py rename auditui/{auth.py => auth/__init__.py} (61%) create mode 100644 auditui/cli/__init__.py rename auditui/{cli.py => cli/main.py} (91%) rename auditui/{configure.py => configure/__init__.py} (78%) rename auditui/{constants.py => constants/__init__.py} (98%) create mode 100644 auditui/downloads/__init__.py rename auditui/{downloads.py => downloads/manager.py} (88%) create mode 100644 auditui/library/__init__.py rename auditui/{library.py => library/client.py} (82%) rename auditui/{search_utils.py => library/search.py} (57%) rename auditui/{table_utils.py => library/table.py} (73%) delete mode 100644 auditui/playback.py create mode 100644 auditui/playback/__init__.py create mode 100644 auditui/playback/chapters.py create mode 100644 auditui/playback/constants.py create mode 100644 auditui/playback/controller.py create mode 100644 auditui/playback/controller_lifecycle.py create mode 100644 auditui/playback/controller_seek_speed.py create mode 100644 auditui/playback/controller_state.py create mode 100644 auditui/playback/elapsed.py rename auditui/{ => playback}/media_info.py (88%) create mode 100644 auditui/playback/process.py create mode 100644 auditui/playback/seek.py create mode 100644 auditui/stats/__init__.py create mode 100644 auditui/stats/account.py create mode 100644 auditui/stats/aggregator.py create mode 100644 auditui/stats/email.py create mode 100644 auditui/stats/format.py create mode 100644 auditui/stats/listening.py create mode 100644 auditui/types/__init__.py delete mode 100644 auditui/ui.py create mode 100644 auditui/ui/__init__.py create mode 100644 auditui/ui/common.py create mode 100644 auditui/ui/filter_screen.py create mode 100644 auditui/ui/help_screen.py create mode 100644 auditui/ui/stats_screen.py diff --git a/README.md b/README.md index dcb615a..9a81576 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ This project uses [uv](https://github.com/astral-sh/uv) for dependency managemen $ uv sync # modify the code... # ...and run the TUI -$ uv run python -m auditui.cli +$ uv run auditui ``` Don't forget to run the tests. diff --git a/auditui/__init__.py b/auditui/__init__.py index fceab3b..db53994 100644 --- a/auditui/__init__.py +++ b/auditui/__init__.py @@ -1,3 +1,3 @@ -"""Auditui package""" +"""Auditui: Audible TUI client. One folder per module; all code lives inside module packages.""" __version__ = "0.1.6" diff --git a/auditui/app.py b/auditui/app.py deleted file mode 100644 index b77e5fa..0000000 --- a/auditui/app.py +++ /dev/null @@ -1,614 +0,0 @@ -"""Textual application for the Audible TUI.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from textual import work -from textual.app import App, ComposeResult -from textual.containers import Horizontal -from textual.events import Key, Resize -from textual.widgets import DataTable, ProgressBar, Static -from textual.worker import get_current_worker - -from . import __version__ -from .constants import ( - PROGRESS_COLUMN_INDEX, - SEEK_SECONDS, - TABLE_COLUMN_DEFS, - TABLE_CSS, -) -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 .search_utils import build_search_text, filter_items -from .ui import FilterScreen, HelpScreen, StatsScreen - -if TYPE_CHECKING: - from textual.widgets.data_table import ColumnKey - - -class Auditui(App): - """Main application class for the Audible TUI app.""" - - SHOW_PALETTE = False - - BINDINGS = [ - ("?", "show_help", "Help"), - ("s", "show_stats", "Stats"), - ("/", "filter", "Filter"), - ("escape", "clear_filter", "Clear filter"), - ("n", "sort", "Sort by name"), - ("p", "sort_by_progress", "Sort by progress"), - ("a", "show_all", "All/Unfinished"), - ("r", "refresh", "Refresh"), - ("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"), - ("up", "increase_speed", "Increase speed"), - ("down", "decrease_speed", "Decrease speed"), - ("f", "toggle_finished", "Mark finished"), - ("d", "toggle_download", "Download/Delete"), - ("q", "quit", "Quit"), - ] - - CSS = TABLE_CSS - - def __init__(self, auth=None, client=None) -> None: - super().__init__() - self.auth = auth - self.client = client - self.library_client = LibraryClient(client) if client else None - self.download_manager = ( - DownloadManager(auth, client) if auth and client else None - ) - self.playback = PlaybackController(self.update_status, self.library_client) - - self.all_items: list[dict] = [] - self.current_items: list[dict] = [] - self._search_text_cache: dict[int, str] = {} - self.show_all_mode = False - self.filter_text = "" - self.title_sort_reverse = False - self.progress_sort_reverse = False - self.title_column_key: ColumnKey | None = None - self.progress_column_index = PROGRESS_COLUMN_INDEX - - def compose(self) -> ComposeResult: - yield Horizontal( - Static("? Help", id="top_left"), - Static(f"Auditui v{__version__}", id="top_center"), - Static("q Quit", id="top_right"), - id="top_bar", - ) - yield Static("Loading...", id="status") - table: DataTable = DataTable() - table.zebra_stripes = True - table.cursor_type = "row" - yield table - yield Static("", id="progress_info") - with Horizontal(id="progress_bar_container"): - yield ProgressBar( - id="progress_bar", show_eta=False, show_percentage=False, total=100 - ) - - def on_mount(self) -> None: - """Initialize the table and start fetching library data.""" - self.theme = "textual-dark" - table = self.query_one(DataTable) - for column_name, _ratio in TABLE_COLUMN_DEFS: - table.add_column(column_name) - self.call_after_refresh(lambda: self._apply_column_widths(table)) - column_keys = list(table.columns.keys()) - self.title_column_key = column_keys[0] - - if self.client: - self.update_status("Fetching library...") - self.fetch_library() - else: - self.update_status("Not authenticated. Please restart and authenticate.") - - self.set_interval(1.0, self._check_playback_status) - self.set_interval(0.5, self._update_progress) - self.set_interval(30.0, self._save_position_periodically) - - def on_unmount(self) -> None: - """Clean up on app exit.""" - self.playback.stop() - if self.download_manager: - self.download_manager.close() - - def on_resize(self, event: Resize) -> None: - """Keep table columns responsive to terminal width changes.""" - del event - try: - table = self.query_one(DataTable) - except Exception: - return - self.call_after_refresh(lambda: self._apply_column_widths(table)) - - def on_key(self, event: Key) -> None: - """Handle key presses.""" - if self.playback.is_playing: - if event.key == "ctrl+left": - event.prevent_default() - self.action_previous_chapter() - return - elif event.key == "ctrl+right": - event.prevent_default() - self.action_next_chapter() - return - elif event.key == "left": - event.prevent_default() - self.action_seek_backward() - return - elif event.key == "right": - event.prevent_default() - self.action_seek_forward() - return - elif event.key == "up": - event.prevent_default() - self.action_increase_speed() - return - elif event.key == "down": - event.prevent_default() - self.action_decrease_speed() - return - - if isinstance(self.focused, DataTable): - if event.key == "enter": - event.prevent_default() - self.action_play_selected() - elif event.key == "space": - event.prevent_default() - self.action_toggle_playback() - - def update_status(self, message: str) -> None: - """Update the status message in the UI.""" - status = self.query_one("#status", Static) - status.display = True - status.update(message) - - def _apply_column_widths(self, table: DataTable) -> None: - """Assign proportional column widths based on available space. - - Each column's render width = column.width + 2 * cell_padding. - We compute column.width values so columns fill the full table width. - """ - if not table.columns: - return - - column_keys = list(table.columns.keys()) - num_cols = len(column_keys) - ratios = [ratio for _, ratio in TABLE_COLUMN_DEFS] - total_ratio = sum(ratios) or num_cols - - content_width = table.scrollable_content_region.width - if content_width <= 0: - content_width = table.size.width - if content_width <= 0: - return - - padding_total = 2 * table.cell_padding * num_cols - distributable = max(num_cols, content_width - padding_total) - - widths: list[int] = [] - for ratio in ratios: - w = max(1, (distributable * ratio) // total_ratio) - widths.append(w) - - remainder = distributable - sum(widths) - if remainder > 0: - indices = sorted(range(num_cols), key=lambda i: ratios[i], reverse=True) - for i in range(remainder): - widths[indices[i % num_cols]] += 1 - - for column_key, w in zip(column_keys, widths): - col = table.columns[column_key] - col.auto_width = False - col.width = w - table.refresh() - - def _thread_status_update(self, message: str) -> None: - """Safely update status from worker threads.""" - self.call_from_thread(self.update_status, message) - - @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 or not self.library_client: - return - - try: - all_items = self.library_client.fetch_all_items(self._thread_status_update) - self.call_from_thread(self.on_library_loaded, all_items) - except (OSError, ValueError, KeyError) as exc: - self.call_from_thread(self.on_library_error, str(exc)) - - def on_library_loaded(self, items: list[dict]) -> None: - """Handle successful library load.""" - self.all_items = items - self._search_text_cache.clear() - self._prime_search_cache(items) - self.update_status(f"Loaded {len(items)} books") - if self.show_all_mode: - self.show_all() - else: - self.show_unfinished() - - def on_library_error(self, error: str) -> None: - """Handle library fetch error.""" - self.update_status(f"Error fetching library: {error}") - - def _populate_table(self, items: list[dict]) -> None: - """Populate the DataTable with library items.""" - table = self.query_one(DataTable) - table.clear() - - if not items or not self.library_client: - self.update_status("No books found.") - return - - for item in items: - 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 - status = self.query_one("#status", Static) - status.display = False - self._apply_column_widths(table) - - 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: - return - self.show_all_mode = True - self._refresh_filtered_view() - - def show_unfinished(self) -> None: - """Display only unfinished books in the table.""" - if not self.all_items or not self.library_client: - return - self.show_all_mode = False - self._refresh_filtered_view() - - 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: - 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: - """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 - 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.""" - if self.show_all_mode: - self.show_unfinished() - else: - self.show_all() - - def action_refresh(self) -> None: - """Refresh the library data from the API.""" - if not self.client: - self.update_status("Not authenticated. Cannot refresh.") - return - self.update_status("Refreshing library...") - self.fetch_library() - - def action_play_selected(self) -> None: - """Start playing 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._start_playback_async(asin) - - def action_toggle_playback(self) -> None: - """Toggle pause/resume state.""" - if not self.playback.toggle_playback(): - self._no_playback_message() - - def action_seek_forward(self) -> None: - """Seek forward 30 seconds.""" - if not self.playback.seek_forward(SEEK_SECONDS): - self._no_playback_message() - - def action_seek_backward(self) -> None: - """Seek backward 30 seconds.""" - if not self.playback.seek_backward(SEEK_SECONDS): - self._no_playback_message() - - def action_next_chapter(self) -> None: - """Seek to the next chapter.""" - if not self.playback.seek_to_next_chapter(): - self._no_playback_message() - - def action_previous_chapter(self) -> None: - """Seek to the previous chapter.""" - if not self.playback.seek_to_previous_chapter(): - self._no_playback_message() - - def action_increase_speed(self) -> None: - """Increase playback speed.""" - if not self.playback.increase_speed(): - self._no_playback_message() - - def action_decrease_speed(self) -> None: - """Decrease playback speed.""" - if not self.playback.decrease_speed(): - self._no_playback_message() - - def action_toggle_finished(self) -> None: - """Toggle finished/unfinished status for the selected book.""" - if not self.library_client: - self.update_status("Library client not available") - 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 - - 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_finished_async(asin) - - @work(exclusive=True, thread=True) - def _toggle_finished_async(self, asin: str) -> None: - """Toggle finished/unfinished status asynchronously.""" - if not self.library_client: - return - - selected_item = None - for item in self.current_items: - if self.library_client.extract_asin(item) == asin: - selected_item = item - break - - if not selected_item: - return - - is_currently_finished = self.library_client.is_finished(selected_item) - - if is_currently_finished: - self.call_from_thread(self.update_status, "Already marked as finished") - return - - success = self.library_client.mark_as_finished(asin, selected_item) - message = "Marked as finished" if success else "Failed to mark as finished" - - self.call_from_thread(self.update_status, message) - if success: - if self.download_manager and self.download_manager.is_cached(asin): - self.download_manager.remove_cached( - asin, notify=self._thread_status_update - ) - self.call_from_thread(self.fetch_library) - - def _no_playback_message(self) -> None: - """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 action_show_stats(self) -> None: - """Show the stats screen with listening statistics.""" - self.push_screen(StatsScreen()) - - def action_filter(self) -> None: - """Show the filter screen to search the library.""" - self.push_screen( - FilterScreen( - self.filter_text, - on_change=self._apply_filter, - ), - self._apply_filter, - ) - - def action_clear_filter(self) -> None: - """Clear the current filter if active.""" - if self.filter_text: - self.filter_text = "" - self._refresh_filtered_view() - self.update_status("Filter cleared") - - def _apply_filter(self, filter_text: str | None) -> None: - """Apply the filter to the library.""" - self.filter_text = filter_text or "" - self._refresh_filtered_view() - - def _refresh_filtered_view(self) -> None: - """Refresh the table with current filter and view mode.""" - if not self.all_items: - return - - items = self.all_items - - if self.filter_text: - items = filter_items(items, self.filter_text, self._get_search_text) - self._populate_table(items) - self.update_status(f"Filter: '{self.filter_text}' ({len(items)} books)") - return - - if not self.show_all_mode and self.library_client: - items = filter_unfinished_items(items, self.library_client) - - self._populate_table(items) - - def _get_search_text(self, item: dict) -> str: - """Return cached search text for filtering.""" - cache_key = id(item) - cached = self._search_text_cache.get(cache_key) - if cached is not None: - return cached - search_text = build_search_text(item, self.library_client) - self._search_text_cache[cache_key] = search_text - return search_text - - def _prime_search_cache(self, items: list[dict]) -> None: - """Precompute search text for a list of items.""" - for item in items: - self._get_search_text(item) - - def _check_playback_status(self) -> None: - """Check if playback process has finished and update state accordingly.""" - message = self.playback.check_status() - if message: - self.update_status(message) - self._hide_progress() - - def _update_progress(self) -> None: - """Update the progress bar and info during playback.""" - if not self.playback.is_playing: - self._hide_progress() - return - - progress_data = self.playback.get_current_progress() - if not progress_data: - self._hide_progress() - return - - chapter_name, chapter_elapsed, chapter_total = progress_data - if chapter_total <= 0: - self._hide_progress() - return - - progress_info = self.query_one("#progress_info", Static) - progress_bar = self.query_one("#progress_bar", ProgressBar) - progress_bar_container = self.query_one("#progress_bar_container", Horizontal) - - progress_percent = min( - 100.0, max(0.0, (chapter_elapsed / chapter_total) * 100.0) - ) - progress_bar.update(progress=progress_percent) - 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 - progress_bar_container.display = True - - def _hide_progress(self) -> None: - """Hide the progress widget.""" - progress_info = self.query_one("#progress_info", Static) - progress_bar_container = self.query_one("#progress_bar_container", Horizontal) - progress_info.display = False - progress_bar_container.display = False - - 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.""" - if not self.download_manager: - return - self.playback.prepare_and_start( - self.download_manager, - asin, - self._thread_status_update, - ) diff --git a/auditui/app/__init__.py b/auditui/app/__init__.py new file mode 100644 index 0000000..e1c10b4 --- /dev/null +++ b/auditui/app/__init__.py @@ -0,0 +1,27 @@ +"""Main Textual app: table, bindings, and orchestration of library, playback, and downloads.""" + +from __future__ import annotations + +from textual.app import App + +from ..constants import TABLE_CSS + +from .bindings import BINDINGS +from .state import init_auditui_state +from .layout import AppLayoutMixin +from .table import AppTableMixin +from .library import AppLibraryMixin +from .actions import AppActionsMixin +from .progress import AppProgressMixin + + +class Auditui(App, AppProgressMixin, AppActionsMixin, AppLibraryMixin, AppTableMixin, AppLayoutMixin): + """Orchestrates the library table, playback, downloads, filter, and modal screens.""" + + SHOW_PALETTE = False + BINDINGS = BINDINGS + CSS = TABLE_CSS + + def __init__(self, auth=None, client=None) -> None: + super().__init__() + init_auditui_state(self, auth, client) diff --git a/auditui/app/actions.py b/auditui/app/actions.py new file mode 100644 index 0000000..284c61c --- /dev/null +++ b/auditui/app/actions.py @@ -0,0 +1,159 @@ +"""Selection, playback/download/finish actions, modals, and filter.""" + +from __future__ import annotations + +from textual import work +from textual.widgets import DataTable + +from ..constants import SEEK_SECONDS +from ..ui import FilterScreen, HelpScreen, StatsScreen + + +class AppActionsMixin: + def _get_selected_asin(self) -> str | None: + if not self.download_manager: + self.update_status( + "Not authenticated. Please restart and authenticate.") + return None + table = self.query_one(DataTable) + if table.row_count == 0: + self.update_status("No books available") + return None + cursor_row = table.cursor_row + if cursor_row >= len(self.current_items): + self.update_status("Invalid selection") + return None + if not self.library_client: + self.update_status("Library client not available") + return None + 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 None + return asin + + def action_play_selected(self) -> None: + asin = self._get_selected_asin() + if asin: + self._start_playback_async(asin) + + def action_toggle_playback(self) -> None: + if not self.playback.toggle_playback(): + self._no_playback_message() + + def action_seek_forward(self) -> None: + if not self.playback.seek_forward(SEEK_SECONDS): + self._no_playback_message() + + def action_seek_backward(self) -> None: + if not self.playback.seek_backward(SEEK_SECONDS): + self._no_playback_message() + + def action_next_chapter(self) -> None: + if not self.playback.seek_to_next_chapter(): + self._no_playback_message() + + def action_previous_chapter(self) -> None: + if not self.playback.seek_to_previous_chapter(): + self._no_playback_message() + + def action_increase_speed(self) -> None: + if not self.playback.increase_speed(): + self._no_playback_message() + + def action_decrease_speed(self) -> None: + if not self.playback.decrease_speed(): + self._no_playback_message() + + def action_toggle_finished(self) -> None: + asin = self._get_selected_asin() + if asin: + self._toggle_finished_async(asin) + + @work(exclusive=True, thread=True) + def _toggle_finished_async(self, asin: str) -> None: + if not self.library_client: + return + + selected_item = None + for item in self.current_items: + if self.library_client.extract_asin(item) == asin: + selected_item = item + break + + if not selected_item: + return + + if self.library_client.is_finished(selected_item): + self.call_from_thread(self.update_status, + "Already marked as finished") + return + + success = self.library_client.mark_as_finished(asin, selected_item) + message = "Marked as finished" if success else "Failed to mark as finished" + + self.call_from_thread(self.update_status, message) + if success: + if self.download_manager and self.download_manager.is_cached(asin): + self.download_manager.remove_cached( + asin, notify=self._thread_status_update + ) + self.call_from_thread(self.fetch_library) + + def _no_playback_message(self) -> None: + self.update_status("No playback active. Press Enter to play a book.") + + def action_show_help(self) -> None: + self.push_screen(HelpScreen()) + + def action_show_stats(self) -> None: + self.push_screen(StatsScreen()) + + def action_filter(self) -> None: + self.push_screen( + FilterScreen( + self.filter_text, + on_change=self._apply_filter, + ), + self._apply_filter, + ) + + def action_clear_filter(self) -> None: + if self.filter_text: + self.filter_text = "" + self._refresh_filtered_view() + self.update_status("Filter cleared") + + def _apply_filter(self, filter_text: str | None) -> None: + self.filter_text = filter_text or "" + self._refresh_filtered_view() + + def action_toggle_download(self) -> None: + asin = self._get_selected_asin() + if asin: + self._toggle_download_async(asin) + + @work(exclusive=True, thread=True) + def _toggle_download_async(self, asin: str) -> None: + 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: + if not self.download_manager: + return + self.playback.prepare_and_start( + self.download_manager, + asin, + self._thread_status_update, + ) diff --git a/auditui/app/bindings.py b/auditui/app/bindings.py new file mode 100644 index 0000000..2aa04a4 --- /dev/null +++ b/auditui/app/bindings.py @@ -0,0 +1,23 @@ +"""Key bindings for the main app.""" + +BINDINGS = [ + ("?", "show_help", "Help"), + ("s", "show_stats", "Stats"), + ("/", "filter", "Filter"), + ("escape", "clear_filter", "Clear filter"), + ("n", "sort", "Sort by name"), + ("p", "sort_by_progress", "Sort by progress"), + ("a", "show_all", "All/Unfinished"), + ("r", "refresh", "Refresh"), + ("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"), + ("up", "increase_speed", "Increase speed"), + ("down", "decrease_speed", "Decrease speed"), + ("f", "toggle_finished", "Mark finished"), + ("d", "toggle_download", "Download/Delete"), + ("q", "quit", "Quit"), +] diff --git a/auditui/app/layout.py b/auditui/app/layout.py new file mode 100644 index 0000000..4348a0c --- /dev/null +++ b/auditui/app/layout.py @@ -0,0 +1,108 @@ +"""Main layout: compose, mount, resize, status bar, table column widths.""" + +from __future__ import annotations + +from textual.app import ComposeResult +from textual.containers import Horizontal +from textual.events import Resize +from textual.widgets import DataTable, ProgressBar, Static + +from .. import __version__ +from ..constants import TABLE_COLUMN_DEFS, TABLE_CSS + + +class AppLayoutMixin: + def compose(self) -> ComposeResult: + yield Horizontal( + Static("? Help", id="top_left"), + Static(f"Auditui v{__version__}", id="top_center"), + Static("q Quit", id="top_right"), + id="top_bar", + ) + yield Static("Loading...", id="status") + table = DataTable() + table.zebra_stripes = True + table.cursor_type = "row" + yield table + yield Static("", id="progress_info") + with Horizontal(id="progress_bar_container"): + yield ProgressBar( + id="progress_bar", show_eta=False, show_percentage=False, total=100 + ) + + def on_mount(self) -> None: + self.theme = "textual-dark" + table = self.query_one(DataTable) + for column_name, _ratio in TABLE_COLUMN_DEFS: + table.add_column(column_name) + self.call_after_refresh(lambda: self._apply_column_widths(table)) + column_keys = list(table.columns.keys()) + self.title_column_key = column_keys[0] + + if self.client: + self.update_status("Fetching library...") + self.fetch_library() + else: + self.update_status( + "Not authenticated. Please restart and authenticate.") + + self.set_interval(1.0, self._check_playback_status) + self.set_interval(0.5, self._update_progress) + self.set_interval(30.0, self._save_position_periodically) + + def on_unmount(self) -> None: + self.playback.stop() + if self.download_manager: + self.download_manager.close() + + def on_resize(self, event: Resize) -> None: + del event + try: + table = self.query_one(DataTable) + except Exception: + return + self.call_after_refresh(lambda: self._apply_column_widths(table)) + + def update_status(self, message: str) -> None: + status = self.query_one("#status", Static) + status.display = True + status.update(message) + + def _apply_column_widths(self, table: DataTable) -> None: + if not table.columns: + return + + column_keys = list(table.columns.keys()) + num_cols = len(column_keys) + ratios = [ratio for _, ratio in TABLE_COLUMN_DEFS] + total_ratio = sum(ratios) or num_cols + + content_width = table.scrollable_content_region.width + if content_width <= 0: + content_width = table.size.width + if content_width <= 0: + return + + padding_total = 2 * table.cell_padding * num_cols + distributable = max(num_cols, content_width - padding_total) + + widths = [] + for ratio in ratios: + w = max(1, (distributable * ratio) // total_ratio) + widths.append(w) + + remainder = distributable - sum(widths) + if remainder > 0: + indices = sorted( + range(num_cols), key=lambda i: ratios[i], reverse=True) + for i in range(remainder): + widths[indices[i % num_cols]] += 1 + + for column_key, w in zip(column_keys, widths): + col = table.columns[column_key] + col.auto_width = False + col.width = w + table.refresh() + + def _thread_status_update(self, message: str) -> None: + self.call_from_thread(self.update_status, message) diff --git a/auditui/app/library.py b/auditui/app/library.py new file mode 100644 index 0000000..bd2075b --- /dev/null +++ b/auditui/app/library.py @@ -0,0 +1,36 @@ +"""Library fetch and load/error handlers.""" + +from __future__ import annotations + +from textual import work +from textual.worker import get_current_worker + +from ..types import LibraryItem + + +class AppLibraryMixin: + @work(exclusive=True, thread=True) + def fetch_library(self) -> None: + worker = get_current_worker() + if worker.is_cancelled or not self.library_client: + return + + try: + all_items = self.library_client.fetch_all_items( + self._thread_status_update) + self.call_from_thread(self.on_library_loaded, all_items) + except (OSError, ValueError, KeyError) as exc: + self.call_from_thread(self.on_library_error, str(exc)) + + def on_library_loaded(self, items: list[LibraryItem]) -> None: + self.all_items = items + self._search_text_cache.clear() + self._prime_search_cache(items) + self.update_status(f"Loaded {len(items)} books") + if self.show_all_mode: + self.show_all() + else: + self.show_unfinished() + + def on_library_error(self, error: str) -> None: + self.update_status(f"Error fetching library: {error}") diff --git a/auditui/app/progress.py b/auditui/app/progress.py new file mode 100644 index 0000000..659bb2c --- /dev/null +++ b/auditui/app/progress.py @@ -0,0 +1,94 @@ +"""Playback key handling, progress bar updates, and position save.""" + +from __future__ import annotations + +from textual.containers import Horizontal +from textual.events import Key +from textual.widgets import DataTable, ProgressBar, Static + +from ..library import LibraryClient + + +class AppProgressMixin: + def on_key(self, event: Key) -> None: + if self.playback.is_playing: + if event.key == "ctrl+left": + event.prevent_default() + self.action_previous_chapter() + return + elif event.key == "ctrl+right": + event.prevent_default() + self.action_next_chapter() + return + elif event.key == "left": + event.prevent_default() + self.action_seek_backward() + return + elif event.key == "right": + event.prevent_default() + self.action_seek_forward() + return + elif event.key == "up": + event.prevent_default() + self.action_increase_speed() + return + elif event.key == "down": + event.prevent_default() + self.action_decrease_speed() + return + + if isinstance(self.focused, DataTable): + if event.key == "enter": + event.prevent_default() + self.action_play_selected() + elif event.key == "space": + event.prevent_default() + self.action_toggle_playback() + + def _check_playback_status(self) -> None: + message = self.playback.check_status() + if message: + self.update_status(message) + self._hide_progress() + + def _update_progress(self) -> None: + if not self.playback.is_playing: + self._hide_progress() + return + + progress_data = self.playback.get_current_progress() + if not progress_data: + self._hide_progress() + return + + chapter_name, chapter_elapsed, chapter_total = progress_data + if chapter_total <= 0: + self._hide_progress() + return + + progress_info = self.query_one("#progress_info", Static) + progress_bar = self.query_one("#progress_bar", ProgressBar) + progress_bar_container = self.query_one( + "#progress_bar_container", Horizontal) + + progress_percent = min( + 100.0, max(0.0, (chapter_elapsed / chapter_total) * 100.0) + ) + progress_bar.update(progress=progress_percent) + 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 + progress_bar_container.display = True + + def _hide_progress(self) -> None: + progress_info = self.query_one("#progress_info", Static) + progress_bar_container = self.query_one( + "#progress_bar_container", Horizontal) + progress_info.display = False + progress_bar_container.display = False + + def _save_position_periodically(self) -> None: + self.playback.update_position_if_needed() diff --git a/auditui/app/state.py b/auditui/app/state.py new file mode 100644 index 0000000..90b048f --- /dev/null +++ b/auditui/app/state.py @@ -0,0 +1,31 @@ +"""App state initialization.""" + +from __future__ import annotations + +from ..constants import PROGRESS_COLUMN_INDEX +from ..downloads import DownloadManager +from ..library import LibraryClient +from ..playback import PlaybackController + + +def init_auditui_state(self: object, auth=None, client=None) -> None: + setattr(self, "auth", auth) + setattr(self, "client", client) + setattr(self, "library_client", LibraryClient(client) if client else None) + setattr( + self, + "download_manager", + DownloadManager(auth, client) if auth and client else None, + ) + notify = getattr(self, "update_status") + lib_client = LibraryClient(client) if client else None + setattr(self, "playback", PlaybackController(notify, lib_client)) + setattr(self, "all_items", []) + setattr(self, "current_items", []) + setattr(self, "_search_text_cache", {}) + setattr(self, "show_all_mode", False) + setattr(self, "filter_text", "") + setattr(self, "title_sort_reverse", False) + setattr(self, "progress_sort_reverse", False) + setattr(self, "title_column_key", None) + setattr(self, "progress_column_index", PROGRESS_COLUMN_INDEX) diff --git a/auditui/app/table.py b/auditui/app/table.py new file mode 100644 index 0000000..2faeb92 --- /dev/null +++ b/auditui/app/table.py @@ -0,0 +1,106 @@ +"""Table population, sorting, filter view, and search cache.""" + +from __future__ import annotations + +from ..library import ( + filter_items, + filter_unfinished_items, + format_item_as_row, + create_progress_sort_key, + create_title_sort_key, +) +from ..types import LibraryItem +from textual.widgets import DataTable, Static + + +class AppTableMixin: + def _populate_table(self, items: list[LibraryItem]) -> None: + table = self.query_one(DataTable) + table.clear() + + if not items or not self.library_client: + self.update_status("No books found.") + return + + for item in items: + 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 + status = self.query_one("#status", Static) + status.display = False + self._apply_column_widths(table) + + def _refresh_table(self) -> None: + if self.current_items: + self._populate_table(self.current_items) + + def show_all(self) -> None: + if not self.all_items: + return + self.show_all_mode = True + self._refresh_filtered_view() + + def show_unfinished(self) -> None: + if not self.all_items or not self.library_client: + return + self.show_all_mode = False + self._refresh_filtered_view() + + def action_sort(self) -> None: + table = self.query_one(DataTable) + if table.row_count > 0 and self.title_column_key: + 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: + table = self.query_one(DataTable) + if table.row_count > 0: + self.progress_sort_reverse = not 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: + if self.show_all_mode: + self.show_unfinished() + else: + self.show_all() + + def _refresh_filtered_view(self) -> None: + if not self.all_items: + return + + items = self.all_items + + if self.filter_text: + items = filter_items(items, self.filter_text, + self._get_search_text) + self._populate_table(items) + self.update_status( + f"Filter: '{self.filter_text}' ({len(items)} books)") + return + + if not self.show_all_mode and self.library_client: + items = filter_unfinished_items(items, self.library_client) + + self._populate_table(items) + + def _get_search_text(self, item: LibraryItem) -> str: + cache_key = id(item) + cached = self._search_text_cache.get(cache_key) + if cached is not None: + return cached + from ..library import build_search_text + search_text = build_search_text(item, self.library_client) + self._search_text_cache[cache_key] = search_text + return search_text + + def _prime_search_cache(self, items: list[LibraryItem]) -> None: + for item in items: + self._get_search_text(item) diff --git a/auditui/auth.py b/auditui/auth/__init__.py similarity index 61% rename from auditui/auth.py rename to auditui/auth/__init__.py index f1ca3a9..fbe2b69 100644 --- a/auditui/auth.py +++ b/auditui/auth/__init__.py @@ -1,19 +1,20 @@ -"""Authentication helpers for the Auditui app.""" +"""Load saved Audible credentials and build authenticator and API client.""" from pathlib import Path import audible -from .constants import AUTH_PATH +from ..constants import AUTH_PATH def authenticate( auth_path: Path = AUTH_PATH, ) -> tuple[audible.Authenticator, audible.Client]: - """Authenticate with Audible and return authenticator and client.""" + """Load auth from file and return (Authenticator, Client). Raises if file missing or invalid.""" if not auth_path.exists(): raise FileNotFoundError( - "Authentication file not found. Please run 'auditui configure' to set up authentication.") + "Authentication file not found. Please run 'auditui configure' to set up authentication." + ) try: authenticator = audible.Authenticator.from_file(str(auth_path)) @@ -21,4 +22,5 @@ def authenticate( return authenticator, audible_client except (OSError, ValueError, KeyError) as exc: raise ValueError( - f"Failed to load existing authentication: {exc}") from exc + f"Failed to load existing authentication: {exc}" + ) from exc diff --git a/auditui/cli/__init__.py b/auditui/cli/__init__.py new file mode 100644 index 0000000..4f80a9c --- /dev/null +++ b/auditui/cli/__init__.py @@ -0,0 +1,5 @@ +"""CLI package; entry point is main() from .main.""" + +from .main import main + +__all__ = ["main"] diff --git a/auditui/cli.py b/auditui/cli/main.py similarity index 91% rename from auditui/cli.py rename to auditui/cli/main.py index 02bddd8..2ce2257 100644 --- a/auditui/cli.py +++ b/auditui/cli/main.py @@ -1,5 +1,4 @@ -#!/usr/bin/env python3 -"""Auditui entrypoint.""" +"""CLI entrypoint: configure subcommand or authenticate and run the TUI.""" import argparse import sys @@ -12,7 +11,6 @@ from auditui.constants import AUTH_PATH def main() -> None: - """Authenticate and launch the app.""" parser = argparse.ArgumentParser(prog="auditui") parser.add_argument( "-v", @@ -52,7 +50,3 @@ def main() -> None: app = Auditui(auth=auth, client=client) app.run() - - -if __name__ == "__main__": - main() diff --git a/auditui/configure.py b/auditui/configure/__init__.py similarity index 78% rename from auditui/configure.py rename to auditui/configure/__init__.py index 74fcf4f..f8b15d2 100644 --- a/auditui/configure.py +++ b/auditui/configure/__init__.py @@ -1,4 +1,4 @@ -"""Configuration helpers for the Auditui app.""" +"""Interactive setup of Audible credentials; writes auth and config files.""" import json from getpass import getpass @@ -6,13 +6,13 @@ from pathlib import Path import audible -from .constants import AUTH_PATH, CONFIG_PATH +from ..constants import AUTH_PATH, CONFIG_PATH def configure( auth_path: Path = AUTH_PATH, ) -> tuple[audible.Authenticator, audible.Client]: - """Force re-authentication and save credentials.""" + """Prompt for email/password/locale, authenticate, and save auth.json and config.json.""" if auth_path.exists(): response = input( "Configuration already exists. Are you sure you want to overwrite it? (y/N): " @@ -26,7 +26,8 @@ def configure( email = input("\nEmail: ") password = getpass("Password: ") marketplace = input( - "Marketplace locale (default: US): ").strip().upper() or "US" + "Marketplace locale (default: US): " + ).strip().upper() or "US" authenticator = audible.Authenticator.from_login( username=email, password=password, locale=marketplace diff --git a/auditui/constants.py b/auditui/constants/__init__.py similarity index 98% rename from auditui/constants.py rename to auditui/constants/__init__.py index c3a461d..8471512 100644 --- a/auditui/constants.py +++ b/auditui/constants/__init__.py @@ -1,4 +1,4 @@ -"""Shared constants for the Auditui application.""" +"""Paths, API/config values, and CSS used across the application.""" from pathlib import Path diff --git a/auditui/downloads/__init__.py b/auditui/downloads/__init__.py new file mode 100644 index 0000000..c0ca62b --- /dev/null +++ b/auditui/downloads/__init__.py @@ -0,0 +1,5 @@ +"""Download and cache of Audible AAX files.""" + +from .manager import DownloadManager + +__all__ = ["DownloadManager"] diff --git a/auditui/downloads.py b/auditui/downloads/manager.py similarity index 88% rename from auditui/downloads.py rename to auditui/downloads/manager.py index 20d7c50..6cc3bca 100644 --- a/auditui/downloads.py +++ b/auditui/downloads/manager.py @@ -1,27 +1,25 @@ -"""Download helpers for Audible content.""" +"""Obtains AAX files from Audible (cache or download) and provides activation bytes.""" import re from pathlib import Path -from typing import Callable from urllib.parse import urlparse import audible import httpx from audible.activation_bytes import get_activation_bytes -from .constants import ( +from ..constants import ( CACHE_DIR, DEFAULT_CHUNK_SIZE, DEFAULT_CODEC, DOWNLOAD_URL, MIN_FILE_SIZE, ) - -StatusCallback = Callable[[str], None] +from ..types import StatusCallback class DownloadManager: - """Handle retrieval and download of Audible titles.""" + """Obtains AAX files from Audible (cache or download) and provides activation bytes.""" def __init__( self, @@ -35,16 +33,18 @@ class DownloadManager: self.cache_dir = cache_dir self.cache_dir.mkdir(parents=True, exist_ok=True) self.chunk_size = chunk_size - self._http_client = httpx.Client(auth=auth, timeout=30.0, follow_redirects=True) + self._http_client = httpx.Client( + auth=auth, timeout=30.0, follow_redirects=True) self._download_client = httpx.Client( - timeout=httpx.Timeout(connect=30.0, read=None, write=30.0, pool=30.0), + timeout=httpx.Timeout(connect=30.0, read=None, + write=30.0, pool=30.0), follow_redirects=True, ) def get_or_download( self, asin: str, notify: StatusCallback | None = None ) -> Path | None: - """Get local path of AAX file, downloading if missing.""" + """Return local path to AAX file; download and cache if not present.""" title = self._get_name_from_asin(asin) or asin safe_title = self._sanitize_filename(title) local_path = self.cache_dir / f"{safe_title}.aax" @@ -81,7 +81,7 @@ class DownloadManager: return local_path def get_activation_bytes(self) -> str | None: - """Get activation bytes as hex string.""" + """Return activation bytes as hex string for ffplay/ffmpeg.""" try: activation_bytes = get_activation_bytes(self.auth) if isinstance(activation_bytes, bytes): @@ -91,7 +91,7 @@ class DownloadManager: return None def get_cached_path(self, asin: str) -> Path | None: - """Get the cached file path for a book if it exists.""" + """Return path to cached AAX file if it exists and is valid size.""" title = self._get_name_from_asin(asin) or asin safe_title = self._sanitize_filename(title) local_path = self.cache_dir / f"{safe_title}.aax" @@ -100,11 +100,11 @@ class DownloadManager: return None def is_cached(self, asin: str) -> bool: - """Check if a book is already cached.""" + """Return True if the title is present in cache with valid size.""" 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.""" + """Delete the cached AAX file for the given ASIN. Returns True on success.""" cached_path = self.get_cached_path(asin) if not cached_path: if notify: @@ -151,7 +151,7 @@ class DownloadManager: codec: str = DEFAULT_CODEC, notify: StatusCallback | None = None, ) -> str | None: - """Get download link for book.""" + """Obtain CDN download URL for the given ASIN and codec.""" if self.auth.adp_token is None: if notify: notify("Missing ADP token (not authenticated?)") @@ -189,7 +189,7 @@ class DownloadManager: def _download_file( self, url: str, dest_path: Path, notify: StatusCallback | None = None ) -> Path | None: - """Download file from URL to destination.""" + """Stream download from URL to dest_path; reports progress via notify.""" try: with self._download_client.stream("GET", url) as response: response.raise_for_status() @@ -240,7 +240,7 @@ class DownloadManager: return None def close(self) -> None: - """Close the HTTP clients and release resources.""" + """Close internal HTTP clients. Safe to call multiple times.""" if hasattr(self, "_http_client"): self._http_client.close() if hasattr(self, "_download_client"): diff --git a/auditui/library/__init__.py b/auditui/library/__init__.py new file mode 100644 index 0000000..57d90d3 --- /dev/null +++ b/auditui/library/__init__.py @@ -0,0 +1,22 @@ +"""Fetching, formatting, and filtering of the user's Audible library.""" + +from .client import LibraryClient +from .search import build_search_text, filter_items +from .table import ( + create_progress_sort_key, + create_title_sort_key, + filter_unfinished_items, + format_item_as_row, + truncate_author_name, +) + +__all__ = [ + "LibraryClient", + "build_search_text", + "filter_items", + "create_progress_sort_key", + "create_title_sort_key", + "filter_unfinished_items", + "format_item_as_row", + "truncate_author_name", +] diff --git a/auditui/library.py b/auditui/library/client.py similarity index 82% rename from auditui/library.py rename to auditui/library/client.py index b3a00fd..58ae618 100644 --- a/auditui/library.py +++ b/auditui/library/client.py @@ -1,21 +1,19 @@ -"""Library helpers for fetching and formatting Audible data.""" +"""Client for the Audible library API.""" from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import Callable import audible - -ProgressCallback = Callable[[str], None] +from ..types import LibraryItem, StatusCallback class LibraryClient: - """Helper for interacting with the Audible library.""" + """Client for the Audible library API. Fetches items, extracts metadata, and updates positions.""" def __init__(self, client: audible.Client) -> None: self.client = client - def fetch_all_items(self, on_progress: ProgressCallback | None = None) -> list: + def fetch_all_items(self, on_progress: StatusCallback | None = None) -> list[LibraryItem]: """Fetch all library items from the API.""" response_groups = ( "contributors,media,product_attrs,product_desc,product_details," @@ -25,8 +23,8 @@ class LibraryClient: def _fetch_page( self, page: int, page_size: int, response_groups: str - ) -> tuple[int, list[dict]]: - """Fetch a single page of library items.""" + ) -> tuple[int, list[LibraryItem]]: + """Fetch a single page of library items from the API.""" library = self.client.get( path="library", num_results=page_size, @@ -37,9 +35,9 @@ class LibraryClient: return page, list(items) def _fetch_all_pages( - self, response_groups: str, on_progress: ProgressCallback | None = None - ) -> list: - """Fetch all pages of library items from the API using maximum parallel fetching.""" + self, response_groups: str, on_progress: StatusCallback | None = None + ) -> list[LibraryItem]: + """Fetch all pages of library items using parallel requests.""" library_response = None page_size = 200 @@ -63,7 +61,7 @@ class LibraryClient: if not first_page_items: return [] - all_items: list[dict] = list(first_page_items) + all_items: list[LibraryItem] = list(first_page_items) if on_progress: on_progress(f"Fetched page 1 ({len(first_page_items)} items)...") @@ -80,7 +78,7 @@ class LibraryClient: estimated_pages = 500 max_workers = 50 - page_results: dict[int, list[dict]] = {} + page_results: dict[int, list[LibraryItem]] = {} with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_page: dict = {} @@ -119,8 +117,8 @@ class LibraryClient: return all_items - def extract_title(self, item: dict) -> str: - """Extract title from library item.""" + def extract_title(self, item: LibraryItem) -> str: + """Return the book title from a library item.""" product = item.get("product", {}) return ( product.get("title") @@ -128,8 +126,8 @@ class LibraryClient: or product.get("asin", "Unknown Title") ) - def extract_authors(self, item: dict) -> str: - """Extract author names from library item.""" + def extract_authors(self, item: LibraryItem) -> str: + """Return comma-separated author names from a library item.""" product = item.get("product", {}) authors = product.get("authors") or product.get("contributors") or [] if not authors and "authors" in item: @@ -139,8 +137,8 @@ class LibraryClient: for a in authors if isinstance(a, dict)] return ", ".join(author_names) or "Unknown" - def extract_runtime_minutes(self, item: dict) -> int | None: - """Extract runtime in minutes from library item.""" + def extract_runtime_minutes(self, item: LibraryItem) -> int | None: + """Return runtime in minutes if present.""" product = item.get("product", {}) runtime_fields = [ "runtime_length_min", @@ -165,8 +163,8 @@ class LibraryClient: return int(runtime) return None - def extract_progress_info(self, item: dict) -> float | None: - """Extract progress percentage from library item.""" + def extract_progress_info(self, item: LibraryItem) -> float | None: + """Return progress percentage (0–100) if present.""" percent_complete = item.get("percent_complete") listening_status = item.get("listening_status", {}) @@ -175,13 +173,13 @@ class LibraryClient: return float(percent_complete) if percent_complete is not None else None - def extract_asin(self, item: dict) -> str | None: - """Extract ASIN from library item.""" + def extract_asin(self, item: LibraryItem) -> str | None: + """Return the ASIN for a library item.""" product = item.get("product", {}) return item.get("asin") or product.get("asin") - def is_finished(self, item: dict) -> bool: - """Check if a library item is finished.""" + def is_finished(self, item: LibraryItem) -> bool: + """Return True if the item is marked or inferred as finished.""" is_finished_flag = item.get("is_finished") percent_complete = item.get("percent_complete") listening_status = item.get("listening_status") @@ -194,8 +192,8 @@ class LibraryClient: percent_complete = listening_status.get("percent_complete", 0) return bool(is_finished_flag) or ( - isinstance(percent_complete, (int, float) - ) and percent_complete >= 100 + isinstance(percent_complete, (int, float)) + and percent_complete >= 100 ) def get_last_position(self, asin: str) -> float | None: @@ -227,7 +225,7 @@ class LibraryClient: return None def _get_content_reference(self, asin: str) -> dict | None: - """Get content reference data including ACR and version.""" + """Fetch content reference (ACR and version) for position updates.""" try: response = self.client.get( path=f"1.0/content/{asin}/metadata", @@ -242,7 +240,7 @@ class LibraryClient: return None def _update_position(self, asin: str, position_seconds: float) -> bool: - """Update the playback position for a book.""" + """Persist playback position to the API. Returns True on success.""" if position_seconds < 0: return False @@ -273,7 +271,7 @@ class LibraryClient: return False def save_last_position(self, asin: str, position_seconds: float) -> bool: - """Save the last playback position for a book.""" + """Save playback position to Audible. Returns True on success.""" if position_seconds <= 0: return False return self._update_position(asin, position_seconds) @@ -282,7 +280,7 @@ class LibraryClient: def format_duration( value: int | None, unit: str = "minutes", default_none: str | None = None ) -> str | None: - """Format duration value into a compact string.""" + """Format a duration value as e.g. 2h30m or 45m.""" if value is None or value <= 0: return default_none @@ -296,8 +294,8 @@ class LibraryClient: return f"{hours}h{minutes:02d}" if minutes else f"{hours}h" return f"{minutes}m" - def mark_as_finished(self, asin: str, item: dict | None = None) -> bool: - """Mark a book as finished by setting position to the end.""" + def mark_as_finished(self, asin: str, item: LibraryItem | None = None) -> bool: + """Mark a book as finished on Audible. Optionally mutates item in place.""" total_ms = self._get_runtime_ms(asin, item) if not total_ms: return False @@ -321,8 +319,8 @@ class LibraryClient: except Exception: return False - def _get_runtime_ms(self, asin: str, item: dict | None = None) -> int | None: - """Get total runtime in milliseconds.""" + def _get_runtime_ms(self, asin: str, item: LibraryItem | None = None) -> int | None: + """Return total runtime in ms from item or API.""" if item: runtime_min = self.extract_runtime_minutes(item) if runtime_min: @@ -340,7 +338,7 @@ class LibraryClient: return None def _get_acr(self, asin: str) -> str | None: - """Get ACR token needed for position updates.""" + """Fetch ACR token required for position and finish updates.""" try: response = self.client.post( path=f"1.0/content/{asin}/licenserequest", @@ -356,7 +354,7 @@ class LibraryClient: @staticmethod def format_time(seconds: float) -> str: - """Format seconds as HH:MM:SS or MM:SS.""" + """Format seconds as HH:MM:SS or MM:SS for display.""" total_seconds = int(seconds) hours = total_seconds // 3600 minutes = (total_seconds % 3600) // 60 diff --git a/auditui/search_utils.py b/auditui/library/search.py similarity index 57% rename from auditui/search_utils.py rename to auditui/library/search.py index 32e1b48..bdcf659 100644 --- a/auditui/search_utils.py +++ b/auditui/library/search.py @@ -1,14 +1,16 @@ -"""Search helpers for filtering library items.""" +"""Text search over library items for the filter feature.""" from __future__ import annotations from typing import Callable -from .library import LibraryClient +from ..types import LibraryItem + +from .client import LibraryClient -def build_search_text(item: dict, library_client: LibraryClient | None) -> str: - """Build a lowercase search string for an item.""" +def build_search_text(item: LibraryItem, library_client: LibraryClient | None) -> str: + """Build a single lowercase string from title and authors for matching.""" if library_client: title = library_client.extract_title(item) authors = library_client.extract_authors(item) @@ -23,11 +25,11 @@ def build_search_text(item: dict, library_client: LibraryClient | None) -> str: def filter_items( - items: list[dict], + items: list[LibraryItem], filter_text: str, - get_search_text: Callable[[dict], str], -) -> list[dict]: - """Filter items by a search string.""" + get_search_text: Callable[[LibraryItem], str], +) -> list[LibraryItem]: + """Return items whose search text contains filter_text (case-insensitive).""" if not filter_text: return items filter_lower = filter_text.lower() diff --git a/auditui/table_utils.py b/auditui/library/table.py similarity index 73% rename from auditui/table_utils.py rename to auditui/library/table.py index a4c4299..5f06af6 100644 --- a/auditui/table_utils.py +++ b/auditui/library/table.py @@ -1,20 +1,21 @@ -"""Utils for table operations.""" +"""Formatting and sorting of library items for the main table.""" import unicodedata from typing import TYPE_CHECKING, Callable -from .constants import ( +from ..constants import ( AUTHOR_NAME_DISPLAY_LENGTH, AUTHOR_NAME_MAX_LENGTH, PROGRESS_COLUMN_INDEX, ) +from ..types import LibraryItem if TYPE_CHECKING: - from .downloads import DownloadManager + from ..downloads import DownloadManager def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]: - """Create a sort key function for sorting by title.""" + """Return a (key_fn, reverse) pair for DataTable sort by title column.""" def title_key(row_values): title_cell = row_values[0] if isinstance(title_cell, str): @@ -26,7 +27,7 @@ def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]: 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.""" + """Return a (key_fn, reverse) pair for DataTable sort by progress column.""" def progress_key(row_values): progress_cell = row_values[progress_column_index] if isinstance(progress_cell, str): @@ -40,18 +41,14 @@ def create_progress_sort_key(progress_column_index: int = PROGRESS_COLUMN_INDEX, def truncate_author_name(author_names: str) -> str: - """Truncate author name if it exceeds maximum length.""" + """Truncate author string to display length with ellipsis if over max.""" 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 - """ +def format_item_as_row(item: LibraryItem, library_client, download_manager: "DownloadManager | None" = None) -> tuple[str, str, str, str, str]: + """Turn a library item into (title, author, runtime, progress, downloaded) for the table.""" title = library_client.extract_title(item) author_names = library_client.extract_authors(item) @@ -79,8 +76,8 @@ def format_item_as_row(item: dict, library_client, download_manager: "DownloadMa 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.""" +def filter_unfinished_items(items: list[LibraryItem], library_client) -> list[LibraryItem]: + """Return only items that are not marked as finished.""" return [ item for item in items if not library_client.is_finished(item) diff --git a/auditui/playback.py b/auditui/playback.py deleted file mode 100644 index 0ef69f5..0000000 --- a/auditui/playback.py +++ /dev/null @@ -1,513 +0,0 @@ -"""Playback control for Auditui.""" - -from __future__ import annotations - -import os -import shutil -import signal -import subprocess -import time -from pathlib import Path -from typing import Callable - -from .downloads import DownloadManager -from .library import LibraryClient -from .media_info import load_media_info - -StatusCallback = Callable[[str], None] - -MIN_SPEED = 0.5 -MAX_SPEED = 2.0 -SPEED_INCREMENT = 0.5 - - -class PlaybackController: - """Manage playback through ffplay.""" - - def __init__(self, notify: StatusCallback, library_client: LibraryClient | None = None) -> None: - self.notify = notify - self.library_client = library_client - self.playback_process: subprocess.Popen | None = None - self.is_playing = False - self.is_paused = False - self.current_file_path: Path | None = None - self.current_asin: str | None = None - self.playback_start_time: float | None = None - self.paused_duration: float = 0.0 - self.pause_start_time: float | None = None - self.total_duration: float | None = None - self.chapters: list[dict] = [] - self.seek_offset: float = 0.0 - self.activation_hex: str | None = None - self.last_save_time: float = 0.0 - self.position_save_interval: float = 30.0 - self.playback_speed: float = 1.0 - - def start( - self, - path: Path, - activation_hex: str | None = None, - status_callback: StatusCallback | None = None, - start_position: float = 0.0, - speed: float | None = None, - ) -> bool: - """Start playing a local file using ffplay.""" - notify = status_callback or self.notify - - if not shutil.which("ffplay"): - notify("ffplay not found. Please install ffmpeg") - return False - - if self.playback_process is not None: - self.stop() - - self.activation_hex = activation_hex - self.seek_offset = start_position - if speed is not None: - self.playback_speed = speed - - cmd = ["ffplay", "-nodisp", "-autoexit"] - if activation_hex: - cmd.extend(["-activation_bytes", activation_hex]) - if start_position > 0: - cmd.extend(["-ss", str(start_position)]) - if self.playback_speed != 1.0: - cmd.extend(["-af", f"atempo={self.playback_speed:.2f}"]) - cmd.append(str(path)) - - try: - self.playback_process = subprocess.Popen( - cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL - ) - - time.sleep(0.2) - if self.playback_process.poll() is not None: - return_code = self.playback_process.returncode - if return_code == 0 and start_position > 0 and self.total_duration: - if start_position >= self.total_duration - 5: - notify("Reached end of file") - self._reset_state() - return False - notify( - f"Playback process exited immediately (code: {return_code})") - self.playback_process = None - return False - - self.is_playing = True - self.is_paused = False - self.current_file_path = path - self.playback_start_time = time.time() - self.paused_duration = 0.0 - self.pause_start_time = None - duration, chapters = load_media_info(path, activation_hex) - self.total_duration = duration - self.chapters = chapters - notify(f"Playing: {path.name}") - return True - - except (OSError, ValueError, subprocess.SubprocessError) as exc: - notify(f"Error starting playback: {exc}") - return False - - def stop(self) -> None: - """Stop the current playback.""" - if self.playback_process is None: - return - - self._save_current_position() - - try: - if self.playback_process.poll() is None: - self.playback_process.terminate() - try: - self.playback_process.wait(timeout=2) - except subprocess.TimeoutExpired: - self.playback_process.kill() - self.playback_process.wait() - except (ProcessLookupError, ValueError): - pass - finally: - self._reset_state() - - def pause(self) -> None: - """Pause the current playback.""" - if not self._validate_playback_state(require_paused=False): - return - - self.pause_start_time = time.time() - self._send_signal(signal.SIGSTOP, "Paused", "pause") - - def resume(self) -> None: - """Resume the current playback.""" - if not self._validate_playback_state(require_paused=True): - return - - if self.pause_start_time is not None: - self.paused_duration += time.time() - self.pause_start_time - self.pause_start_time = None - self._send_signal(signal.SIGCONT, "Playing", "resume") - - def check_status(self) -> str | None: - """Check if playback process has finished and return status message.""" - if self.playback_process is None: - return None - - return_code = self.playback_process.poll() - if return_code is None: - return None - - finished_file = self.current_file_path - self._reset_state() - - if finished_file: - if return_code == 0: - return f"Finished: {finished_file.name}" - return f"Playback ended unexpectedly (code: {return_code}): {finished_file.name}" - return "Playback finished" - - def _reset_state(self) -> None: - """Reset all playback state.""" - self.playback_process = None - self.is_playing = False - self.is_paused = False - self.current_file_path = None - self.current_asin = None - self.playback_start_time = None - self.paused_duration = 0.0 - self.pause_start_time = None - self.total_duration = None - self.chapters = [] - self.seek_offset = 0.0 - self.activation_hex = None - self.last_save_time = 0.0 - self.playback_speed = 1.0 - - def _validate_playback_state(self, require_paused: bool) -> bool: - """Validate playback state before pause/resume operations.""" - if not (self.playback_process and self.is_playing): - return False - - if require_paused and not self.is_paused: - return False - if not require_paused and self.is_paused: - return False - - if not self.is_alive(): - self.stop() - self.notify("Playback process has ended") - return False - - return True - - def _send_signal(self, sig: signal.Signals, status_prefix: str, action: str) -> None: - """Send signal to playback process and update state.""" - if self.playback_process is None: - return - - try: - os.kill(self.playback_process.pid, sig) - self.is_paused = sig == signal.SIGSTOP - filename = self.current_file_path.name if self.current_file_path else None - message = f"{status_prefix}: {filename}" if filename else status_prefix - self.notify(message) - except ProcessLookupError: - self.stop() - self.notify("Process no longer exists") - except PermissionError: - self.notify(f"Permission denied: cannot {action} playback") - except (OSError, ValueError) as exc: - self.notify(f"Error {action}ing playback: {exc}") - - def is_alive(self) -> bool: - """Check if playback process is still running.""" - if self.playback_process is None: - return False - return self.playback_process.poll() is None - - def prepare_and_start( - self, - download_manager: DownloadManager, - asin: str, - status_callback: StatusCallback | None = None, - ) -> bool: - """Download file, get activation bytes, and start playback.""" - notify = status_callback or self.notify - - if not download_manager: - notify("Could not download file") - return False - - notify("Preparing playback...") - - local_path = download_manager.get_or_download(asin, notify) - if not local_path: - notify("Could not download file") - return False - - notify("Getting activation bytes...") - activation_hex = download_manager.get_activation_bytes() - if not activation_hex: - notify("Failed to get activation bytes") - return False - - start_position = 0.0 - if self.library_client: - try: - last_position = self.library_client.get_last_position(asin) - if last_position is not None and last_position > 0: - start_position = last_position - notify( - f"Resuming from {LibraryClient.format_time(start_position)}") - except (OSError, ValueError, KeyError): - pass - - notify(f"Starting playback of {local_path.name}...") - self.current_asin = asin - self.last_save_time = time.time() - return self.start(local_path, activation_hex, notify, start_position, self.playback_speed) - - def toggle_playback(self) -> bool: - """Toggle pause/resume state. Returns True if action was taken.""" - if not self.is_playing: - return False - - if not self.is_alive(): - self.stop() - self.notify("Playback has ended") - return False - - if self.is_paused: - self.resume() - else: - self.pause() - return True - - def _get_current_elapsed(self) -> float: - """Calculate current elapsed playback time.""" - if self.playback_start_time is None: - return 0.0 - - current_time = time.time() - if self.is_paused and self.pause_start_time is not None: - return (self.pause_start_time - self.playback_start_time) - self.paused_duration - - if self.pause_start_time is not None: - self.paused_duration += current_time - self.pause_start_time - self.pause_start_time = None - - return max(0.0, (current_time - self.playback_start_time) - self.paused_duration) - - def _stop_process(self) -> None: - """Stop the playback process without resetting state.""" - if not self.playback_process: - return - - try: - if self.playback_process.poll() is None: - self.playback_process.terminate() - try: - self.playback_process.wait(timeout=2) - except subprocess.TimeoutExpired: - self.playback_process.kill() - self.playback_process.wait() - except (ProcessLookupError, ValueError): - pass - - self.playback_process = None - self.is_playing = False - self.is_paused = False - self.playback_start_time = None - self.paused_duration = 0.0 - self.pause_start_time = None - - def _get_saved_state(self) -> dict: - """Get current playback state for saving.""" - return { - "file_path": self.current_file_path, - "asin": self.current_asin, - "activation": self.activation_hex, - "duration": self.total_duration, - "chapters": self.chapters.copy(), - "speed": self.playback_speed, - } - - def _restart_at_position( - self, new_position: float, new_speed: float | None = None, message: str | None = None - ) -> bool: - """Restart playback at a new position, optionally with new speed.""" - if not self.is_playing or not self.current_file_path: - return False - - was_paused = self.is_paused - saved_state = self._get_saved_state() - speed = new_speed if new_speed is not None else saved_state["speed"] - - self._stop_process() - time.sleep(0.2) - - if self.start(saved_state["file_path"], saved_state["activation"], self.notify, new_position, speed): - self.current_asin = saved_state["asin"] - self.total_duration = saved_state["duration"] - self.chapters = saved_state["chapters"] - if was_paused: - time.sleep(0.3) - self.pause() - if message: - self.notify(message) - return True - return False - - def _seek(self, seconds: float, direction: str) -> bool: - """Seek forward or backward by specified seconds.""" - elapsed = self._get_current_elapsed() - current_total_position = self.seek_offset + elapsed - - if direction == "forward": - new_position = current_total_position + seconds - if self.total_duration: - if new_position >= self.total_duration - 2: - self.notify("Already at end of file") - return False - new_position = min(new_position, self.total_duration - 2) - message = f"Skipped forward {int(seconds)}s" - else: - new_position = max(0.0, current_total_position - seconds) - message = f"Skipped backward {int(seconds)}s" - - return self._restart_at_position(new_position, message=message) - - def seek_forward(self, seconds: float = 30.0) -> bool: - """Seek forward by specified seconds. Returns True if action was taken.""" - return self._seek(seconds, "forward") - - def seek_backward(self, seconds: float = 30.0) -> bool: - """Seek backward by specified seconds. Returns True if action was taken.""" - return self._seek(seconds, "backward") - - 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: - return None - - elapsed = self._get_current_elapsed() - total_elapsed = self.seek_offset + elapsed - chapter_name, chapter_elapsed, chapter_total = self._get_current_chapter( - total_elapsed) - return (chapter_name, chapter_elapsed, chapter_total) - - def _get_current_chapter(self, elapsed: float) -> tuple[str, float, float]: - """Get current chapter info.""" - if not self.chapters: - return ("Unknown Chapter", elapsed, self.total_duration or 0.0) - - for chapter in self.chapters: - if chapter["start_time"] <= elapsed < chapter["end_time"]: - chapter_elapsed = elapsed - chapter["start_time"] - chapter_total = chapter["end_time"] - chapter["start_time"] - return (chapter["title"], chapter_elapsed, chapter_total) - - last_chapter = self.chapters[-1] - chapter_elapsed = max(0.0, elapsed - last_chapter["start_time"]) - chapter_total = last_chapter["end_time"] - last_chapter["start_time"] - return (last_chapter["title"], chapter_elapsed, chapter_total) - - def _get_current_chapter_index(self, elapsed: float) -> int | None: - """Get the index of the current chapter based on elapsed time.""" - if not self.chapters: - return None - - for idx, chapter in enumerate(self.chapters): - if chapter["start_time"] <= elapsed < chapter["end_time"]: - return idx - - return len(self.chapters) - 1 - - def seek_to_chapter(self, direction: str) -> bool: - """Seek to next or previous chapter.""" - if not self.is_playing or not self.current_file_path: - return False - - if not self.chapters: - self.notify("No chapter information available") - return False - - elapsed = self._get_current_elapsed() - current_total_position = self.seek_offset + elapsed - current_chapter_idx = self._get_current_chapter_index( - current_total_position) - - if current_chapter_idx is None: - self.notify("Could not determine current chapter") - return False - - if direction == "next": - if current_chapter_idx >= len(self.chapters) - 1: - self.notify("Already at last chapter") - return False - target_chapter = self.chapters[current_chapter_idx + 1] - new_position = target_chapter["start_time"] - message = f"Next chapter: {target_chapter['title']}" - else: - if current_chapter_idx <= 0: - self.notify("Already at first chapter") - return False - target_chapter = self.chapters[current_chapter_idx - 1] - new_position = target_chapter["start_time"] - message = f"Previous chapter: {target_chapter['title']}" - - return self._restart_at_position(new_position, message=message) - - def seek_to_next_chapter(self) -> bool: - """Seek to the next chapter. Returns True if action was taken.""" - return self.seek_to_chapter("next") - - def seek_to_previous_chapter(self) -> bool: - """Seek to the previous chapter. Returns True if action was taken.""" - return self.seek_to_chapter("previous") - - def _save_current_position(self) -> None: - """Save the current playback position to Audible.""" - if not (self.library_client and self.current_asin and self.is_playing): - return - - if self.playback_start_time is None: - return - - current_position = self.seek_offset + self._get_current_elapsed() - if current_position <= 0: - return - - try: - self.library_client.save_last_position( - self.current_asin, current_position) - except (OSError, ValueError, KeyError): - pass - - 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): - return - - current_time = time.time() - if current_time - self.last_save_time >= self.position_save_interval: - self._save_current_position() - self.last_save_time = current_time - - def _change_speed(self, delta: float) -> bool: - """Change playback speed by delta amount. Returns True if action was taken.""" - new_speed = max(MIN_SPEED, min(MAX_SPEED, self.playback_speed + delta)) - if new_speed == self.playback_speed: - return False - - elapsed = self._get_current_elapsed() - current_total_position = self.seek_offset + elapsed - - return self._restart_at_position(current_total_position, new_speed, f"Speed: {new_speed:.2f}x") - - def increase_speed(self) -> bool: - """Increase playback speed. Returns True if action was taken.""" - return self._change_speed(SPEED_INCREMENT) - - def decrease_speed(self) -> bool: - """Decrease playback speed. Returns True if action was taken.""" - return self._change_speed(-SPEED_INCREMENT) diff --git a/auditui/playback/__init__.py b/auditui/playback/__init__.py new file mode 100644 index 0000000..e997c39 --- /dev/null +++ b/auditui/playback/__init__.py @@ -0,0 +1,6 @@ +"""Playback control via ffplay and position sync with Audible.""" + +from .controller import PlaybackController +from .media_info import load_media_info + +__all__ = ["PlaybackController", "load_media_info"] diff --git a/auditui/playback/chapters.py b/auditui/playback/chapters.py new file mode 100644 index 0000000..2f79b01 --- /dev/null +++ b/auditui/playback/chapters.py @@ -0,0 +1,30 @@ +"""Chapter lookup by elapsed time.""" + + +def get_current_chapter( + elapsed: float, + chapters: list[dict], + total_duration: float | None, +) -> tuple[str, float, float]: + """Return (title, elapsed_in_chapter, chapter_duration) for the chapter at elapsed time.""" + if not chapters: + return ("Unknown Chapter", elapsed, total_duration or 0.0) + for chapter in chapters: + if chapter["start_time"] <= elapsed < chapter["end_time"]: + chapter_elapsed = elapsed - chapter["start_time"] + chapter_total = chapter["end_time"] - chapter["start_time"] + return (chapter["title"], chapter_elapsed, chapter_total) + last = chapters[-1] + chapter_elapsed = max(0.0, elapsed - last["start_time"]) + chapter_total = last["end_time"] - last["start_time"] + return (last["title"], chapter_elapsed, chapter_total) + + +def get_current_chapter_index(elapsed: float, chapters: list[dict]) -> int | None: + """Return the index of the chapter containing the given elapsed time.""" + if not chapters: + return None + for idx, chapter in enumerate(chapters): + if chapter["start_time"] <= elapsed < chapter["end_time"]: + return idx + return len(chapters) - 1 diff --git a/auditui/playback/constants.py b/auditui/playback/constants.py new file mode 100644 index 0000000..da63e35 --- /dev/null +++ b/auditui/playback/constants.py @@ -0,0 +1,5 @@ +"""Speed limits and increment for playback.""" + +MIN_SPEED = 0.5 +MAX_SPEED = 2.0 +SPEED_INCREMENT = 0.5 diff --git a/auditui/playback/controller.py b/auditui/playback/controller.py new file mode 100644 index 0000000..6ef91d6 --- /dev/null +++ b/auditui/playback/controller.py @@ -0,0 +1,14 @@ +"""Orchestrates ffplay process, position, chapters, seek, and speed; delegates to playback submodules.""" + +from __future__ import annotations + +from ..library import LibraryClient +from ..types import StatusCallback + +from .controller_seek_speed import ControllerSeekSpeedMixin +from .controller_lifecycle import ControllerLifecycleMixin +from .controller_state import ControllerStateMixin + + +class PlaybackController(ControllerSeekSpeedMixin, ControllerLifecycleMixin, ControllerStateMixin): + """Controls ffplay: start/stop, pause/resume, seek, speed, and saving position to Audible.""" diff --git a/auditui/playback/controller_lifecycle.py b/auditui/playback/controller_lifecycle.py new file mode 100644 index 0000000..23946ee --- /dev/null +++ b/auditui/playback/controller_lifecycle.py @@ -0,0 +1,183 @@ +"""Playback lifecycle: start, stop, pause, resume, prepare_and_start, restart at position.""" + +from __future__ import annotations + +import signal +import subprocess +import time +from pathlib import Path + +from ..downloads import DownloadManager +from ..library import LibraryClient +from ..types import StatusCallback + +from . import process as process_mod +from .media_info import load_media_info + +from .controller_state import ControllerStateMixin + + +class ControllerLifecycleMixin(ControllerStateMixin): + """Start/stop, pause/resume, and restart-at-position logic.""" + + def start( + self, + path: Path, + activation_hex: str | None = None, + status_callback: StatusCallback | None = None, + start_position: float = 0.0, + speed: float | None = None, + ) -> bool: + """Start ffplay for the given AAX path. Returns True if playback started.""" + notify = status_callback or self.notify + if not process_mod.is_ffplay_available(): + notify("ffplay not found. Please install ffmpeg") + return False + if self.playback_process is not None: + self.stop() + self.activation_hex = activation_hex + self.seek_offset = start_position + if speed is not None: + self.playback_speed = speed + cmd = process_mod.build_ffplay_cmd( + path, activation_hex, start_position, self.playback_speed + ) + try: + proc, return_code = process_mod.run_ffplay(cmd) + if proc is None: + if return_code == 0 and start_position > 0 and self.total_duration and start_position >= self.total_duration - 5: + notify("Reached end of file") + self._reset_state() + return False + notify( + f"Playback process exited immediately (code: {return_code})") + return False + self.playback_process = proc + self.is_playing = True + self.is_paused = False + self.current_file_path = path + self.playback_start_time = time.time() + self.paused_duration = 0.0 + self.pause_start_time = None + duration, chs = load_media_info(path, activation_hex) + self.total_duration = duration + self.chapters = chs + notify(f"Playing: {path.name}") + return True + except (OSError, ValueError, subprocess.SubprocessError) as exc: + notify(f"Error starting playback: {exc}") + return False + + def stop(self) -> None: + """Stop ffplay, save position to Audible, and reset state.""" + if self.playback_process is None: + return + self._save_current_position() + try: + process_mod.terminate_process(self.playback_process) + finally: + self._reset_state() + + def pause(self) -> None: + """Send SIGSTOP to ffplay and mark state as paused.""" + if not self._validate_playback_state(require_paused=False): + return + self.pause_start_time = time.time() + self._send_signal(signal.SIGSTOP, "Paused", "pause") + + def resume(self) -> None: + """Send SIGCONT to ffplay and clear paused state.""" + if not self._validate_playback_state(require_paused=True): + return + if self.pause_start_time is not None: + self.paused_duration += time.time() - self.pause_start_time + self.pause_start_time = None + self._send_signal(signal.SIGCONT, "Playing", "resume") + + def check_status(self) -> str | None: + """If the process has exited, return a status message and reset state; else None.""" + if self.playback_process is None: + return None + return_code = self.playback_process.poll() + if return_code is None: + return None + finished_file = self.current_file_path + self._reset_state() + if finished_file: + if return_code == 0: + return f"Finished: {finished_file.name}" + return f"Playback ended unexpectedly (code: {return_code}): {finished_file.name}" + return "Playback finished" + + def prepare_and_start( + self, + download_manager: DownloadManager, + asin: str, + status_callback: StatusCallback | None = None, + ) -> bool: + """Download AAX if needed, get activation bytes, then start playback. Returns True on success.""" + notify = status_callback or self.notify + if not download_manager: + notify("Could not download file") + return False + notify("Preparing playback...") + local_path = download_manager.get_or_download(asin, notify) + if not local_path: + notify("Could not download file") + return False + notify("Getting activation bytes...") + activation_hex = download_manager.get_activation_bytes() + if not activation_hex: + notify("Failed to get activation bytes") + return False + start_position = 0.0 + if self.library_client: + try: + last = self.library_client.get_last_position(asin) + if last is not None and last > 0: + start_position = last + notify( + f"Resuming from {LibraryClient.format_time(start_position)}") + except (OSError, ValueError, KeyError): + pass + notify(f"Starting playback of {local_path.name}...") + self.current_asin = asin + self.last_save_time = time.time() + return self.start(local_path, activation_hex, notify, start_position, self.playback_speed) + + def toggle_playback(self) -> bool: + """Toggle between pause and resume. Returns True if an action was performed.""" + if not self.is_playing: + return False + if not self.is_alive(): + self.stop() + self.notify("Playback has ended") + return False + if self.is_paused: + self.resume() + else: + self.pause() + return True + + def _restart_at_position( + self, new_position: float, new_speed: float | None = None, message: str | None = None + ) -> bool: + """Stop current process and start again at new_position; optionally set speed and notify.""" + if not self.is_playing or not self.current_file_path: + return False + was_paused = self.is_paused + saved = self._get_saved_state() + speed = new_speed if new_speed is not None else saved["speed"] + self._stop_process() + time.sleep(0.2) + if self.start(saved["file_path"], saved["activation"], self.notify, new_position, speed): + self.current_asin = saved["asin"] + self.total_duration = saved["duration"] + self.chapters = saved["chapters"] + if was_paused: + time.sleep(0.3) + self.pause() + if message: + self.notify(message) + return True + return False diff --git a/auditui/playback/controller_seek_speed.py b/auditui/playback/controller_seek_speed.py new file mode 100644 index 0000000..956e907 --- /dev/null +++ b/auditui/playback/controller_seek_speed.py @@ -0,0 +1,127 @@ +"""Seek, chapter, position save, and playback speed for the controller.""" + +from __future__ import annotations + +import time + +from . import chapters as chapters_mod +from . import seek as seek_mod +from .constants import MIN_SPEED, MAX_SPEED, SPEED_INCREMENT + +from .controller_lifecycle import ControllerLifecycleMixin + + +class ControllerSeekSpeedMixin(ControllerLifecycleMixin): + """Seek, chapter navigation, position persistence, and speed control.""" + + def _seek(self, seconds: float, direction: str) -> bool: + """Seek forward or backward by seconds via restart at new position. Returns True if done.""" + elapsed = self._get_current_elapsed() + current = self.seek_offset + elapsed + result = seek_mod.compute_seek_target( + current, self.total_duration, seconds, direction + ) + if result is None: + self.notify("Already at end of file") + return False + new_position, message = result + return self._restart_at_position(new_position, message=message) + + def seek_forward(self, seconds: float = 30.0) -> bool: + """Seek forward by the given seconds. Returns True if seek was performed.""" + return self._seek(seconds, "forward") + + def seek_backward(self, seconds: float = 30.0) -> bool: + """Seek backward by the given seconds. Returns True if seek was performed.""" + return self._seek(seconds, "backward") + + def get_current_progress(self) -> tuple[str, float, float] | None: + """Return (chapter_title, chapter_elapsed, chapter_total) for progress display, or None.""" + if not self.is_playing or self.playback_start_time is None: + return None + elapsed = self._get_current_elapsed() + total_elapsed = self.seek_offset + elapsed + return chapters_mod.get_current_chapter( + total_elapsed, self.chapters, self.total_duration + ) + + def seek_to_chapter(self, direction: str) -> bool: + """Seek to the next or previous chapter. direction is 'next' or 'previous'. Returns True if done.""" + if not self.is_playing or not self.current_file_path: + return False + if not self.chapters: + self.notify("No chapter information available") + return False + elapsed = self._get_current_elapsed() + current_total = self.seek_offset + elapsed + idx = chapters_mod.get_current_chapter_index( + current_total, self.chapters) + if idx is None: + self.notify("Could not determine current chapter") + return False + if direction == "next": + if idx >= len(self.chapters) - 1: + self.notify("Already at last chapter") + return False + target = self.chapters[idx + 1] + new_position = target["start_time"] + message = f"Next chapter: {target['title']}" + else: + if idx <= 0: + self.notify("Already at first chapter") + return False + target = self.chapters[idx - 1] + new_position = target["start_time"] + message = f"Previous chapter: {target['title']}" + return self._restart_at_position(new_position, message=message) + + def seek_to_next_chapter(self) -> bool: + """Seek to the next chapter. Returns True if seek was performed.""" + return self.seek_to_chapter("next") + + def seek_to_previous_chapter(self) -> bool: + """Seek to the previous chapter. Returns True if seek was performed.""" + return self.seek_to_chapter("previous") + + def _save_current_position(self) -> None: + """Persist current position to Audible via library_client.""" + if not (self.library_client and self.current_asin and self.is_playing): + return + if self.playback_start_time is None: + return + current_position = self.seek_offset + self._get_current_elapsed() + if current_position <= 0: + return + try: + self.library_client.save_last_position( + self.current_asin, current_position) + except (OSError, ValueError, KeyError): + pass + + def update_position_if_needed(self) -> None: + """Save position to Audible if the save interval has elapsed since last save.""" + if not (self.is_playing and self.library_client and self.current_asin): + return + current_time = time.time() + if current_time - self.last_save_time >= self.position_save_interval: + self._save_current_position() + self.last_save_time = current_time + + def _change_speed(self, delta: float) -> bool: + """Change speed by delta (clamped to MIN/MAX). Restarts playback. Returns True if changed.""" + new_speed = max(MIN_SPEED, min(MAX_SPEED, self.playback_speed + delta)) + if new_speed == self.playback_speed: + return False + elapsed = self._get_current_elapsed() + current_total = self.seek_offset + elapsed + return self._restart_at_position( + current_total, new_speed, f"Speed: {new_speed:.2f}x" + ) + + def increase_speed(self) -> bool: + """Increase playback speed. Returns True if speed was changed.""" + return self._change_speed(SPEED_INCREMENT) + + def decrease_speed(self) -> bool: + """Decrease playback speed. Returns True if speed was changed.""" + return self._change_speed(-SPEED_INCREMENT) diff --git a/auditui/playback/controller_state.py b/auditui/playback/controller_state.py new file mode 100644 index 0000000..86c2498 --- /dev/null +++ b/auditui/playback/controller_state.py @@ -0,0 +1,124 @@ +"""Playback state: init, reset, elapsed time, process validation and signals.""" + +from __future__ import annotations + +import signal +import time +from pathlib import Path + +from ..library import LibraryClient +from ..types import StatusCallback + +from . import elapsed as elapsed_mod +from . import process as process_mod + + +class ControllerStateMixin: + """State attributes and helpers for process/signal handling.""" + + def __init__(self, notify: StatusCallback, library_client: LibraryClient | None = None) -> None: + self.notify = notify + self.library_client = library_client + self.playback_process = None + self.is_playing = False + self.is_paused = False + self.current_file_path: Path | None = None + self.current_asin: str | None = None + self.playback_start_time: float | None = None + self.paused_duration: float = 0.0 + self.pause_start_time: float | None = None + self.total_duration: float | None = None + self.chapters: list[dict] = [] + self.seek_offset: float = 0.0 + self.activation_hex: str | None = None + self.last_save_time: float = 0.0 + self.position_save_interval: float = 30.0 + self.playback_speed: float = 1.0 + + def _reset_state(self) -> None: + """Clear playing/paused state and references to current file/asin.""" + self.playback_process = None + self.is_playing = False + self.is_paused = False + self.current_file_path = None + self.current_asin = None + self.playback_start_time = None + self.paused_duration = 0.0 + self.pause_start_time = None + self.total_duration = None + self.chapters = [] + self.seek_offset = 0.0 + self.activation_hex = None + self.last_save_time = 0.0 + self.playback_speed = 1.0 + + def _get_saved_state(self) -> dict: + """Return a snapshot of path, asin, activation, duration, chapters, speed for restart.""" + return { + "file_path": self.current_file_path, + "asin": self.current_asin, + "activation": self.activation_hex, + "duration": self.total_duration, + "chapters": self.chapters.copy(), + "speed": self.playback_speed, + } + + def _get_current_elapsed(self) -> float: + """Return elapsed seconds since start, accounting for pauses.""" + if self.pause_start_time is not None and not self.is_paused: + self.paused_duration += time.time() - self.pause_start_time + self.pause_start_time = None + return elapsed_mod.get_elapsed( + self.playback_start_time, + self.pause_start_time, + self.paused_duration, + self.is_paused, + ) + + def _stop_process(self) -> None: + """Terminate the process and clear playing state without saving position.""" + process_mod.terminate_process(self.playback_process) + self.playback_process = None + self.is_playing = False + self.is_paused = False + self.playback_start_time = None + self.paused_duration = 0.0 + self.pause_start_time = None + + def _validate_playback_state(self, require_paused: bool) -> bool: + """Return True if process is running and paused state matches require_paused.""" + if not (self.playback_process and self.is_playing): + return False + if require_paused and not self.is_paused: + return False + if not require_paused and self.is_paused: + return False + if not self.is_alive(): + self.stop() + self.notify("Playback process has ended") + return False + return True + + def _send_signal(self, sig: signal.Signals, status_prefix: str, action: str) -> None: + """Send sig to the process, update is_paused, and notify.""" + if self.playback_process is None: + return + try: + process_mod.send_signal(self.playback_process, sig) + self.is_paused = sig == signal.SIGSTOP + filename = self.current_file_path.name if self.current_file_path else None + msg = f"{status_prefix}: {filename}" if filename else status_prefix + self.notify(msg) + except ProcessLookupError: + self.stop() + self.notify("Process no longer exists") + except PermissionError: + self.notify(f"Permission denied: cannot {action} playback") + except (OSError, ValueError) as exc: + self.notify(f"Error {action}ing playback: {exc}") + + def is_alive(self) -> bool: + """Return True if the ffplay process is still running.""" + if self.playback_process is None: + return False + return self.playback_process.poll() is None diff --git a/auditui/playback/elapsed.py b/auditui/playback/elapsed.py new file mode 100644 index 0000000..9d7f0f1 --- /dev/null +++ b/auditui/playback/elapsed.py @@ -0,0 +1,23 @@ +"""Elapsed playback time accounting for pauses.""" + +import time + + +def get_elapsed( + playback_start_time: float | None, + pause_start_time: float | None, + paused_duration: float, + is_paused: bool, +) -> float: + """Return elapsed seconds since start, accounting for pauses.""" + if playback_start_time is None: + return 0.0 + current_time = time.time() + if is_paused and pause_start_time is not None: + return (pause_start_time - playback_start_time) - paused_duration + if pause_start_time is not None: + paused_duration += current_time - pause_start_time + return max( + 0.0, + (current_time - playback_start_time) - paused_duration, + ) diff --git a/auditui/media_info.py b/auditui/playback/media_info.py similarity index 88% rename from auditui/media_info.py rename to auditui/playback/media_info.py index edf33c1..f8fa9e4 100644 --- a/auditui/media_info.py +++ b/auditui/playback/media_info.py @@ -1,4 +1,4 @@ -"""Media information loading for Audible content.""" +"""Duration and chapter list for AAX files via ffprobe.""" import json import shutil @@ -7,7 +7,7 @@ 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.""" + """Return (total_duration_seconds, chapters) for the AAX file. Chapters have start_time, end_time, title.""" if not shutil.which("ffprobe"): return None, [] diff --git a/auditui/playback/process.py b/auditui/playback/process.py new file mode 100644 index 0000000..95c89d8 --- /dev/null +++ b/auditui/playback/process.py @@ -0,0 +1,68 @@ +"""FFplay process: build command, spawn, terminate, and send signals.""" + +import os +import shutil +import signal +import subprocess +import time +from pathlib import Path + + +def build_ffplay_cmd( + path: Path, + activation_hex: str | None, + start_position: float, + speed: float, +) -> list[str]: + """Build the ffplay command line for the given path and options.""" + cmd = ["ffplay", "-nodisp", "-autoexit"] + if activation_hex: + cmd.extend(["-activation_bytes", activation_hex]) + if start_position > 0: + cmd.extend(["-ss", str(start_position)]) + if speed != 1.0: + cmd.extend(["-af", f"atempo={speed:.2f}"]) + cmd.append(str(path)) + return cmd + + +def is_ffplay_available() -> bool: + """Return True if ffplay is on PATH.""" + return shutil.which("ffplay") is not None + + +def run_ffplay(cmd: list[str]) -> tuple[subprocess.Popen | None, int | None]: + """Spawn ffplay. Returns (proc, None) on success, (None, return_code) if process exited immediately, (None, None) if ffplay missing or spawn failed.""" + if not is_ffplay_available(): + return (None, None) + try: + proc = subprocess.Popen( + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + time.sleep(0.2) + if proc.poll() is not None: + return (None, proc.returncode) + return (proc, None) + except (OSError, ValueError, subprocess.SubprocessError): + return (None, None) + + +def terminate_process(proc: subprocess.Popen | None) -> None: + """Terminate the process; kill if it does not exit within timeout.""" + if proc is None: + return + try: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=2) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + except (ProcessLookupError, ValueError): + pass + + +def send_signal(proc: subprocess.Popen, sig: signal.Signals) -> None: + """Send sig to the process. May raise ProcessLookupError, PermissionError, OSError.""" + os.kill(proc.pid, sig) diff --git a/auditui/playback/seek.py b/auditui/playback/seek.py new file mode 100644 index 0000000..f4c7f7f --- /dev/null +++ b/auditui/playback/seek.py @@ -0,0 +1,19 @@ +"""Seek target computation: new position and message from direction and seconds.""" + + +def compute_seek_target( + current_position: float, + total_duration: float | None, + seconds: float, + direction: str, +) -> tuple[float, str] | None: + """Return (new_position, message) for a seek, or None if seek is invalid (e.g. at end).""" + if direction == "forward": + new_position = current_position + seconds + if total_duration is not None: + if new_position >= total_duration - 2: + return None + new_position = min(new_position, total_duration - 2) + return (new_position, f"Skipped forward {int(seconds)}s") + new_position = max(0.0, current_position - seconds) + return (new_position, f"Skipped backward {int(seconds)}s") diff --git a/auditui/stats/__init__.py b/auditui/stats/__init__.py new file mode 100644 index 0000000..3bcd881 --- /dev/null +++ b/auditui/stats/__init__.py @@ -0,0 +1,5 @@ +"""Listening and account statistics for the stats screen.""" + +from .aggregator import StatsAggregator + +__all__ = ["StatsAggregator"] diff --git a/auditui/stats/account.py b/auditui/stats/account.py new file mode 100644 index 0000000..77079a6 --- /dev/null +++ b/auditui/stats/account.py @@ -0,0 +1,71 @@ +"""Account and subscription data from the API.""" + +from typing import Any + + +def get_account_info(client: Any) -> dict: + if not client: + return {} + account_info = {} + endpoints = [ + ( + "1.0/account/information", + "subscription_details,plan_summary,subscription_details_payment_instrument,delinquency_status,customer_benefits,customer_segments,directed_ids", + ), + ( + "1.0/customer/information", + "subscription_details_premium,subscription_details_rodizio,customer_segment,subscription_details_channels,migration_details", + ), + ( + "1.0/customer/status", + "benefits_status,member_giving_status,prime_benefits_status,prospect_benefits_status", + ), + ] + for endpoint, response_groups in endpoints: + try: + response = client.get(endpoint, response_groups=response_groups) + account_info.update(response) + except Exception: + pass + return account_info + + +def get_subscription_details(account_info: dict) -> dict: + paths = [ + ["customer_details", "subscription", "subscription_details"], + ["customer", "customer_details", "subscription", "subscription_details"], + ["subscription_details"], + ["subscription", "subscription_details"], + ] + for path in paths: + data = account_info + for key in path: + if isinstance(data, dict): + data = data.get(key) + else: + break + if isinstance(data, list) and data: + return data[0] + return {} + + +def get_country(auth: Any) -> str: + if not auth: + return "Unknown" + try: + locale_obj = getattr(auth, "locale", None) + if not locale_obj: + return "Unknown" + if hasattr(locale_obj, "country_code"): + return locale_obj.country_code.upper() + if hasattr(locale_obj, "domain"): + return locale_obj.domain.upper() + if isinstance(locale_obj, str): + return ( + locale_obj.split("_")[-1].upper() + if "_" in locale_obj + else locale_obj.upper() + ) + return str(locale_obj) + except Exception: + return "Unknown" diff --git a/auditui/stats/aggregator.py b/auditui/stats/aggregator.py new file mode 100644 index 0000000..508da84 --- /dev/null +++ b/auditui/stats/aggregator.py @@ -0,0 +1,85 @@ +"""Aggregates listening time, account info, and subscription data for display.""" + +from datetime import date +from typing import Any + +from ..types import LibraryItem + +from . import account as account_mod +from . import email as email_mod +from . import format as format_mod +from . import listening as listening_mod + + +class StatsAggregator: + """Builds a list of (label, value) stats from the API, auth, and library.""" + + def __init__( + self, + client: Any, + auth: Any, + library_client: Any, + all_items: list[LibraryItem], + ) -> None: + self.client = client + self.auth = auth + self.library_client = library_client + self.all_items = all_items + + def get_stats(self, today: date | None = None) -> list[tuple[str, str]]: + if not self.client: + return [] + today = today or date.today() + signup_year = listening_mod.get_signup_year(self.client) + month_time = listening_mod.get_listening_time( + self.client, 1, today.strftime("%Y-%m") + ) + year_time = listening_mod.get_listening_time( + self.client, 12, today.strftime("%Y-01") + ) + finished_count = listening_mod.get_finished_books_count( + self.library_client, self.all_items or [] + ) + total_books = len(self.all_items) if self.all_items else 0 + email = email_mod.resolve_email( + self.auth, + self.client, + get_account_info=lambda: account_mod.get_account_info(self.client), + ) + country = account_mod.get_country(self.auth) + subscription_name = "Unknown" + subscription_price = "Unknown" + next_bill_date = "Unknown" + account_info = account_mod.get_account_info(self.client) + if account_info: + subscription_data = account_mod.get_subscription_details( + account_info) + if subscription_data: + if name := subscription_data.get("name"): + subscription_name = name + if bill_date := subscription_data.get("next_bill_date"): + next_bill_date = format_mod.format_date(bill_date) + if bill_amount := subscription_data.get("next_bill_amount", {}): + amount = bill_amount.get("currency_value") + currency = bill_amount.get("currency_code", "EUR") + if amount is not None: + subscription_price = f"{amount} {currency}" + stats_items = [] + if email != "Unknown": + stats_items.append(("Email", email)) + stats_items.append(("Country Store", country)) + stats_items.append( + ("Signup Year", str(signup_year) if signup_year > 0 else "Unknown") + ) + if next_bill_date != "Unknown": + stats_items.append(("Next Credit", next_bill_date)) + stats_items.append(("Next Bill", next_bill_date)) + if subscription_name != "Unknown": + stats_items.append(("Subscription", subscription_name)) + if subscription_price != "Unknown": + stats_items.append(("Price", subscription_price)) + stats_items.append(("This Month", format_mod.format_time(month_time))) + stats_items.append(("This Year", format_mod.format_time(year_time))) + stats_items.append( + ("Books Finished", f"{finished_count} / {total_books}")) + return stats_items diff --git a/auditui/stats/email.py b/auditui/stats/email.py new file mode 100644 index 0000000..e57d4ae --- /dev/null +++ b/auditui/stats/email.py @@ -0,0 +1,155 @@ +"""Email resolution from auth, config, auth file, and account API.""" + +import json +from pathlib import Path +from typing import Any, Callable + +from ..constants import AUTH_PATH, CONFIG_PATH + + +def find_email_in_data(data: Any) -> str | None: + if data is None: + return None + stack = [data] + while stack: + current = stack.pop() + if isinstance(current, dict): + stack.extend(current.values()) + elif isinstance(current, list): + stack.extend(current) + elif isinstance(current, str): + if "@" in current: + local, _, domain = current.partition("@") + if local and "." in domain: + return current + return None + + +def first_email(*values: str | None) -> str | None: + for value in values: + if value and value != "Unknown": + return value + return None + + +def get_email_from_auth(auth: Any) -> str | None: + if not auth: + return None + try: + email = first_email( + getattr(auth, "username", None), + getattr(auth, "login", None), + getattr(auth, "email", None), + ) + if email: + return email + except Exception: + return None + try: + customer_info = getattr(auth, "customer_info", None) + if isinstance(customer_info, dict): + email = first_email( + customer_info.get("email"), + customer_info.get("email_address"), + customer_info.get("primary_email"), + ) + if email: + return email + except Exception: + return None + try: + data = getattr(auth, "data", None) + if isinstance(data, dict): + return first_email( + data.get("username"), + data.get("email"), + data.get("login"), + data.get("user_email"), + ) + except Exception: + return None + return None + + +def get_email_from_config(config_path: Path | None = None) -> str | None: + path = config_path or CONFIG_PATH + try: + if path.exists(): + with open(path, "r", encoding="utf-8") as f: + config = json.load(f) + return first_email( + config.get("email"), + config.get("username"), + config.get("login"), + ) + except Exception: + return None + return None + + +def get_email_from_auth_file(auth_path: Path | None = None) -> str | None: + path = auth_path or AUTH_PATH + try: + if path.exists(): + with open(path, "r", encoding="utf-8") as f: + auth_file_data = json.load(f) + return first_email( + auth_file_data.get("username"), + auth_file_data.get("email"), + auth_file_data.get("login"), + auth_file_data.get("user_email"), + ) + except Exception: + return None + return None + + +def get_email_from_account_info(account_info: dict) -> str | None: + email = first_email( + account_info.get("email"), + account_info.get("customer_email"), + account_info.get("username"), + ) + if email: + return email + customer_info = account_info.get("customer_info", {}) + if isinstance(customer_info, dict): + return first_email( + customer_info.get("email"), + customer_info.get("email_address"), + customer_info.get("primary_email"), + ) + return None + + +def resolve_email( + auth: Any, + client: Any, + config_path: Path | None = None, + auth_path: Path | None = None, + get_account_info: Callable[[], dict] | None = None, +) -> str: + config_path = config_path or CONFIG_PATH + auth_path = auth_path or AUTH_PATH + for getter in ( + lambda: get_email_from_auth(auth), + lambda: get_email_from_config(config_path), + lambda: get_email_from_auth_file(auth_path), + lambda: get_email_from_account_info( + get_account_info()) if get_account_info else None, + ): + email = getter() + if email: + return email + auth_data = None + if auth: + try: + auth_data = getattr(auth, "data", None) + except Exception: + pass + account_info = get_account_info() if get_account_info else {} + for candidate in (auth_data, account_info): + email = find_email_in_data(candidate) + if email: + return email + return "Unknown" diff --git a/auditui/stats/format.py b/auditui/stats/format.py new file mode 100644 index 0000000..9e20fc0 --- /dev/null +++ b/auditui/stats/format.py @@ -0,0 +1,22 @@ +"""Time and date formatting for stats display.""" + +from datetime import datetime + + +def format_time(milliseconds: int) -> str: + total_seconds = int(milliseconds) // 1000 + hours, remainder = divmod(total_seconds, 3600) + minutes, _ = divmod(remainder, 60) + if hours > 0: + return f"{hours}h{minutes:02d}" + return f"{minutes}m" + + +def format_date(date_str: str | None) -> str: + if not date_str: + return "Unknown" + try: + dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d") + except ValueError: + return date_str diff --git a/auditui/stats/listening.py b/auditui/stats/listening.py new file mode 100644 index 0000000..e4743fe --- /dev/null +++ b/auditui/stats/listening.py @@ -0,0 +1,75 @@ +"""Listening time and signup year from stats API; finished books count.""" + +from datetime import date +from typing import Any + +from ..types import LibraryItem + + +def has_activity(stats: dict) -> bool: + monthly_stats = stats.get("aggregated_monthly_listening_stats", []) + return bool( + monthly_stats and any(s.get("aggregated_sum", 0) + > 0 for s in monthly_stats) + ) + + +def get_listening_time(client: Any, duration: int, start_date: str) -> int: + if not client: + return 0 + try: + stats = client.get( + "1.0/stats/aggregates", + monthly_listening_interval_duration=str(duration), + monthly_listening_interval_start_date=start_date, + store="Audible", + ) + monthly_stats = stats.get("aggregated_monthly_listening_stats", []) + return sum(s.get("aggregated_sum", 0) for s in monthly_stats) + except Exception: + return 0 + + +def get_signup_year(client: Any) -> int: + if not client: + return 0 + current_year = date.today().year + try: + stats = client.get( + "1.0/stats/aggregates", + monthly_listening_interval_duration="12", + monthly_listening_interval_start_date=f"{current_year}-01", + store="Audible", + ) + if not has_activity(stats): + return 0 + except Exception: + return 0 + left, right = 1995, current_year + earliest_year = current_year + while left <= right: + middle = (left + right) // 2 + try: + stats = client.get( + "1.0/stats/aggregates", + monthly_listening_interval_duration="12", + monthly_listening_interval_start_date=f"{middle}-01", + store="Audible", + ) + has_activity_ = has_activity(stats) + except Exception: + has_activity_ = False + if has_activity_: + earliest_year = middle + right = middle - 1 + else: + left = middle + 1 + return earliest_year + + +def get_finished_books_count( + library_client: Any, all_items: list[LibraryItem] +) -> int: + if not library_client or not all_items: + return 0 + return sum(1 for item in all_items if library_client.is_finished(item)) diff --git a/auditui/types/__init__.py b/auditui/types/__init__.py new file mode 100644 index 0000000..5f628ee --- /dev/null +++ b/auditui/types/__init__.py @@ -0,0 +1,8 @@ +"""Shared type aliases for the Audible TUI.""" + +from __future__ import annotations + +from typing import Callable + +LibraryItem = dict +StatusCallback = Callable[[str], None] diff --git a/auditui/ui.py b/auditui/ui.py deleted file mode 100644 index 570d4e5..0000000 --- a/auditui/ui.py +++ /dev/null @@ -1,570 +0,0 @@ -"""UI components for the Auditui application.""" - -import json -from datetime import date, datetime -from typing import Any, Callable, Protocol, TYPE_CHECKING, cast - -from textual.app import ComposeResult -from textual.containers import Container, Vertical -from textual.screen import ModalScreen -from textual.timer import Timer -from textual.widgets import Input, Label, ListItem, ListView, Static - -from .constants import AUTH_PATH, CONFIG_PATH - -if TYPE_CHECKING: - from textual.binding import Binding - - -class _AppContext(Protocol): - BINDINGS: list[tuple[str, str, str]] - client: Any - auth: Any - library_client: Any - all_items: list[dict] - - -KEY_DISPLAY_MAP = { - "ctrl+": "^", - "left": "←", - "right": "→", - "up": "↑", - "down": "↓", - "space": "Space", - "enter": "Enter", -} - -KEY_COLOR = "#f9e2af" -DESC_COLOR = "#cdd6f4" - - -class AppContextMixin: - """Mixin to provide a typed app accessor.""" - - def _app(self) -> _AppContext: - return cast(_AppContext, cast(Any, self).app) - - -class HelpScreen(AppContextMixin, ModalScreen): - """Help screen displaying all available keybindings.""" - - BINDINGS = [("escape", "dismiss", "Close"), ("?", "dismiss", "Close")] - - @staticmethod - def _format_key_display(key: str) -> str: - """Format a key string for display with symbols.""" - result = key - for old, new in KEY_DISPLAY_MAP.items(): - result = result.replace(old, new) - return result - - @staticmethod - def _parse_binding(binding: "Binding | tuple[str, str, str]") -> tuple[str, str]: - """Extract key and description from a binding.""" - if isinstance(binding, tuple): - return binding[0], binding[2] - return binding.key, binding.description - - def _make_item(self, binding: "Binding | tuple[str, str, str]") -> ListItem: - """Create a ListItem for a single binding.""" - key, description = self._parse_binding(binding) - key_display = self._format_key_display(key) - text = f"[bold {KEY_COLOR}]{key_display:>16}[/] [{DESC_COLOR}]{description:<25}[/]" - return ListItem(Label(text)) - - def compose(self) -> ComposeResult: - app = self._app() - bindings = list(app.BINDINGS) - - with Container(id="help_container"): - yield Static("Keybindings", id="help_title") - with Vertical(id="help_content"): - yield ListView( - *[self._make_item(b) for b in bindings], - classes="help_list", - ) - yield Static( - f"Press [bold {KEY_COLOR}]?[/] or [bold {KEY_COLOR}]Escape[/] to close", - id="help_footer", - ) - - async def action_dismiss(self, result: Any | None = None) -> None: - await self.dismiss(result) - - -class StatsScreen(AppContextMixin, ModalScreen): - """Stats screen displaying listening statistics.""" - - BINDINGS = [("escape", "dismiss", "Close"), ("s", "dismiss", "Close")] - - def _format_time(self, milliseconds: int) -> str: - """Format milliseconds as hours and minutes.""" - total_seconds = int(milliseconds) // 1000 - hours, remainder = divmod(total_seconds, 3600) - minutes, _ = divmod(remainder, 60) - if hours > 0: - return f"{hours}h{minutes:02d}" - return f"{minutes}m" - - def _format_date(self, date_str: str | None) -> str: - """Format ISO date string for display.""" - if not date_str: - return "Unknown" - try: - dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) - return dt.strftime("%Y-%m-%d") - except ValueError: - return date_str - - def _get_signup_year(self) -> int: - """Get signup year using binary search on listening activity.""" - app = self._app() - if not app.client: - return 0 - - current_year = date.today().year - - try: - stats = app.client.get( - "1.0/stats/aggregates", - monthly_listening_interval_duration="12", - monthly_listening_interval_start_date=f"{current_year}-01", - store="Audible", - ) - if not self._has_activity(stats): - return 0 - except Exception: - return 0 - - left, right = 1995, current_year - earliest_year = current_year - - while left <= right: - middle = (left + right) // 2 - try: - stats = app.client.get( - "1.0/stats/aggregates", - monthly_listening_interval_duration="12", - monthly_listening_interval_start_date=f"{middle}-01", - store="Audible", - ) - has_activity = self._has_activity(stats) - except Exception: - has_activity = False - - if has_activity: - earliest_year = middle - right = middle - 1 - else: - left = middle + 1 - - return earliest_year - - @staticmethod - def _has_activity(stats: dict) -> bool: - """Check if stats contain any listening activity.""" - monthly_stats = stats.get("aggregated_monthly_listening_stats", []) - return bool( - monthly_stats and any(s.get("aggregated_sum", 0) > 0 for s in monthly_stats) - ) - - def _get_listening_time(self, duration: int, start_date: str) -> int: - """Get listening time in milliseconds for a given period.""" - app = self._app() - if not app.client: - return 0 - - try: - stats = app.client.get( - "1.0/stats/aggregates", - monthly_listening_interval_duration=str(duration), - monthly_listening_interval_start_date=start_date, - store="Audible", - ) - monthly_stats = stats.get("aggregated_monthly_listening_stats", []) - return sum(s.get("aggregated_sum", 0) for s in monthly_stats) - except Exception: - return 0 - - def _get_finished_books_count(self) -> int: - """Get count of finished books from library.""" - app = self._app() - if not app.library_client or not app.all_items: - return 0 - return sum(1 for item in app.all_items if app.library_client.is_finished(item)) - - def _get_account_info(self) -> dict: - """Get account information including subscription details.""" - app = self._app() - if not app.client: - return {} - - account_info = {} - endpoints = [ - ( - "1.0/account/information", - "subscription_details,plan_summary,subscription_details_payment_instrument,delinquency_status,customer_benefits,customer_segments,directed_ids", - ), - ( - "1.0/customer/information", - "subscription_details_premium,subscription_details_rodizio,customer_segment,subscription_details_channels,migration_details", - ), - ( - "1.0/customer/status", - "benefits_status,member_giving_status,prime_benefits_status,prospect_benefits_status", - ), - ] - - for endpoint, response_groups in endpoints: - try: - response = app.client.get(endpoint, response_groups=response_groups) - account_info.update(response) - except Exception: - pass - - return account_info - - def _get_email(self) -> str: - """Get email from auth, config, or API.""" - app = self._app() - for getter in ( - self._get_email_from_auth, - self._get_email_from_config, - self._get_email_from_auth_file, - self._get_email_from_account_info, - ): - email = getter(app) - if email: - return email - - auth_data: dict[str, Any] | None = None - if app.auth: - try: - auth_data = getattr(app.auth, "data", None) - except Exception: - auth_data = None - - account_info = self._get_account_info() if app.client else None - for candidate in (auth_data, account_info): - email = self._find_email_in_data(candidate) - if email: - return email - - return "Unknown" - - def _get_email_from_auth(self, app: _AppContext) -> str | None: - """Extract email from the authenticator if available.""" - if not app.auth: - return None - try: - email = self._first_email( - getattr(app.auth, "username", None), - getattr(app.auth, "login", None), - getattr(app.auth, "email", None), - ) - if email: - return email - except Exception: - return None - - try: - customer_info = getattr(app.auth, "customer_info", None) - if isinstance(customer_info, dict): - email = self._first_email( - customer_info.get("email"), - customer_info.get("email_address"), - customer_info.get("primary_email"), - ) - if email: - return email - except Exception: - return None - - try: - data = getattr(app.auth, "data", None) - if isinstance(data, dict): - return self._first_email( - data.get("username"), - data.get("email"), - data.get("login"), - data.get("user_email"), - ) - except Exception: - return None - return None - - def _get_email_from_config(self, app: _AppContext) -> str | None: - """Extract email from the config file.""" - try: - if CONFIG_PATH.exists(): - with open(CONFIG_PATH, "r", encoding="utf-8") as f: - config = json.load(f) - return self._first_email( - config.get("email"), - config.get("username"), - config.get("login"), - ) - except Exception: - return None - return None - - def _get_email_from_auth_file(self, app: _AppContext) -> str | None: - """Extract email from the auth file.""" - try: - if AUTH_PATH.exists(): - with open(AUTH_PATH, "r", encoding="utf-8") as f: - auth_file_data = json.load(f) - return self._first_email( - auth_file_data.get("username"), - auth_file_data.get("email"), - auth_file_data.get("login"), - auth_file_data.get("user_email"), - ) - except Exception: - return None - return None - - def _get_email_from_account_info(self, app: _AppContext) -> str | None: - """Extract email from the account info API.""" - if not app.client: - return None - try: - account_info = self._get_account_info() - if account_info: - email = self._first_email( - account_info.get("email"), - account_info.get("customer_email"), - account_info.get("username"), - ) - if email: - return email - customer_info = account_info.get("customer_info", {}) - if isinstance(customer_info, dict): - return self._first_email( - customer_info.get("email"), - customer_info.get("email_address"), - customer_info.get("primary_email"), - ) - except Exception: - return None - return None - - def _first_email(self, *values: str | None) -> str | None: - """Return the first non-empty, non-Unknown email value.""" - for value in values: - if value and value != "Unknown": - return value - return None - - def _find_email_in_data(self, data: Any) -> str | None: - """Search nested data for an email-like value.""" - if data is None: - return None - - stack: list[Any] = [data] - while stack: - current = stack.pop() - if isinstance(current, dict): - stack.extend(current.values()) - elif isinstance(current, list): - stack.extend(current) - elif isinstance(current, str): - if "@" in current: - local, _, domain = current.partition("@") - if local and "." in domain: - return current - return None - - def _get_subscription_details(self, account_info: dict) -> dict: - """Extract subscription details from nested API response.""" - paths = [ - ["customer_details", "subscription", "subscription_details"], - ["customer", "customer_details", "subscription", "subscription_details"], - ["subscription_details"], - ["subscription", "subscription_details"], - ] - for path in paths: - data: Any = account_info - for key in path: - if isinstance(data, dict): - data = data.get(key) - else: - break - if isinstance(data, list) and data: - return data[0] - return {} - - def _get_country(self) -> str: - """Get country from authenticator locale.""" - app = self._app() - if not app.auth: - return "Unknown" - - try: - locale_obj = getattr(app.auth, "locale", None) - if not locale_obj: - return "Unknown" - - if hasattr(locale_obj, "country_code"): - return locale_obj.country_code.upper() - if hasattr(locale_obj, "domain"): - return locale_obj.domain.upper() - if isinstance(locale_obj, str): - return ( - locale_obj.split("_")[-1].upper() - if "_" in locale_obj - else locale_obj.upper() - ) - return str(locale_obj) - except Exception: - return "Unknown" - - def _make_stat_item(self, label: str, value: str) -> ListItem: - """Create a ListItem for a stat.""" - text = f"[bold {KEY_COLOR}]{label:>16}[/] [{DESC_COLOR}]{value:<25}[/]" - return ListItem(Label(text)) - - def compose(self) -> ComposeResult: - app = self._app() - if not app.client: - with Container(id="help_container"): - yield Static("Statistics", id="help_title") - yield Static( - "Not authenticated. Please restart and authenticate.", - classes="help_row", - ) - yield Static( - f"Press [bold {KEY_COLOR}]s[/] or [bold {KEY_COLOR}]Escape[/] to close", - id="help_footer", - ) - return - - today = date.today() - stats_items = self._build_stats_items(today) - - with Container(id="help_container"): - yield Static("Statistics", id="help_title") - with Vertical(id="help_content"): - yield ListView( - *[ - self._make_stat_item(label, value) - for label, value in stats_items - ], - classes="help_list", - ) - yield Static( - f"Press [bold {KEY_COLOR}]s[/] or [bold {KEY_COLOR}]Escape[/] to close", - id="help_footer", - ) - - def _build_stats_items(self, today: date) -> list[tuple[str, str]]: - """Build the list of stats items to display.""" - signup_year = self._get_signup_year() - month_time = self._get_listening_time(1, today.strftime("%Y-%m")) - year_time = self._get_listening_time(12, today.strftime("%Y-01")) - finished_count = self._get_finished_books_count() - app = self._app() - total_books = len(app.all_items) if app.all_items else 0 - - email = self._get_email() - country = self._get_country() - - subscription_name = "Unknown" - subscription_price = "Unknown" - next_bill_date = "Unknown" - - account_info = self._get_account_info() - if account_info: - subscription_data = self._get_subscription_details(account_info) - if subscription_data: - if name := subscription_data.get("name"): - subscription_name = name - - if bill_date := subscription_data.get("next_bill_date"): - next_bill_date = self._format_date(bill_date) - - if bill_amount := subscription_data.get("next_bill_amount", {}): - amount = bill_amount.get("currency_value") - currency = bill_amount.get("currency_code", "EUR") - if amount is not None: - subscription_price = f"{amount} {currency}" - - stats_items = [] - if email != "Unknown": - stats_items.append(("Email", email)) - stats_items.append(("Country Store", country)) - stats_items.append( - ("Signup Year", str(signup_year) if signup_year > 0 else "Unknown") - ) - if next_bill_date != "Unknown": - stats_items.append(("Next Credit", next_bill_date)) - stats_items.append(("Next Bill", next_bill_date)) - if subscription_name != "Unknown": - stats_items.append(("Subscription", subscription_name)) - if subscription_price != "Unknown": - stats_items.append(("Price", subscription_price)) - stats_items.append(("This Month", self._format_time(month_time))) - stats_items.append(("This Year", self._format_time(year_time))) - stats_items.append(("Books Finished", f"{finished_count} / {total_books}")) - - return stats_items - - async def action_dismiss(self, result: Any | None = None) -> None: - await self.dismiss(result) - - -class FilterScreen(ModalScreen[str]): - """Filter screen for searching the library.""" - - BINDINGS = [("escape", "cancel", "Cancel")] - - def __init__( - self, - initial_filter: str = "", - on_change: Callable[[str], None] | None = None, - debounce_seconds: float = 0.2, - ) -> None: - super().__init__() - self._initial_filter = initial_filter - self._on_change = on_change - self._debounce_seconds = debounce_seconds - self._debounce_timer: Timer | None = None - - def compose(self) -> ComposeResult: - with Container(id="filter_container"): - yield Static("Filter Library", id="filter_title") - yield Input( - value=self._initial_filter, - placeholder="Type to filter by title or author...", - id="filter_input", - ) - yield Static( - f"Press [bold {KEY_COLOR}]Enter[/] to apply, " - f"[bold {KEY_COLOR}]Escape[/] to clear", - id="filter_footer", - ) - - def on_mount(self) -> None: - self.query_one("#filter_input", Input).focus() - - def on_input_submitted(self, event: Input.Submitted) -> None: - self.dismiss(event.value) - - def on_input_changed(self, event: Input.Changed) -> None: - callback = self._on_change - if not callback: - return - if self._debounce_timer: - self._debounce_timer.stop() - value = event.value - self._debounce_timer = self.set_timer( - self._debounce_seconds, - lambda: callback(value), - ) - - def action_cancel(self) -> None: - self.dismiss("") - - def on_unmount(self) -> None: - if self._debounce_timer: - self._debounce_timer.stop() diff --git a/auditui/ui/__init__.py b/auditui/ui/__init__.py new file mode 100644 index 0000000..b4e3ec1 --- /dev/null +++ b/auditui/ui/__init__.py @@ -0,0 +1,7 @@ +"""Modal screens: help keybindings, filter input, and listening/account statistics.""" + +from .filter_screen import FilterScreen +from .help_screen import HelpScreen +from .stats_screen import StatsScreen + +__all__ = ["FilterScreen", "HelpScreen", "StatsScreen"] diff --git a/auditui/ui/common.py b/auditui/ui/common.py new file mode 100644 index 0000000..63c0dd3 --- /dev/null +++ b/auditui/ui/common.py @@ -0,0 +1,30 @@ +"""Shared protocol, constants, and mixin for modal screens that need app context.""" + +from typing import Any, Protocol, cast + + +class _AppContext(Protocol): + BINDINGS: list[tuple[str, str, str]] + client: Any + auth: Any + library_client: Any + all_items: list[dict] + + +KEY_DISPLAY_MAP = { + "ctrl+": "^", + "left": "←", + "right": "→", + "up": "↑", + "down": "↓", + "space": "Space", + "enter": "Enter", +} + +KEY_COLOR = "#f9e2af" +DESC_COLOR = "#cdd6f4" + + +class AppContextMixin: + def _app(self) -> _AppContext: + return cast(_AppContext, cast(Any, self).app) diff --git a/auditui/ui/filter_screen.py b/auditui/ui/filter_screen.py new file mode 100644 index 0000000..ae234a2 --- /dev/null +++ b/auditui/ui/filter_screen.py @@ -0,0 +1,66 @@ +"""Filter modal with input; returns filter string on dismiss.""" + +from typing import Callable + +from textual.app import ComposeResult +from textual.containers import Container +from textual.screen import ModalScreen +from textual.timer import Timer +from textual.widgets import Input, Static + +from .common import KEY_COLOR + + +class FilterScreen(ModalScreen[str]): + BINDINGS = [("escape", "cancel", "Cancel")] + + def __init__( + self, + initial_filter: str = "", + on_change: Callable[[str], None] | None = None, + debounce_seconds: float = 0.2, + ) -> None: + super().__init__() + self._initial_filter = initial_filter + self._on_change = on_change + self._debounce_seconds = debounce_seconds + self._debounce_timer: Timer | None = None + + def compose(self) -> ComposeResult: + with Container(id="filter_container"): + yield Static("Filter Library", id="filter_title") + yield Input( + value=self._initial_filter, + placeholder="Type to filter by title or author...", + id="filter_input", + ) + yield Static( + f"Press [bold {KEY_COLOR}]Enter[/] to apply, " + f"[bold {KEY_COLOR}]Escape[/] to clear", + id="filter_footer", + ) + + def on_mount(self) -> None: + self.query_one("#filter_input", Input).focus() + + def on_input_submitted(self, event: Input.Submitted) -> None: + self.dismiss(event.value) + + def on_input_changed(self, event: Input.Changed) -> None: + callback = self._on_change + if not callback: + return + if self._debounce_timer: + self._debounce_timer.stop() + value = event.value + self._debounce_timer = self.set_timer( + self._debounce_seconds, + lambda: callback(value), + ) + + def action_cancel(self) -> None: + self.dismiss("") + + def on_unmount(self) -> None: + if self._debounce_timer: + self._debounce_timer.stop() diff --git a/auditui/ui/help_screen.py b/auditui/ui/help_screen.py new file mode 100644 index 0000000..114a160 --- /dev/null +++ b/auditui/ui/help_screen.py @@ -0,0 +1,54 @@ +"""Help modal that lists keybindings from the main app.""" + +from typing import TYPE_CHECKING, Any + +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.screen import ModalScreen +from textual.widgets import Label, ListItem, ListView, Static + +from .common import KEY_COLOR, KEY_DISPLAY_MAP, DESC_COLOR, AppContextMixin + +if TYPE_CHECKING: + from textual.binding import Binding + + +class HelpScreen(AppContextMixin, ModalScreen): + BINDINGS = [("escape", "dismiss", "Close"), ("?", "dismiss", "Close")] + + @staticmethod + def _format_key_display(key: str) -> str: + result = key + for old, new in KEY_DISPLAY_MAP.items(): + result = result.replace(old, new) + return result + + @staticmethod + def _parse_binding(binding: "Binding | tuple[str, str, str]") -> tuple[str, str]: + if isinstance(binding, tuple): + return binding[0], binding[2] + return binding.key, binding.description + + def _make_item(self, binding: "Binding | tuple[str, str, str]") -> ListItem: + key, description = self._parse_binding(binding) + key_display = self._format_key_display(key) + text = f"[bold {KEY_COLOR}]{key_display:>16}[/] [{DESC_COLOR}]{description:<25}[/]" + return ListItem(Label(text)) + + def compose(self) -> ComposeResult: + app = self._app() + bindings = list(app.BINDINGS) + with Container(id="help_container"): + yield Static("Keybindings", id="help_title") + with Vertical(id="help_content"): + yield ListView( + *[self._make_item(b) for b in bindings], + classes="help_list", + ) + yield Static( + f"Press [bold {KEY_COLOR}]?[/] or [bold {KEY_COLOR}]Escape[/] to close", + id="help_footer", + ) + + async def action_dismiss(self, result: Any | None = None) -> None: + await self.dismiss(result) diff --git a/auditui/ui/stats_screen.py b/auditui/ui/stats_screen.py new file mode 100644 index 0000000..5b11756 --- /dev/null +++ b/auditui/ui/stats_screen.py @@ -0,0 +1,54 @@ +"""Statistics modal showing listening time and account info via StatsAggregator.""" + +from datetime import date +from typing import Any + +from textual.app import ComposeResult +from textual.containers import Container, Vertical +from textual.screen import ModalScreen +from textual.widgets import Label, ListItem, ListView, Static + +from ..stats import StatsAggregator +from .common import KEY_COLOR, DESC_COLOR, AppContextMixin + + +class StatsScreen(AppContextMixin, ModalScreen): + BINDINGS = [("escape", "dismiss", "Close"), ("s", "dismiss", "Close")] + + def _make_stat_item(self, label: str, value: str) -> ListItem: + text = f"[bold {KEY_COLOR}]{label:>16}[/] [{DESC_COLOR}]{value:<25}[/]" + return ListItem(Label(text)) + + def compose(self) -> ComposeResult: + app = self._app() + if not app.client: + with Container(id="help_container"): + yield Static("Statistics", id="help_title") + yield Static( + "Not authenticated. Please restart and authenticate.", + classes="help_row", + ) + yield Static( + f"Press [bold {KEY_COLOR}]s[/] or [bold {KEY_COLOR}]Escape[/] to close", + id="help_footer", + ) + return + aggregator = StatsAggregator( + app.client, app.auth, app.library_client, app.all_items or [] + ) + stats_items = aggregator.get_stats(date.today()) + with Container(id="help_container"): + yield Static("Statistics", id="help_title") + with Vertical(id="help_content"): + yield ListView( + *[self._make_stat_item(label, value) + for label, value in stats_items], + classes="help_list", + ) + yield Static( + f"Press [bold {KEY_COLOR}]s[/] or [bold {KEY_COLOR}]Escape[/] to close", + id="help_footer", + ) + + async def action_dismiss(self, result: Any | None = None) -> None: + await self.dismiss(result) diff --git a/tests/test_app_filter.py b/tests/test_app_filter.py index ff53abc..a3bf9e5 100644 --- a/tests/test_app_filter.py +++ b/tests/test_app_filter.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from typing import Any, cast from auditui.app import Auditui -from auditui.search_utils import build_search_text, filter_items +from auditui.library import build_search_text, filter_items class StubLibrary: diff --git a/tests/test_downloads.py b/tests/test_downloads.py index 8f07f36..55e3231 100644 --- a/tests/test_downloads.py +++ b/tests/test_downloads.py @@ -2,24 +2,24 @@ from pathlib import Path import pytest -from auditui import downloads +from auditui.downloads import DownloadManager from auditui.constants import MIN_FILE_SIZE def test_sanitize_filename() -> None: - dm = downloads.DownloadManager.__new__(downloads.DownloadManager) + dm = DownloadManager.__new__(DownloadManager) assert dm._sanitize_filename('a<>:"/\\|?*b') == "a_________b" def test_validate_download_url() -> None: - dm = downloads.DownloadManager.__new__(downloads.DownloadManager) + dm = DownloadManager.__new__(DownloadManager) assert dm._validate_download_url("https://example.com/file") is True assert dm._validate_download_url("http://example.com/file") is True assert dm._validate_download_url("ftp://example.com/file") is False def test_cached_path_and_remove(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - dm = downloads.DownloadManager.__new__(downloads.DownloadManager) + dm = DownloadManager.__new__(DownloadManager) dm.cache_dir = tmp_path monkeypatch.setattr(dm, "_get_name_from_asin", lambda asin: "My Book") @@ -37,7 +37,7 @@ def test_cached_path_and_remove(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) def test_cached_path_ignores_small_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - dm = downloads.DownloadManager.__new__(downloads.DownloadManager) + dm = DownloadManager.__new__(DownloadManager) dm.cache_dir = tmp_path monkeypatch.setattr(dm, "_get_name_from_asin", lambda asin: "My Book") diff --git a/tests/test_table_utils.py b/tests/test_table_utils.py index 3aec19b..2399d86 100644 --- a/tests/test_table_utils.py +++ b/tests/test_table_utils.py @@ -1,7 +1,13 @@ from dataclasses import dataclass from typing import Any, cast -from auditui import table_utils +from auditui.constants import AUTHOR_NAME_MAX_LENGTH +from auditui.library import ( + create_progress_sort_key, + create_title_sort_key, + format_item_as_row, + truncate_author_name, +) class StubLibrary: @@ -37,22 +43,22 @@ class StubDownloads: def test_create_title_sort_key_normalizes_accents() -> None: - key_fn, _ = table_utils.create_title_sort_key() + key_fn, _ = create_title_sort_key() assert key_fn(["École"]) == "ecole" assert key_fn(["Zoo"]) == "zoo" def test_create_progress_sort_key_parses_percent() -> None: - key_fn, _ = table_utils.create_progress_sort_key() + key_fn, _ = create_progress_sort_key() assert key_fn(["0", "0", "0", "42.5%"]) == 42.5 assert key_fn(["0", "0", "0", "bad"]) == 0.0 def test_truncate_author_name() -> None: - long_name = "A" * (table_utils.AUTHOR_NAME_MAX_LENGTH + 5) - truncated = table_utils.truncate_author_name(long_name) + long_name = "A" * (AUTHOR_NAME_MAX_LENGTH + 5) + truncated = truncate_author_name(long_name) assert truncated.endswith("...") - assert len(truncated) <= table_utils.AUTHOR_NAME_MAX_LENGTH + assert len(truncated) <= AUTHOR_NAME_MAX_LENGTH def test_format_item_as_row_with_downloaded() -> None: @@ -65,7 +71,7 @@ def test_format_item_as_row_with_downloaded() -> None: "percent": 12.34, "asin": "ASIN123", } - title, author, runtime, progress, downloaded = table_utils.format_item_as_row( + title, author, runtime, progress, downloaded = format_item_as_row( item, library, cast(Any, downloads) ) assert title == "Title" @@ -79,5 +85,5 @@ def test_format_item_as_row_zero_progress() -> None: library = StubLibrary() item = {"title": "Title", "authors": "Author", "minutes": 30, "percent": 0.0} - _, _, _, progress, _ = table_utils.format_item_as_row(item, library, None) + _, _, _, progress, _ = format_item_as_row(item, library, None) assert progress == "0%" diff --git a/tests/test_ui_email.py b/tests/test_ui_email.py index bb9e76f..ac187fd 100644 --- a/tests/test_ui_email.py +++ b/tests/test_ui_email.py @@ -1,63 +1,35 @@ import json -from dataclasses import dataclass, field from pathlib import Path -import pytest - -from auditui import ui - - -@dataclass(slots=True) -class DummyApp: - client: object | None = None - auth: object | None = None - library_client: object | None = None - all_items: list[dict] = field(default_factory=list) - BINDINGS: list[tuple[str, str, str]] = field(default_factory=list) - - -@pytest.fixture -def dummy_app() -> DummyApp: - return DummyApp() +from auditui.stats.email import ( + find_email_in_data, + get_email_from_auth, + get_email_from_auth_file, + get_email_from_config, +) def test_find_email_in_data() -> None: - screen = ui.StatsScreen() data = {"a": {"b": ["nope", "user@example.com"]}} - assert screen._find_email_in_data(data) == "user@example.com" + assert find_email_in_data(data) == "user@example.com" -def test_get_email_from_config( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dummy_app: DummyApp -) -> None: - screen = ui.StatsScreen() +def test_get_email_from_config(tmp_path: Path) -> None: config_path = tmp_path / "config.json" config_path.write_text(json.dumps({"email": "config@example.com"})) - monkeypatch.setattr(ui, "CONFIG_PATH", config_path) - - email = screen._get_email_from_config(dummy_app) - assert email == "config@example.com" + assert get_email_from_config(config_path) == "config@example.com" -def test_get_email_from_auth_file( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dummy_app: DummyApp -) -> None: - screen = ui.StatsScreen() +def test_get_email_from_auth_file(tmp_path: Path) -> None: auth_path = tmp_path / "auth.json" auth_path.write_text(json.dumps({"email": "auth@example.com"})) - monkeypatch.setattr(ui, "AUTH_PATH", auth_path) - - email = screen._get_email_from_auth_file(dummy_app) - assert email == "auth@example.com" + assert get_email_from_auth_file(auth_path) == "auth@example.com" -def test_get_email_from_auth(dummy_app: DummyApp) -> None: - screen = ui.StatsScreen() - +def test_get_email_from_auth() -> None: class Auth: username = "user@example.com" login = None email = None - dummy_app.auth = Auth() - assert screen._get_email_from_auth(dummy_app) == "user@example.com" + assert get_email_from_auth(Auth()) == "user@example.com" From 184585bed0f5ae06abecfdf330242c98e5474a37 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 02:51:35 +0100 Subject: [PATCH 02/35] fix(interface): app compose, table query, and Space pause --- auditui/app/__init__.py | 5 ++++- auditui/app/actions.py | 2 +- auditui/app/layout.py | 9 ++++++--- auditui/app/table.py | 6 +++--- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/auditui/app/__init__.py b/auditui/app/__init__.py index e1c10b4..6dd5087 100644 --- a/auditui/app/__init__.py +++ b/auditui/app/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations -from textual.app import App +from textual.app import App, ComposeResult from ..constants import TABLE_CSS @@ -22,6 +22,9 @@ class Auditui(App, AppProgressMixin, AppActionsMixin, AppLibraryMixin, AppTableM BINDINGS = BINDINGS CSS = TABLE_CSS + def compose(self) -> ComposeResult: + yield from AppLayoutMixin.compose(self) + def __init__(self, auth=None, client=None) -> None: super().__init__() init_auditui_state(self, auth, client) diff --git a/auditui/app/actions.py b/auditui/app/actions.py index 284c61c..bbbe517 100644 --- a/auditui/app/actions.py +++ b/auditui/app/actions.py @@ -15,7 +15,7 @@ class AppActionsMixin: self.update_status( "Not authenticated. Please restart and authenticate.") return None - table = self.query_one(DataTable) + table = self.query_one("#library_table", DataTable) if table.row_count == 0: self.update_status("No books available") return None diff --git a/auditui/app/layout.py b/auditui/app/layout.py index 4348a0c..2f012fd 100644 --- a/auditui/app/layout.py +++ b/auditui/app/layout.py @@ -20,7 +20,7 @@ class AppLayoutMixin: id="top_bar", ) yield Static("Loading...", id="status") - table = DataTable() + table = DataTable(id="library_table") table.zebra_stripes = True table.cursor_type = "row" yield table @@ -32,7 +32,10 @@ class AppLayoutMixin: def on_mount(self) -> None: self.theme = "textual-dark" - table = self.query_one(DataTable) + self.call_after_refresh(self._init_table_and_intervals) + + def _init_table_and_intervals(self) -> None: + table = self.query_one("#library_table", DataTable) for column_name, _ratio in TABLE_COLUMN_DEFS: table.add_column(column_name) self.call_after_refresh(lambda: self._apply_column_widths(table)) @@ -58,7 +61,7 @@ class AppLayoutMixin: def on_resize(self, event: Resize) -> None: del event try: - table = self.query_one(DataTable) + table = self.query_one("#library_table", DataTable) except Exception: return self.call_after_refresh(lambda: self._apply_column_widths(table)) diff --git a/auditui/app/table.py b/auditui/app/table.py index 2faeb92..eeac42a 100644 --- a/auditui/app/table.py +++ b/auditui/app/table.py @@ -15,7 +15,7 @@ from textual.widgets import DataTable, Static class AppTableMixin: def _populate_table(self, items: list[LibraryItem]) -> None: - table = self.query_one(DataTable) + table = self.query_one("#library_table", DataTable) table.clear() if not items or not self.library_client: @@ -51,14 +51,14 @@ class AppTableMixin: self._refresh_filtered_view() def action_sort(self) -> None: - table = self.query_one(DataTable) + table = self.query_one("#library_table", DataTable) if table.row_count > 0 and self.title_column_key: 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: - table = self.query_one(DataTable) + table = self.query_one("#library_table", DataTable) if table.row_count > 0: self.progress_sort_reverse = not self.progress_sort_reverse progress_key, reverse = create_progress_sort_key( From 7f5e3266beb011ac68304fa29d176a44c7a4ffc6 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 02:59:04 +0100 Subject: [PATCH 03/35] fix: ensure Space always reaches toggle_playback action in TUI --- auditui/app/bindings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/auditui/app/bindings.py b/auditui/app/bindings.py index 2aa04a4..207b7f4 100644 --- a/auditui/app/bindings.py +++ b/auditui/app/bindings.py @@ -1,5 +1,7 @@ """Key bindings for the main app.""" +from textual.binding import Binding + BINDINGS = [ ("?", "show_help", "Help"), ("s", "show_stats", "Stats"), @@ -10,7 +12,7 @@ BINDINGS = [ ("a", "show_all", "All/Unfinished"), ("r", "refresh", "Refresh"), ("enter", "play_selected", "Play"), - ("space", "toggle_playback", "Pause/Resume"), + Binding("space", "toggle_playback", "Pause/Resume", priority=True), ("left", "seek_backward", "-30s"), ("right", "seek_forward", "+30s"), ("ctrl+left", "previous_chapter", "Previous chapter"), From bd2bd43e7f40a7f01a1b889cfaf99b6b30cf8e03 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:01:51 +0100 Subject: [PATCH 04/35] test: add regression coverage for app key bindings and space priority --- tests/test_app_bindings.py | 58 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/test_app_bindings.py diff --git a/tests/test_app_bindings.py b/tests/test_app_bindings.py new file mode 100644 index 0000000..28090e7 --- /dev/null +++ b/tests/test_app_bindings.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import TypeAlias + +from auditui.app.bindings import BINDINGS +from textual.binding import Binding + + +BindingTuple: TypeAlias = tuple[str, str, str] +NormalizedBinding: TypeAlias = tuple[str, str, str, bool] + +EXPECTED_BINDINGS: tuple[NormalizedBinding, ...] = ( + ("?", "show_help", "Help", False), + ("s", "show_stats", "Stats", False), + ("/", "filter", "Filter", False), + ("escape", "clear_filter", "Clear filter", False), + ("n", "sort", "Sort by name", False), + ("p", "sort_by_progress", "Sort by progress", False), + ("a", "show_all", "All/Unfinished", False), + ("r", "refresh", "Refresh", False), + ("enter", "play_selected", "Play", False), + ("space", "toggle_playback", "Pause/Resume", True), + ("left", "seek_backward", "-30s", False), + ("right", "seek_forward", "+30s", False), + ("ctrl+left", "previous_chapter", "Previous chapter", False), + ("ctrl+right", "next_chapter", "Next chapter", False), + ("up", "increase_speed", "Increase speed", False), + ("down", "decrease_speed", "Decrease speed", False), + ("f", "toggle_finished", "Mark finished", False), + ("d", "toggle_download", "Download/Delete", False), + ("q", "quit", "Quit", False), +) + + +def _normalize_binding( + binding: Binding | BindingTuple, +) -> NormalizedBinding: + """Return key, action, description, and priority for a binding item.""" + if isinstance(binding, Binding): + return (binding.key, binding.action, binding.description, binding.priority) + key, action, description = binding + return (key, action, description, False) + + +def _normalize_bindings() -> list[NormalizedBinding]: + """Normalize all declared bindings to a comparable shape.""" + return [_normalize_binding(binding) for binding in BINDINGS] + + +def test_bindings_match_expected_shortcuts() -> None: + """Ensure the app ships with the expected binding set and actions.""" + assert _normalize_bindings() == list(EXPECTED_BINDINGS) + + +def test_binding_keys_are_unique() -> None: + """Ensure each key is defined once to avoid ambiguous key dispatch.""" + keys = [binding[0] for binding in _normalize_bindings()] + assert len(keys) == len(set(keys)) From cd99960f2f34a1660cd8fbf904160e92300f3578 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:17:33 +0100 Subject: [PATCH 05/35] test: reorganize core suite into explicit domain files --- .../test_app_bindings_contract.py} | 18 ++- .../test_app_search_cache_logic.py} | 18 ++- tests/conftest.py | 16 ++- ...t_download_manager_cache_and_validation.py | 57 ++++++++ .../test_download_manager_workflow.py | 85 ++++++++++++ .../library/test_library_client_extractors.py | 111 +++++++++++++++ .../test_library_client_progress_updates.py | 103 ++++++++++++++ tests/library/test_library_search_filters.py | 34 +++++ .../library/test_library_table_formatting.py | 99 +++++++++++++ tests/stats/test_stats_account_data.py | 54 ++++++++ tests/stats/test_stats_aggregator_output.py | 67 +++++++++ tests/stats/test_stats_email_resolution.py | 64 +++++++++ tests/stats/test_stats_formatting.py | 16 +++ tests/stats/test_stats_listening_metrics.py | 64 +++++++++ tests/test_downloads.py | 48 ------- tests/test_library.py | 131 ------------------ tests/test_table_utils.py | 89 ------------ tests/test_ui_email.py | 35 ----- .../test_ui_filter_screen_behavior.py} | 22 ++- 19 files changed, 805 insertions(+), 326 deletions(-) rename tests/{test_app_bindings.py => app/test_app_bindings_contract.py} (75%) rename tests/{test_app_filter.py => app/test_app_search_cache_logic.py} (67%) create mode 100644 tests/downloads/test_download_manager_cache_and_validation.py create mode 100644 tests/downloads/test_download_manager_workflow.py create mode 100644 tests/library/test_library_client_extractors.py create mode 100644 tests/library/test_library_client_progress_updates.py create mode 100644 tests/library/test_library_search_filters.py create mode 100644 tests/library/test_library_table_formatting.py create mode 100644 tests/stats/test_stats_account_data.py create mode 100644 tests/stats/test_stats_aggregator_output.py create mode 100644 tests/stats/test_stats_email_resolution.py create mode 100644 tests/stats/test_stats_formatting.py create mode 100644 tests/stats/test_stats_listening_metrics.py delete mode 100644 tests/test_downloads.py delete mode 100644 tests/test_library.py delete mode 100644 tests/test_table_utils.py delete mode 100644 tests/test_ui_email.py rename tests/{test_ui_filter.py => ui/test_ui_filter_screen_behavior.py} (59%) diff --git a/tests/test_app_bindings.py b/tests/app/test_app_bindings_contract.py similarity index 75% rename from tests/test_app_bindings.py rename to tests/app/test_app_bindings_contract.py index 28090e7..1d28c51 100644 --- a/tests/test_app_bindings.py +++ b/tests/app/test_app_bindings_contract.py @@ -32,27 +32,25 @@ EXPECTED_BINDINGS: tuple[NormalizedBinding, ...] = ( ) -def _normalize_binding( - binding: Binding | BindingTuple, -) -> NormalizedBinding: - """Return key, action, description, and priority for a binding item.""" +def _normalize_binding(binding: Binding | BindingTuple) -> NormalizedBinding: + """Return key, action, description, and priority from one binding item.""" if isinstance(binding, Binding): return (binding.key, binding.action, binding.description, binding.priority) key, action, description = binding return (key, action, description, False) -def _normalize_bindings() -> list[NormalizedBinding]: - """Normalize all declared bindings to a comparable shape.""" +def _all_bindings() -> list[NormalizedBinding]: + """Normalize all app bindings into a stable comparable structure.""" return [_normalize_binding(binding) for binding in BINDINGS] def test_bindings_match_expected_shortcuts() -> None: - """Ensure the app ships with the expected binding set and actions.""" - assert _normalize_bindings() == list(EXPECTED_BINDINGS) + """Ensure the shipped shortcut list stays stable and explicit.""" + assert _all_bindings() == list(EXPECTED_BINDINGS) def test_binding_keys_are_unique() -> None: - """Ensure each key is defined once to avoid ambiguous key dispatch.""" - keys = [binding[0] for binding in _normalize_bindings()] + """Ensure each key is defined only once to avoid dispatch ambiguity.""" + keys = [binding[0] for binding in _all_bindings()] assert len(keys) == len(set(keys)) diff --git a/tests/test_app_filter.py b/tests/app/test_app_search_cache_logic.py similarity index 67% rename from tests/test_app_filter.py rename to tests/app/test_app_search_cache_logic.py index a3bf9e5..1db45e4 100644 --- a/tests/test_app_filter.py +++ b/tests/app/test_app_search_cache_logic.py @@ -8,22 +8,29 @@ from auditui.library import build_search_text, filter_items class StubLibrary: + """Minimal library facade used by search-related app helpers.""" + def extract_title(self, item: dict) -> str: + """Return title from a synthetic item.""" return item.get("title", "") def extract_authors(self, item: dict) -> str: + """Return authors from a synthetic item.""" return item.get("authors", "") @dataclass(slots=True) -class Dummy: +class DummyAuditui: + """Narrow object compatible with Auditui search-cache helper calls.""" + _search_text_cache: dict[int, str] = field(default_factory=dict) library_client: StubLibrary = field(default_factory=StubLibrary) def test_get_search_text_is_cached() -> None: + """Ensure repeated text extraction for one item reuses cache entries.""" item = {"title": "Title", "authors": "Author"} - dummy = Dummy() + dummy = DummyAuditui() first = Auditui._get_search_text(cast(Auditui, dummy), item) second = Auditui._get_search_text(cast(Auditui, dummy), item) assert first == "title author" @@ -31,7 +38,8 @@ def test_get_search_text_is_cached() -> None: assert len(dummy._search_text_cache) == 1 -def test_filter_items_uses_cache() -> None: +def test_filter_items_uses_cached_callable() -> None: + """Ensure filter_items cooperates with a memoized search text callback.""" library = StubLibrary() cache: dict[int, str] = {} items = [ @@ -40,6 +48,7 @@ def test_filter_items_uses_cache() -> None: ] def cached(item: dict) -> str: + """Build and cache normalized search text per object identity.""" cache_key = id(item) if cache_key not in cache: cache[cache_key] = build_search_text(item, cast(Any, library)) @@ -49,6 +58,7 @@ def test_filter_items_uses_cache() -> None: assert result == [items[1]] -def test_build_search_text_without_library() -> None: +def test_build_search_text_without_library_client() -> None: + """Ensure fallback search text path handles inline author dicts.""" item = {"title": "Title", "authors": [{"name": "A"}, {"name": "B"}]} assert build_search_text(item, None) == "title a, b" diff --git a/tests/conftest.py b/tests/conftest.py index 58ba452..51d62e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys from pathlib import Path from types import ModuleType +from typing import Any, cast ROOT = Path(__file__).resolve().parents[1] @@ -15,21 +16,26 @@ try: except ModuleNotFoundError: audible_stub = ModuleType("audible") - class Authenticator: # minimal stub for type usage + class Authenticator: + """Minimal audible authenticator test stub.""" + pass - class Client: # minimal stub for type usage + class Client: + """Minimal audible client test stub.""" + pass - audible_stub.Authenticator = Authenticator - audible_stub.Client = Client + setattr(cast(Any, audible_stub), "Authenticator", Authenticator) + setattr(cast(Any, audible_stub), "Client", Client) activation_bytes = ModuleType("audible.activation_bytes") def get_activation_bytes(_auth: Authenticator | None = None) -> bytes: + """Return deterministic empty activation bytes for tests.""" return b"" - activation_bytes.get_activation_bytes = get_activation_bytes + setattr(cast(Any, activation_bytes), "get_activation_bytes", get_activation_bytes) sys.modules["audible"] = audible_stub sys.modules["audible.activation_bytes"] = activation_bytes diff --git a/tests/downloads/test_download_manager_cache_and_validation.py b/tests/downloads/test_download_manager_cache_and_validation.py new file mode 100644 index 0000000..6c7a6ae --- /dev/null +++ b/tests/downloads/test_download_manager_cache_and_validation.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from auditui.constants import MIN_FILE_SIZE +from auditui.downloads import DownloadManager + + +def _manager_with_cache_dir(tmp_path: Path) -> DownloadManager: + """Build a lightweight DownloadManager instance without real HTTP clients.""" + manager = DownloadManager.__new__(DownloadManager) + manager.cache_dir = tmp_path + manager.chunk_size = 1024 + return manager + + +def test_sanitize_filename_replaces_invalid_characters() -> None: + """Ensure filesystem-invalid symbols are replaced with underscores.""" + manager = DownloadManager.__new__(DownloadManager) + assert manager._sanitize_filename('a<>:"/\\|?*b') == "a_________b" + + +def test_validate_download_url_accepts_only_http_schemes() -> None: + """Ensure download URL validation only accepts HTTP and HTTPS links.""" + manager = DownloadManager.__new__(DownloadManager) + assert manager._validate_download_url("https://example.com/file") is True + assert manager._validate_download_url("http://example.com/file") is True + assert manager._validate_download_url("ftp://example.com/file") is False + + +def test_get_cached_path_and_remove_cached( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure cache lookup and cache deletion work for valid files.""" + manager = _manager_with_cache_dir(tmp_path) + monkeypatch.setattr(manager, "_get_name_from_asin", lambda asin: "My Book") + cached_path = tmp_path / "My Book.aax" + cached_path.write_bytes(b"0" * MIN_FILE_SIZE) + messages: list[str] = [] + assert manager.get_cached_path("ASIN123") == cached_path + assert manager.is_cached("ASIN123") is True + assert manager.remove_cached("ASIN123", notify=messages.append) is True + assert not cached_path.exists() + assert "Removed from cache" in messages[-1] + + +def test_get_cached_path_ignores_small_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure undersized files are not treated as valid cache entries.""" + manager = _manager_with_cache_dir(tmp_path) + monkeypatch.setattr(manager, "_get_name_from_asin", lambda asin: "My Book") + cached_path = tmp_path / "My Book.aax" + cached_path.write_bytes(b"0" * (MIN_FILE_SIZE - 1)) + assert manager.get_cached_path("ASIN123") is None diff --git a/tests/downloads/test_download_manager_workflow.py b/tests/downloads/test_download_manager_workflow.py new file mode 100644 index 0000000..cb146c9 --- /dev/null +++ b/tests/downloads/test_download_manager_workflow.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from auditui.constants import MIN_FILE_SIZE +from auditui.downloads import DownloadManager +from auditui.downloads import manager as manager_mod + + +def _bare_manager(tmp_path: Path) -> DownloadManager: + """Create manager without invoking constructor side effects.""" + manager = DownloadManager.__new__(DownloadManager) + manager.cache_dir = tmp_path + manager.chunk_size = 1024 + manager.auth = type( + "Auth", (), {"adp_token": "x", "locale": type("Loc", (), {"domain": "fr"})()} + )() + return manager + + +def test_get_activation_bytes_returns_hex( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure activation bytes are converted to lowercase hex string.""" + manager = _bare_manager(tmp_path) + monkeypatch.setattr(manager_mod, "get_activation_bytes", lambda _auth: b"\xde\xad") + assert manager.get_activation_bytes() == "dead" + + +def test_get_activation_bytes_handles_errors( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Ensure activation retrieval failures are handled gracefully.""" + manager = _bare_manager(tmp_path) + + def _boom(_auth: object) -> bytes: + """Raise a deterministic failure for exception-path coverage.""" + raise OSError("no auth") + + monkeypatch.setattr(manager_mod, "get_activation_bytes", _boom) + assert manager.get_activation_bytes() is None + + +def test_get_or_download_uses_cached_file_when_available( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure cached files bypass link generation and download work.""" + manager = _bare_manager(tmp_path) + monkeypatch.setattr(manager, "_get_name_from_asin", lambda asin: "Book") + cached_path = tmp_path / "Book.aax" + cached_path.write_bytes(b"1" * MIN_FILE_SIZE) + messages: list[str] = [] + assert manager.get_or_download("ASIN", notify=messages.append) == cached_path + assert "Using cached file" in messages[0] + + +def test_get_or_download_reports_invalid_url( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure workflow reports invalid download URLs and aborts.""" + manager = _bare_manager(tmp_path) + monkeypatch.setattr(manager, "_get_name_from_asin", lambda asin: "Book") + monkeypatch.setattr( + manager, "_get_download_link", lambda asin, notify=None: "ftp://bad" + ) + messages: list[str] = [] + assert manager.get_or_download("ASIN", notify=messages.append) is None + assert "Invalid download URL" in messages + + +def test_get_or_download_handles_download_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure workflow reports failures when stream download does not complete.""" + manager = _bare_manager(tmp_path) + monkeypatch.setattr(manager, "_get_name_from_asin", lambda asin: "Book") + monkeypatch.setattr( + manager, "_get_download_link", lambda asin, notify=None: "https://ok" + ) + monkeypatch.setattr(manager, "_download_file", lambda url, path, notify=None: None) + messages: list[str] = [] + assert manager.get_or_download("ASIN", notify=messages.append) is None + assert "Download failed" in messages diff --git a/tests/library/test_library_client_extractors.py b/tests/library/test_library_client_extractors.py new file mode 100644 index 0000000..c83f952 --- /dev/null +++ b/tests/library/test_library_client_extractors.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from auditui.library import LibraryClient + + +@dataclass(slots=True) +class MockClient: + """Client double that records writes and serves configurable responses.""" + + put_calls: list[tuple[str, dict]] = field(default_factory=list) + post_calls: list[tuple[str, dict]] = field(default_factory=list) + _post_response: dict = field(default_factory=dict) + raise_on_put: bool = False + + def put(self, path: str, body: dict) -> dict: + """Record put payload or raise when configured.""" + if self.raise_on_put: + raise RuntimeError("put failed") + self.put_calls.append((path, body)) + return {} + + def post(self, path: str, body: dict) -> dict: + """Record post payload and return configured response.""" + self.post_calls.append((path, body)) + return self._post_response + + def get(self, path: str, **kwargs: dict) -> dict: + """Return empty data for extractor-focused tests.""" + del path, kwargs + return {} + + +def build_item( + *, + title: str | None = None, + product_title: str | None = None, + authors: list[dict] | None = None, + runtime_min: int | None = None, + listening_status: dict | None = None, + percent_complete: int | float | None = None, + asin: str | None = None, +) -> dict: + """Construct synthetic library items for extractor and finish tests.""" + item: dict = {} + if title is not None: + item["title"] = title + if percent_complete is not None: + item["percent_complete"] = percent_complete + if listening_status is not None: + item["listening_status"] = listening_status + if asin is not None: + item["asin"] = asin + product: dict = {} + if product_title is not None: + product["title"] = product_title + if runtime_min is not None: + product["runtime_length"] = {"min": runtime_min} + if authors is not None: + product["authors"] = authors + if asin is not None: + product["asin"] = asin + if product: + item["product"] = product + if runtime_min is not None: + item["runtime_length_min"] = runtime_min + return item + + +def test_extract_title_prefers_product_title() -> None: + """Ensure product title has precedence over outer item title.""" + library = LibraryClient(MockClient()) # type: ignore[arg-type] + assert ( + library.extract_title(build_item(title="Outer", product_title="Inner")) + == "Inner" + ) + + +def test_extract_title_falls_back_to_asin() -> None: + """Ensure title fallback uses product ASIN when no title exists.""" + library = LibraryClient(MockClient()) # type: ignore[arg-type] + assert library.extract_title({"product": {"asin": "A1"}}) == "A1" + + +def test_extract_authors_joins_names() -> None: + """Ensure author dictionaries are converted to a readable list.""" + library = LibraryClient(MockClient()) # type: ignore[arg-type] + item = build_item(authors=[{"name": "A"}, {"name": "B"}]) + assert library.extract_authors(item) == "A, B" + + +def test_extract_runtime_minutes_handles_dict_and_number() -> None: + """Ensure runtime extraction supports dict and numeric payloads.""" + library = LibraryClient(MockClient()) # type: ignore[arg-type] + assert library.extract_runtime_minutes(build_item(runtime_min=12)) == 12 + assert library.extract_runtime_minutes({"runtime_length": 42}) == 42 + + +def test_extract_progress_info_prefers_listening_status_when_needed() -> None: + """Ensure progress can be sourced from listening_status when top-level is absent.""" + library = LibraryClient(MockClient()) # type: ignore[arg-type] + item = build_item(listening_status={"percent_complete": 25.0}) + assert library.extract_progress_info(item) == 25.0 + + +def test_extract_asin_prefers_item_then_product() -> None: + """Ensure ASIN extraction works from both item and product fields.""" + library = LibraryClient(MockClient()) # type: ignore[arg-type] + assert library.extract_asin(build_item(asin="ASIN1")) == "ASIN1" + assert library.extract_asin({"product": {"asin": "ASIN2"}}) == "ASIN2" diff --git a/tests/library/test_library_client_progress_updates.py b/tests/library/test_library_client_progress_updates.py new file mode 100644 index 0000000..68f32e2 --- /dev/null +++ b/tests/library/test_library_client_progress_updates.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from auditui.library import LibraryClient + + +@dataclass(slots=True) +class ProgressClient: + """Client double for position and finished-state API methods.""" + + get_responses: dict[str, dict] = field(default_factory=dict) + put_calls: list[tuple[str, dict]] = field(default_factory=list) + post_response: dict = field(default_factory=dict) + fail_put: bool = False + + def get(self, path: str, **kwargs: object) -> dict: + """Return preconfigured payloads by API path.""" + del kwargs + return self.get_responses.get(path, {}) + + def put(self, path: str, body: dict) -> dict: + """Record payloads or raise to exercise error handling.""" + if self.fail_put: + raise OSError("write failed") + self.put_calls.append((path, body)) + return {} + + def post(self, path: str, body: dict) -> dict: + """Return licenserequest response for ACR extraction.""" + del path, body + return self.post_response + + +def test_is_finished_true_from_percent_complete() -> None: + """Ensure 100 percent completion is treated as finished.""" + library = LibraryClient(ProgressClient()) # type: ignore[arg-type] + assert library.is_finished({"percent_complete": 100}) is True + + +def test_get_last_position_reads_matching_annotation() -> None: + """Ensure last position is read in seconds from matching annotation.""" + client = ProgressClient( + get_responses={ + "1.0/annotations/lastpositions": { + "asin_last_position_heard_annots": [ + {"asin": "X", "last_position_heard": {"position_ms": 9000}} + ] + } + } + ) + library = LibraryClient(client) # type: ignore[arg-type] + assert library.get_last_position("X") == 9.0 + + +def test_get_last_position_returns_none_for_missing_state() -> None: + """Ensure DoesNotExist status is surfaced as no saved position.""" + client = ProgressClient( + get_responses={ + "1.0/annotations/lastpositions": { + "asin_last_position_heard_annots": [ + {"asin": "X", "last_position_heard": {"status": "DoesNotExist"}} + ] + } + } + ) + library = LibraryClient(client) # type: ignore[arg-type] + assert library.get_last_position("X") is None + + +def test_save_last_position_validates_non_positive_values() -> None: + """Ensure save_last_position short-circuits on non-positive input.""" + library = LibraryClient(ProgressClient()) # type: ignore[arg-type] + assert library.save_last_position("A", 0) is False + + +def test_update_position_writes_version_when_available() -> None: + """Ensure version is included in payload when metadata provides it.""" + client = ProgressClient( + get_responses={ + "1.0/content/A/metadata": { + "content_metadata": { + "content_reference": {"acr": "token", "version": "2"} + } + } + } + ) + library = LibraryClient(client) # type: ignore[arg-type] + assert library._update_position("A", 5.5) is True + path, body = client.put_calls[0] + assert path == "1.0/lastpositions/A" + assert body["position_ms"] == 5500 + assert body["version"] == "2" + + +def test_mark_as_finished_updates_item_in_place() -> None: + """Ensure successful finish update mutates local item flags.""" + client = ProgressClient(post_response={"content_license": {"acr": "token"}}) + library = LibraryClient(client) # type: ignore[arg-type] + item = {"runtime_length_min": 1, "listening_status": {}} + assert library.mark_as_finished("ASIN", item) is True + assert item["is_finished"] is True + assert item["listening_status"]["is_finished"] is True diff --git a/tests/library/test_library_search_filters.py b/tests/library/test_library_search_filters.py new file mode 100644 index 0000000..c393869 --- /dev/null +++ b/tests/library/test_library_search_filters.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from auditui.library import build_search_text, filter_items + + +class SearchLibrary: + """Simple search extraction adapter for build_search_text tests.""" + + def extract_title(self, item: dict) -> str: + """Return a title value from a synthetic item.""" + return item.get("t", "") + + def extract_authors(self, item: dict) -> str: + """Return an author value from a synthetic item.""" + return item.get("a", "") + + +def test_build_search_text_uses_library_client_when_present() -> None: + """Ensure search text delegates to library extractor methods.""" + item = {"t": "The Book", "a": "The Author"} + assert build_search_text(item, SearchLibrary()) == "the book the author" + + +def test_filter_items_returns_input_when_filter_empty() -> None: + """Ensure empty filter bypasses per-item search callback evaluation.""" + items = [{"k": 1}, {"k": 2}] + assert filter_items(items, "", lambda _item: "ignored") == items + + +def test_filter_items_matches_case_insensitively() -> None: + """Ensure search matching is case-insensitive across computed text.""" + items = [{"name": "Alpha"}, {"name": "Beta"}] + result = filter_items(items, "BETA", lambda item: item["name"].lower()) + assert result == [items[1]] diff --git a/tests/library/test_library_table_formatting.py b/tests/library/test_library_table_formatting.py new file mode 100644 index 0000000..a94ddde --- /dev/null +++ b/tests/library/test_library_table_formatting.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +from auditui.constants import AUTHOR_NAME_MAX_LENGTH +from auditui.library import ( + create_progress_sort_key, + create_title_sort_key, + filter_unfinished_items, + format_item_as_row, + truncate_author_name, +) + + +class StubLibrary: + """Library facade exposing only helpers needed by table formatting code.""" + + def extract_title(self, item: dict) -> str: + """Return synthetic title value.""" + return item.get("title", "") + + def extract_authors(self, item: dict) -> str: + """Return synthetic authors value.""" + return item.get("authors", "") + + def extract_runtime_minutes(self, item: dict) -> int | None: + """Return synthetic minute duration.""" + return item.get("minutes") + + def format_duration( + self, value: int | None, unit: str = "minutes", default_none: str | None = None + ) -> str | None: + """Render runtime in compact minute format for tests.""" + del unit + return default_none if value is None else f"{value}m" + + def extract_progress_info(self, item: dict) -> float | None: + """Return synthetic progress percentage value.""" + return item.get("percent") + + def extract_asin(self, item: dict) -> str | None: + """Return synthetic ASIN value.""" + return item.get("asin") + + def is_finished(self, item: dict) -> bool: + """Return synthetic finished flag from the item.""" + return bool(item.get("finished")) + + +@dataclass(slots=True) +class StubDownloads: + """Download cache adapter exposing just is_cached.""" + + cached: set[str] + + def is_cached(self, asin: str) -> bool: + """Return whether an ASIN is cached.""" + return asin in self.cached + + +def test_create_title_sort_key_normalizes_accents() -> None: + """Ensure title sorting removes accents before case-fold compare.""" + key_fn, _ = create_title_sort_key() + assert key_fn(["Ecole"]) == key_fn(["École"]) + + +def test_create_progress_sort_key_parses_percent_strings() -> None: + """Ensure progress sorting converts percentages and handles invalid values.""" + key_fn, _ = create_progress_sort_key() + assert key_fn(["0", "0", "0", "42.5%", ""]) == 42.5 + assert key_fn(["0", "0", "0", "bad", ""]) == 0.0 + + +def test_truncate_author_name_clamps_long_values() -> None: + """Ensure very long author strings are shortened with ellipsis.""" + long_name = "A" * (AUTHOR_NAME_MAX_LENGTH + 5) + out = truncate_author_name(long_name) + assert out.endswith("...") + assert len(out) <= AUTHOR_NAME_MAX_LENGTH + + +def test_format_item_as_row_marks_downloaded_titles() -> None: + """Ensure downloaded ASINs are shown with a checkmark in table rows.""" + item = { + "title": "Title", + "authors": "Author", + "minutes": 90, + "percent": 12.34, + "asin": "A1", + } + row = format_item_as_row(item, StubLibrary(), cast(Any, StubDownloads({"A1"}))) + assert row == ("Title", "Author", "90m", "12.3%", "✓") + + +def test_filter_unfinished_items_keeps_only_incomplete() -> None: + """Ensure unfinished filter excludes items marked as finished.""" + items = [{"id": 1, "finished": False}, {"id": 2, "finished": True}] + assert filter_unfinished_items(items, StubLibrary()) == [items[0]] diff --git a/tests/stats/test_stats_account_data.py b/tests/stats/test_stats_account_data.py new file mode 100644 index 0000000..e20bfbf --- /dev/null +++ b/tests/stats/test_stats_account_data.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from auditui.stats.account import ( + get_account_info, + get_country, + get_subscription_details, +) + + +class AccountClient: + """Minimal API client returning endpoint-specific account responses.""" + + def __init__(self, responses: dict[str, dict]) -> None: + """Store endpoint response map for deterministic tests.""" + self._responses = responses + + def get(self, path: str, **kwargs: object) -> dict: + """Return configured response and ignore query parameters.""" + del kwargs + return self._responses.get(path, {}) + + +def test_get_account_info_merges_multiple_endpoints() -> None: + """Ensure account info aggregator combines endpoint payload dictionaries.""" + client = AccountClient( + { + "1.0/account/information": {"a": 1}, + "1.0/customer/information": {"b": 2}, + "1.0/customer/status": {"c": 3}, + } + ) + assert get_account_info(client) == {"a": 1, "b": 2, "c": 3} + + +def test_get_subscription_details_uses_known_nested_paths() -> None: + """Ensure first valid subscription_details list entry is returned.""" + info = { + "customer_details": { + "subscription": {"subscription_details": [{"name": "Plan"}]} + } + } + assert get_subscription_details(info) == {"name": "Plan"} + + +def test_get_country_supports_locale_variants() -> None: + """Ensure country extraction supports object, domain, and locale string forms.""" + auth_country_code = type( + "Auth", (), {"locale": type("Loc", (), {"country_code": "us"})()} + )() + auth_domain = type("Auth", (), {"locale": type("Loc", (), {"domain": "fr"})()})() + auth_string = type("Auth", (), {"locale": "en_gb"})() + assert get_country(auth_country_code) == "US" + assert get_country(auth_domain) == "FR" + assert get_country(auth_string) == "GB" diff --git a/tests/stats/test_stats_aggregator_output.py b/tests/stats/test_stats_aggregator_output.py new file mode 100644 index 0000000..839a6b1 --- /dev/null +++ b/tests/stats/test_stats_aggregator_output.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from datetime import date + +from auditui.stats.aggregator import StatsAggregator +from auditui.stats import aggregator as aggregator_mod + + +def test_get_stats_returns_empty_without_client() -> None: + """Ensure stats aggregation short-circuits when API client is absent.""" + aggregator = StatsAggregator( + client=None, auth=None, library_client=None, all_items=[] + ) + assert aggregator.get_stats() == [] + + +def test_get_stats_builds_expected_rows(monkeypatch) -> None: + """Ensure aggregator assembles rows from listening, account, and email sources.""" + monkeypatch.setattr( + aggregator_mod.listening_mod, "get_signup_year", lambda _client: 2015 + ) + monkeypatch.setattr( + aggregator_mod.listening_mod, + "get_listening_time", + lambda _client, duration, start_date: 120_000 if duration == 1 else 3_600_000, + ) + monkeypatch.setattr( + aggregator_mod.listening_mod, "get_finished_books_count", lambda _lc, _items: 7 + ) + monkeypatch.setattr( + aggregator_mod.email_mod, + "resolve_email", + lambda *args, **kwargs: "user@example.com", + ) + monkeypatch.setattr(aggregator_mod.account_mod, "get_country", lambda _auth: "US") + monkeypatch.setattr( + aggregator_mod.account_mod, + "get_account_info", + lambda _client: { + "subscription_details": [ + { + "name": "Premium", + "next_bill_date": "2026-02-01T00:00:00Z", + "next_bill_amount": { + "currency_value": "14.95", + "currency_code": "USD", + }, + } + ] + }, + ) + + aggregator = StatsAggregator( + client=object(), + auth=object(), + library_client=object(), + all_items=[{}, {}, {}], + ) + stats = dict(aggregator.get_stats(today=date(2026, 2, 1))) + assert stats["Email"] == "user@example.com" + assert stats["Country Store"] == "US" + assert stats["Signup Year"] == "2015" + assert stats["Subscription"] == "Premium" + assert stats["Price"] == "14.95 USD" + assert stats["This Month"] == "2m" + assert stats["This Year"] == "1h00" + assert stats["Books Finished"] == "7 / 3" diff --git a/tests/stats/test_stats_email_resolution.py b/tests/stats/test_stats_email_resolution.py new file mode 100644 index 0000000..93eb8c4 --- /dev/null +++ b/tests/stats/test_stats_email_resolution.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from auditui.stats.email import ( + find_email_in_data, + first_email, + get_email_from_account_info, + get_email_from_auth, + get_email_from_auth_file, + get_email_from_config, + resolve_email, +) + + +def test_find_email_in_nested_data() -> None: + """Ensure nested structures are scanned until a plausible email is found.""" + data = {"a": {"b": ["nope", "user@example.com"]}} + assert find_email_in_data(data) == "user@example.com" + + +def test_first_email_skips_unknown_and_none() -> None: + """Ensure first_email ignores empty and Unknown sentinel values.""" + assert first_email(None, "Unknown", "ok@example.com") == "ok@example.com" + + +def test_get_email_from_config_and_auth_file(tmp_path: Path) -> None: + """Ensure config and auth-file readers extract valid email fields.""" + config_path = tmp_path / "config.json" + auth_path = tmp_path / "auth.json" + config_path.write_text( + json.dumps({"email": "config@example.com"}), encoding="utf-8" + ) + auth_path.write_text(json.dumps({"email": "auth@example.com"}), encoding="utf-8") + assert get_email_from_config(config_path) == "config@example.com" + assert get_email_from_auth_file(auth_path) == "auth@example.com" + + +def test_get_email_from_auth_prefers_username() -> None: + """Ensure auth object attributes are checked in expected precedence order.""" + auth = type( + "Auth", (), {"username": "user@example.com", "login": None, "email": None} + )() + assert get_email_from_auth(auth) == "user@example.com" + + +def test_get_email_from_account_info_supports_nested_customer_info() -> None: + """Ensure account email can be discovered in nested customer_info payload.""" + info = {"customer_info": {"primary_email": "nested@example.com"}} + assert get_email_from_account_info(info) == "nested@example.com" + + +def test_resolve_email_falls_back_to_account_getter(tmp_path: Path) -> None: + """Ensure resolve_email checks account-info callback when local sources miss.""" + auth = object() + value = resolve_email( + auth, + client=object(), + config_path=tmp_path / "missing-config.json", + auth_path=tmp_path / "missing-auth.json", + get_account_info=lambda: {"customer_email": "account@example.com"}, + ) + assert value == "account@example.com" diff --git a/tests/stats/test_stats_formatting.py b/tests/stats/test_stats_formatting.py new file mode 100644 index 0000000..e587619 --- /dev/null +++ b/tests/stats/test_stats_formatting.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from auditui.stats.format import format_date, format_time + + +def test_format_time_handles_minutes_and_hours() -> None: + """Ensure format_time outputs minute-only and hour-minute formats.""" + assert format_time(90_000) == "1m" + assert format_time(3_660_000) == "1h01" + + +def test_format_date_handles_iso_and_invalid_values() -> None: + """Ensure format_date normalizes ISO timestamps and preserves invalid input.""" + assert format_date("2026-01-15T10:20:30Z") == "2026-01-15" + assert format_date("not-a-date") == "not-a-date" + assert format_date(None) == "Unknown" diff --git a/tests/stats/test_stats_listening_metrics.py b/tests/stats/test_stats_listening_metrics.py new file mode 100644 index 0000000..3dba383 --- /dev/null +++ b/tests/stats/test_stats_listening_metrics.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from auditui.stats.listening import ( + get_finished_books_count, + get_listening_time, + get_signup_year, + has_activity, +) + + +class StatsClient: + """Client double for monthly aggregate lookups keyed by start date.""" + + def __init__(self, sums_by_start_date: dict[str, list[int]]) -> None: + """Store aggregate sums grouped by monthly_listening_interval_start_date.""" + self._sums = sums_by_start_date + + def get(self, path: str, **kwargs: str) -> dict: + """Return aggregate payload based on requested interval start date.""" + del path + start_date = kwargs["monthly_listening_interval_start_date"] + sums = self._sums.get(start_date, [0]) + return { + "aggregated_monthly_listening_stats": [{"aggregated_sum": s} for s in sums] + } + + +def test_has_activity_detects_non_zero_months() -> None: + """Ensure activity helper returns true when any month has positive sum.""" + assert ( + has_activity( + { + "aggregated_monthly_listening_stats": [ + {"aggregated_sum": 0}, + {"aggregated_sum": 1}, + ] + } + ) + is True + ) + + +def test_get_listening_time_sums_aggregated_months() -> None: + """Ensure monthly aggregate sums are added into one listening total.""" + client = StatsClient({"2026-01": [1000, 2000, 3000]}) + assert get_listening_time(client, duration=1, start_date="2026-01") == 6000 + + +def test_get_signup_year_returns_earliest_year_with_activity() -> None: + """Ensure signup year search finds first active year via binary search.""" + client = StatsClient( + {"2026-01": [1], "2010-01": [1], "2002-01": [1], "2001-01": [0]} + ) + year = get_signup_year(client) + assert year <= 2010 + + +def test_get_finished_books_count_uses_library_is_finished() -> None: + """Ensure finished books count delegates to library client predicate.""" + library_client = type( + "Library", (), {"is_finished": lambda self, item: item.get("done", False)} + )() + items = [{"done": True}, {"done": False}, {"done": True}] + assert get_finished_books_count(library_client, items) == 2 diff --git a/tests/test_downloads.py b/tests/test_downloads.py deleted file mode 100644 index 55e3231..0000000 --- a/tests/test_downloads.py +++ /dev/null @@ -1,48 +0,0 @@ -from pathlib import Path - -import pytest - -from auditui.downloads import DownloadManager -from auditui.constants import MIN_FILE_SIZE - - -def test_sanitize_filename() -> None: - dm = DownloadManager.__new__(DownloadManager) - assert dm._sanitize_filename('a<>:"/\\|?*b') == "a_________b" - - -def test_validate_download_url() -> None: - dm = DownloadManager.__new__(DownloadManager) - assert dm._validate_download_url("https://example.com/file") is True - assert dm._validate_download_url("http://example.com/file") is True - assert dm._validate_download_url("ftp://example.com/file") is False - - -def test_cached_path_and_remove(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - dm = DownloadManager.__new__(DownloadManager) - dm.cache_dir = tmp_path - - monkeypatch.setattr(dm, "_get_name_from_asin", lambda asin: "My Book") - safe_name = dm._sanitize_filename("My Book") - cached_path = tmp_path / f"{safe_name}.aax" - cached_path.write_bytes(b"0" * MIN_FILE_SIZE) - - assert dm.get_cached_path("ASIN123") == cached_path - assert dm.is_cached("ASIN123") is True - - messages: list[str] = [] - assert dm.remove_cached("ASIN123", notify=messages.append) is True - assert not cached_path.exists() - assert messages and "Removed from cache" in messages[-1] - - -def test_cached_path_ignores_small_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - dm = DownloadManager.__new__(DownloadManager) - dm.cache_dir = tmp_path - - monkeypatch.setattr(dm, "_get_name_from_asin", lambda asin: "My Book") - safe_name = dm._sanitize_filename("My Book") - cached_path = tmp_path / f"{safe_name}.aax" - cached_path.write_bytes(b"0" * (MIN_FILE_SIZE - 1)) - - assert dm.get_cached_path("ASIN123") is None diff --git a/tests/test_library.py b/tests/test_library.py deleted file mode 100644 index 4c84d16..0000000 --- a/tests/test_library.py +++ /dev/null @@ -1,131 +0,0 @@ -from dataclasses import dataclass, field - -from auditui.library import LibraryClient - - -@dataclass(slots=True) -class MockClient: - put_calls: list[tuple[str, dict]] = field(default_factory=list) - post_calls: list[tuple[str, dict]] = field(default_factory=list) - _post_response: dict = field(default_factory=dict) - raise_on_put: bool = False - - def put(self, path: str, body: dict) -> dict: - if self.raise_on_put: - raise RuntimeError("put failed") - self.put_calls.append((path, body)) - return {} - - def post(self, path: str, body: dict) -> dict: - self.post_calls.append((path, body)) - return self._post_response - - def get(self, path: str, **kwargs: dict) -> dict: - return {} - - -def test_extract_title_prefers_product() -> None: - client = MockClient() - library = LibraryClient(client) # type: ignore[arg-type] - item = build_item(title="Outer", product_title="Inner") - assert library.extract_title(item) == "Inner" - - -def test_extract_authors_joins_names() -> None: - client = MockClient() - library = LibraryClient(client) # type: ignore[arg-type] - item = build_item(authors=[{"name": "A"}, {"name": "B"}]) - assert library.extract_authors(item) == "A, B" - - -def test_extract_runtime_minutes_from_dict() -> None: - client = MockClient() - library = LibraryClient(client) # type: ignore[arg-type] - item = build_item(runtime_min=12) - assert library.extract_runtime_minutes(item) == 12 - - -def test_extract_progress_info_from_listening_status() -> None: - client = MockClient() - library = LibraryClient(client) # type: ignore[arg-type] - item = build_item(listening_status={"percent_complete": 25.0}) - assert library.extract_progress_info(item) == 25.0 - - -def test_is_finished_with_percent_complete() -> None: - client = MockClient() - library = LibraryClient(client) # type: ignore[arg-type] - item = build_item(percent_complete=100) - assert library.is_finished(item) - - -def test_format_duration_and_time() -> None: - client = MockClient() - library = LibraryClient(client) # type: ignore[arg-type] - assert library.format_duration(61) == "1h01" - assert library.format_time(3661) == "01:01:01" - - -def test_mark_as_finished_success_updates_item() -> None: - client = MockClient() - client._post_response = {"content_license": {"acr": "token"}} - library = LibraryClient(client) # type: ignore[arg-type] - item = build_item(runtime_min=1, listening_status={}) - ok = library.mark_as_finished("ASIN", item) - assert ok - assert client.put_calls - path, body = client.put_calls[0] - assert path == "1.0/lastpositions/ASIN" - assert body["acr"] == "token" - assert body["position_ms"] == 60_000 - assert item["is_finished"] is True - assert item["listening_status"]["is_finished"] is True - - -def test_mark_as_finished_fails_without_acr() -> None: - client = MockClient() - client._post_response = {} - library = LibraryClient(client) # type: ignore[arg-type] - item = build_item(runtime_min=1) - ok = library.mark_as_finished("ASIN", item) - assert ok is False - - -def test_mark_as_finished_handles_put_error() -> None: - client = MockClient() - client._post_response = {"content_license": {"acr": "token"}} - client.raise_on_put = True - library = LibraryClient(client) # type: ignore[arg-type] - item = build_item(runtime_min=1) - ok = library.mark_as_finished("ASIN", item) - assert ok is False - - -def build_item( - *, - title: str | None = None, - product_title: str | None = None, - authors: list[dict] | None = None, - runtime_min: int | None = None, - listening_status: dict | None = None, - percent_complete: int | float | None = None, -) -> dict: - item: dict = {} - if title is not None: - item["title"] = title - if percent_complete is not None: - item["percent_complete"] = percent_complete - if listening_status is not None: - item["listening_status"] = listening_status - product: dict = {} - if product_title is not None: - product["title"] = product_title - if runtime_min is not None: - product["runtime_length"] = {"min": runtime_min} - if authors is not None: - product["authors"] = authors - if product: - item["product"] = product - if runtime_min is not None and "runtime_length_min" not in item: - item["runtime_length_min"] = runtime_min - return item diff --git a/tests/test_table_utils.py b/tests/test_table_utils.py deleted file mode 100644 index 2399d86..0000000 --- a/tests/test_table_utils.py +++ /dev/null @@ -1,89 +0,0 @@ -from dataclasses import dataclass -from typing import Any, cast - -from auditui.constants import AUTHOR_NAME_MAX_LENGTH -from auditui.library import ( - create_progress_sort_key, - create_title_sort_key, - format_item_as_row, - truncate_author_name, -) - - -class StubLibrary: - def extract_title(self, item: dict) -> str: - return item.get("title", "") - - def extract_authors(self, item: dict) -> str: - return item.get("authors", "") - - def extract_runtime_minutes(self, item: dict) -> int | None: - return item.get("minutes") - - def format_duration( - self, value: int | None, unit: str = "minutes", default_none: str | None = None - ) -> str | None: - if value is None: - return default_none - return f"{value}m" - - def extract_progress_info(self, item: dict) -> float | None: - return item.get("percent") - - def extract_asin(self, item: dict) -> str | None: - return item.get("asin") - - -@dataclass(slots=True) -class StubDownloads: - _cached: set[str] - - def is_cached(self, asin: str) -> bool: - return asin in self._cached - - -def test_create_title_sort_key_normalizes_accents() -> None: - key_fn, _ = create_title_sort_key() - assert key_fn(["École"]) == "ecole" - assert key_fn(["Zoo"]) == "zoo" - - -def test_create_progress_sort_key_parses_percent() -> None: - key_fn, _ = create_progress_sort_key() - assert key_fn(["0", "0", "0", "42.5%"]) == 42.5 - assert key_fn(["0", "0", "0", "bad"]) == 0.0 - - -def test_truncate_author_name() -> None: - long_name = "A" * (AUTHOR_NAME_MAX_LENGTH + 5) - truncated = truncate_author_name(long_name) - assert truncated.endswith("...") - assert len(truncated) <= AUTHOR_NAME_MAX_LENGTH - - -def test_format_item_as_row_with_downloaded() -> None: - library = StubLibrary() - downloads = StubDownloads({"ASIN123"}) - item = { - "title": "Title", - "authors": "Author One", - "minutes": 90, - "percent": 12.34, - "asin": "ASIN123", - } - title, author, runtime, progress, downloaded = format_item_as_row( - item, library, cast(Any, downloads) - ) - assert title == "Title" - assert author == "Author One" - assert runtime == "90m" - assert progress == "12.3%" - assert downloaded == "✓" - - -def test_format_item_as_row_zero_progress() -> None: - library = StubLibrary() - item = {"title": "Title", "authors": "Author", - "minutes": 30, "percent": 0.0} - _, _, _, progress, _ = format_item_as_row(item, library, None) - assert progress == "0%" diff --git a/tests/test_ui_email.py b/tests/test_ui_email.py deleted file mode 100644 index ac187fd..0000000 --- a/tests/test_ui_email.py +++ /dev/null @@ -1,35 +0,0 @@ -import json -from pathlib import Path - -from auditui.stats.email import ( - find_email_in_data, - get_email_from_auth, - get_email_from_auth_file, - get_email_from_config, -) - - -def test_find_email_in_data() -> None: - data = {"a": {"b": ["nope", "user@example.com"]}} - assert find_email_in_data(data) == "user@example.com" - - -def test_get_email_from_config(tmp_path: Path) -> None: - config_path = tmp_path / "config.json" - config_path.write_text(json.dumps({"email": "config@example.com"})) - assert get_email_from_config(config_path) == "config@example.com" - - -def test_get_email_from_auth_file(tmp_path: Path) -> None: - auth_path = tmp_path / "auth.json" - auth_path.write_text(json.dumps({"email": "auth@example.com"})) - assert get_email_from_auth_file(auth_path) == "auth@example.com" - - -def test_get_email_from_auth() -> None: - class Auth: - username = "user@example.com" - login = None - email = None - - assert get_email_from_auth(Auth()) == "user@example.com" diff --git a/tests/test_ui_filter.py b/tests/ui/test_ui_filter_screen_behavior.py similarity index 59% rename from tests/test_ui_filter.py rename to tests/ui/test_ui_filter_screen_behavior.py index ca27f56..74a60b1 100644 --- a/tests/test_ui_filter.py +++ b/tests/ui/test_ui_filter_screen_behavior.py @@ -9,40 +9,54 @@ from textual.widgets import Input @dataclass(slots=True) class DummyEvent: + """Minimal event object carrying an input value for tests.""" + value: str @dataclass(slots=True) class FakeTimer: + """Timer substitute recording whether stop() was called.""" + callback: Callable[[], None] stopped: bool = False def stop(self) -> None: + """Mark timer as stopped.""" self.stopped = True def test_filter_debounce_uses_latest_value(monkeypatch) -> None: + """Ensure debounce cancels previous timer and emits latest input value.""" seen: list[str] = [] timers: list[FakeTimer] = [] def on_change(value: str) -> None: + """Capture emitted filter values.""" seen.append(value) screen = FilterScreen(on_change=on_change, debounce_seconds=0.2) - def fake_set_timer(_delay: float, callback): + def fake_set_timer(_delay: float, callback: Callable[[], None]) -> FakeTimer: + """Record timer callbacks instead of scheduling real timers.""" timer = FakeTimer(callback) timers.append(timer) return timer monkeypatch.setattr(screen, "set_timer", fake_set_timer) - screen.on_input_changed(cast(Input.Changed, DummyEvent("a"))) screen.on_input_changed(cast(Input.Changed, DummyEvent("ab"))) - assert len(timers) == 2 assert timers[0].stopped is True assert timers[1].stopped is False - timers[1].callback() assert seen == ["ab"] + + +def test_on_unmount_stops_pending_timer() -> None: + """Ensure screen unmount stops pending debounce timer when present.""" + screen = FilterScreen(on_change=lambda _value: None) + timer = FakeTimer(lambda: None) + screen._debounce_timer = timer + screen.on_unmount() + assert timer.stopped is True From 4bc9b3fd3f92e885028b712f674d7941fc6da783 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:17:42 +0100 Subject: [PATCH 06/35] test: add focused playback helper unit coverage --- .../test_playback_chapter_selection.py | 34 ++++++++++ tests/playback/test_playback_elapsed_math.py | 21 ++++++ .../playback/test_playback_process_helpers.py | 67 +++++++++++++++++++ tests/playback/test_playback_seek_targets.py | 20 ++++++ 4 files changed, 142 insertions(+) create mode 100644 tests/playback/test_playback_chapter_selection.py create mode 100644 tests/playback/test_playback_elapsed_math.py create mode 100644 tests/playback/test_playback_process_helpers.py create mode 100644 tests/playback/test_playback_seek_targets.py diff --git a/tests/playback/test_playback_chapter_selection.py b/tests/playback/test_playback_chapter_selection.py new file mode 100644 index 0000000..0581aa7 --- /dev/null +++ b/tests/playback/test_playback_chapter_selection.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from auditui.playback.chapters import get_current_chapter, get_current_chapter_index + + +CHAPTERS = [ + {"title": "One", "start_time": 0.0, "end_time": 60.0}, + {"title": "Two", "start_time": 60.0, "end_time": 120.0}, +] + + +def test_get_current_chapter_handles_empty_chapter_list() -> None: + """Ensure empty chapter metadata still returns a sensible fallback row.""" + assert get_current_chapter(12.0, [], 300.0) == ("Unknown Chapter", 12.0, 300.0) + + +def test_get_current_chapter_returns_matching_chapter_window() -> None: + """Ensure chapter selection returns title and chapter-relative timing.""" + assert get_current_chapter(75.0, CHAPTERS, 120.0) == ("Two", 15.0, 60.0) + + +def test_get_current_chapter_falls_back_to_last_chapter() -> None: + """Ensure elapsed values past known ranges map to last chapter.""" + assert get_current_chapter(150.0, CHAPTERS, 200.0) == ("Two", 90.0, 60.0) + + +def test_get_current_chapter_index_returns_none_without_chapters() -> None: + """Ensure chapter index lookup returns None when no chapters exist.""" + assert get_current_chapter_index(10.0, []) is None + + +def test_get_current_chapter_index_returns_last_when_past_end() -> None: + """Ensure chapter index lookup falls back to the final chapter index.""" + assert get_current_chapter_index(200.0, CHAPTERS) == 1 diff --git a/tests/playback/test_playback_elapsed_math.py b/tests/playback/test_playback_elapsed_math.py new file mode 100644 index 0000000..2891a2e --- /dev/null +++ b/tests/playback/test_playback_elapsed_math.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from auditui.playback.elapsed import get_elapsed +from auditui.playback import elapsed as elapsed_mod + + +def test_get_elapsed_returns_zero_without_start_time() -> None: + """Ensure elapsed computation returns zero when playback has not started.""" + assert get_elapsed(None, None, 0.0, False) == 0.0 + + +def test_get_elapsed_while_paused_uses_pause_start(monkeypatch) -> None: + """Ensure paused elapsed is fixed at pause_start minus previous pauses.""" + monkeypatch.setattr(elapsed_mod.time, "time", lambda: 500.0) + assert get_elapsed(100.0, 250.0, 20.0, True) == 130.0 + + +def test_get_elapsed_subtracts_pause_duration_when_resumed(monkeypatch) -> None: + """Ensure resumed elapsed removes newly accumulated paused duration.""" + monkeypatch.setattr(elapsed_mod.time, "time", lambda: 400.0) + assert get_elapsed(100.0, 300.0, 10.0, False) == 190.0 diff --git a/tests/playback/test_playback_process_helpers.py b/tests/playback/test_playback_process_helpers.py new file mode 100644 index 0000000..ea7a58b --- /dev/null +++ b/tests/playback/test_playback_process_helpers.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + +from auditui.playback import process as process_mod + + +class DummyProc: + """Minimal subprocess-like object for terminate_process tests.""" + + def __init__(self, alive: bool = True) -> None: + """Initialize process state and bookkeeping flags.""" + self._alive = alive + self.terminated = False + self.killed = False + self.pid = 123 + + def poll(self) -> int | None: + """Return None while process is alive and 0 when stopped.""" + return None if self._alive else 0 + + def terminate(self) -> None: + """Mark process as terminated and no longer alive.""" + self.terminated = True + self._alive = False + + def wait(self, timeout: float | None = None) -> int: + """Return immediately to emulate a cooperative shutdown.""" + del timeout + return 0 + + def kill(self) -> None: + """Mark process as killed and no longer alive.""" + self.killed = True + self._alive = False + + +def test_build_ffplay_cmd_includes_activation_seek_and_speed() -> None: + """Ensure ffplay command includes optional playback arguments when set.""" + cmd = process_mod.build_ffplay_cmd(Path("book.aax"), "abcd", 12.5, 1.2) + assert "-activation_bytes" in cmd + assert "-ss" in cmd + assert "atempo=1.20" in " ".join(cmd) + + +def test_terminate_process_handles_alive_process() -> None: + """Ensure terminate_process gracefully shuts down a running process.""" + proc = DummyProc(alive=True) + process_mod.terminate_process(proc) # type: ignore[arg-type] + assert proc.terminated is True + + +def test_run_ffplay_returns_none_when_unavailable(monkeypatch) -> None: + """Ensure ffplay launch exits early when binary is not on PATH.""" + monkeypatch.setattr(process_mod, "is_ffplay_available", lambda: False) + assert process_mod.run_ffplay(["ffplay", "book.aax"]) == (None, None) + + +def test_send_signal_delegates_to_os_kill(monkeypatch) -> None: + """Ensure send_signal forwards process PID and signal to os.kill.""" + seen: list[tuple[int, object]] = [] + monkeypatch.setattr( + process_mod.os, "kill", lambda pid, sig: seen.append((pid, sig)) + ) + process_mod.send_signal(DummyProc(), process_mod.signal.SIGSTOP) # type: ignore[arg-type] + assert seen and seen[0][0] == 123 diff --git a/tests/playback/test_playback_seek_targets.py b/tests/playback/test_playback_seek_targets.py new file mode 100644 index 0000000..07fb9d4 --- /dev/null +++ b/tests/playback/test_playback_seek_targets.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from auditui.playback.seek import compute_seek_target + + +def test_forward_seek_returns_new_position_and_message() -> None: + """Ensure forward seek computes expected position and status message.""" + target = compute_seek_target(10.0, 100.0, 30.0, "forward") + assert target == (40.0, "Skipped forward 30s") + + +def test_forward_seek_returns_none_near_end() -> None: + """Ensure seeking too close to end returns an invalid seek result.""" + assert compute_seek_target(95.0, 100.0, 10.0, "forward") is None + + +def test_backward_seek_clamps_to_zero() -> None: + """Ensure backward seek cannot go below zero.""" + target = compute_seek_target(5.0, None, 30.0, "backward") + assert target == (0.0, "Skipped backward 30s") From e88dcee155c15fb5ee12e99b52a0303720870ac8 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:17:48 +0100 Subject: [PATCH 07/35] test: cover app and playback controller mixin behavior --- ...test_app_actions_selection_and_controls.py | 124 +++++++++++++++ tests/app/test_app_library_mixin_behavior.py | 114 ++++++++++++++ tests/app/test_app_progress_mixin_behavior.py | 148 ++++++++++++++++++ tests/app/test_app_progress_periodic_save.py | 30 ++++ tests/app/test_app_state_initialization.py | 78 +++++++++ ...est_playback_controller_lifecycle_mixin.py | 121 ++++++++++++++ ...st_playback_controller_seek_speed_mixin.py | 100 ++++++++++++ .../test_playback_controller_state_mixin.py | 76 +++++++++ 8 files changed, 791 insertions(+) create mode 100644 tests/app/test_app_actions_selection_and_controls.py create mode 100644 tests/app/test_app_library_mixin_behavior.py create mode 100644 tests/app/test_app_progress_mixin_behavior.py create mode 100644 tests/app/test_app_progress_periodic_save.py create mode 100644 tests/app/test_app_state_initialization.py create mode 100644 tests/playback/test_playback_controller_lifecycle_mixin.py create mode 100644 tests/playback/test_playback_controller_seek_speed_mixin.py create mode 100644 tests/playback/test_playback_controller_state_mixin.py diff --git a/tests/app/test_app_actions_selection_and_controls.py b/tests/app/test_app_actions_selection_and_controls.py new file mode 100644 index 0000000..d708eff --- /dev/null +++ b/tests/app/test_app_actions_selection_and_controls.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from auditui.app.actions import AppActionsMixin + + +@dataclass(slots=True) +class FakeTable: + """Minimal table shim exposing cursor and row count.""" + + row_count: int + cursor_row: int = 0 + + +class FakePlayback: + """Playback stub with togglable boolean return values.""" + + def __init__(self, result: bool) -> None: + """Store deterministic toggle result for tests.""" + self._result = result + self.calls: list[str] = [] + + def toggle_playback(self) -> bool: + """Return configured result and record call.""" + self.calls.append("toggle") + return self._result + + def seek_forward(self, _seconds: float) -> bool: + """Return configured result and record call.""" + self.calls.append("seek_forward") + return self._result + + +class DummyActionsApp(AppActionsMixin): + """Mixin host with just enough state for action method tests.""" + + def __init__(self) -> None: + """Initialize fake app state used by action helpers.""" + self.messages: list[str] = [] + self.current_items: list[dict] = [] + self.download_manager = object() + self.library_client = type( + "Library", (), {"extract_asin": lambda self, item: item.get("asin")} + )() + self.playback = FakePlayback(True) + self.filter_text = "hello" + self._refreshed = 0 + self._table = FakeTable(row_count=0, cursor_row=0) + + def update_status(self, message: str) -> None: + """Collect status messages for assertions.""" + self.messages.append(message) + + def query_one(self, selector: str, _type: object) -> FakeTable: + """Return the fake table used in selection tests.""" + assert selector == "#library_table" + return self._table + + def _refresh_filtered_view(self) -> None: + """Record refresh invocations for filter tests.""" + self._refreshed += 1 + + def _start_playback_async(self, asin: str) -> None: + """Capture async playback launch argument.""" + self.messages.append(f"start:{asin}") + + +def test_get_selected_asin_requires_non_empty_table() -> None: + """Ensure selection fails gracefully when table has no rows.""" + app = DummyActionsApp() + app._table = FakeTable(row_count=0) + assert app._get_selected_asin() is None + assert app.messages[-1] == "No books available" + + +def test_get_selected_asin_returns_current_row_asin() -> None: + """Ensure selected row index maps to current_items ASIN.""" + app = DummyActionsApp() + app._table = FakeTable(row_count=2, cursor_row=1) + app.current_items = [{"asin": "A1"}, {"asin": "A2"}] + assert app._get_selected_asin() == "A2" + + +def test_action_play_selected_starts_async_playback() -> None: + """Ensure play action calls async starter with selected ASIN.""" + app = DummyActionsApp() + app._table = FakeTable(row_count=1, cursor_row=0) + app.current_items = [{"asin": "ASIN"}] + app.action_play_selected() + assert app.messages[-1] == "start:ASIN" + + +def test_action_toggle_playback_shows_hint_when_no_playback() -> None: + """Ensure toggle action displays no-playback hint on false return.""" + app = DummyActionsApp() + app.playback = FakePlayback(False) + app.action_toggle_playback() + assert app.messages[-1] == "No playback active. Press Enter to play a book." + + +def test_action_seek_forward_shows_hint_when_seek_fails() -> None: + """Ensure failed seek action reuses no-playback helper status.""" + app = DummyActionsApp() + app.playback = FakePlayback(False) + app.action_seek_forward() + assert app.messages[-1] == "No playback active. Press Enter to play a book." + + +def test_action_clear_filter_resets_filter_and_refreshes() -> None: + """Ensure clearing filter resets text and refreshes filtered view.""" + app = DummyActionsApp() + app.action_clear_filter() + assert app.filter_text == "" + assert app._refreshed == 1 + assert app.messages[-1] == "Filter cleared" + + +def test_apply_filter_coerces_none_to_empty_string() -> None: + """Ensure apply_filter normalizes None and refreshes list view.""" + app = DummyActionsApp() + app._apply_filter(None) + assert app.filter_text == "" + assert app._refreshed == 1 diff --git a/tests/app/test_app_library_mixin_behavior.py b/tests/app/test_app_library_mixin_behavior.py new file mode 100644 index 0000000..cab782e --- /dev/null +++ b/tests/app/test_app_library_mixin_behavior.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from auditui.app.library import AppLibraryMixin +from auditui.app import library as library_mod + + +class DummyLibraryApp(AppLibraryMixin): + """Mixin host exposing only members used by AppLibraryMixin.""" + + def __init__(self) -> None: + """Initialize in-memory app state and call tracking.""" + self.all_items: list[dict] = [] + self.show_all_mode = False + self._search_text_cache: dict[int, str] = {1: "x"} + self.messages: list[str] = [] + self.call_log: list[tuple[str, tuple]] = [] + self.library_client = None + + def _prime_search_cache(self, items: list[dict]) -> None: + """Store a marker so callers can assert this method was reached.""" + self.call_log.append(("prime", (items,))) + + def show_all(self) -> None: + """Record show_all invocation for assertion.""" + self.call_log.append(("show_all", ())) + + def show_unfinished(self) -> None: + """Record show_unfinished invocation for assertion.""" + self.call_log.append(("show_unfinished", ())) + + def update_status(self, message: str) -> None: + """Capture status messages.""" + self.messages.append(message) + + def call_from_thread(self, func, *args) -> None: + """Execute callback immediately to simplify tests.""" + func(*args) + + def _thread_status_update(self, message: str) -> None: + """Capture worker-thread status update messages.""" + self.messages.append(message) + + +def test_on_library_loaded_refreshes_cache_and_shows_unfinished() -> None: + """Ensure loaded items reset cache and default to unfinished view.""" + app = DummyLibraryApp() + items = [{"asin": "a"}, {"asin": "b"}] + app.on_library_loaded(items) + assert app.all_items == items + assert app._search_text_cache == {} + assert app.messages[-1] == "Loaded 2 books" + assert app.call_log[-1][0] == "show_unfinished" + + +def test_on_library_loaded_uses_show_all_mode() -> None: + """Ensure loaded items respect show_all mode when enabled.""" + app = DummyLibraryApp() + app.show_all_mode = True + app.on_library_loaded([{"asin": "a"}]) + assert app.call_log[-1][0] == "show_all" + + +def test_on_library_error_formats_message() -> None: + """Ensure library errors are surfaced through status updates.""" + app = DummyLibraryApp() + app.on_library_error("boom") + assert app.messages == ["Error fetching library: boom"] + + +def test_fetch_library_calls_on_loaded(monkeypatch) -> None: + """Ensure fetch_library forwards fetched items through call_from_thread.""" + app = DummyLibraryApp() + + class Worker: + """Simple worker shim exposing cancellation state.""" + + is_cancelled = False + + class LibraryClient: + """Fake client returning a deterministic item list.""" + + def fetch_all_items(self, callback): + """Invoke callback and return one item.""" + callback("progress") + return [{"asin": "x"}] + + app.library_client = LibraryClient() + monkeypatch.setattr(library_mod, "get_current_worker", lambda: Worker()) + AppLibraryMixin.fetch_library.__wrapped__(app) + assert app.all_items == [{"asin": "x"}] + assert "Loaded 1 books" in app.messages + + +def test_fetch_library_handles_expected_exception(monkeypatch) -> None: + """Ensure fetch exceptions call on_library_error with error text.""" + app = DummyLibraryApp() + + class Worker: + """Simple worker shim exposing cancellation state.""" + + is_cancelled = False + + class BrokenClient: + """Fake client raising an expected fetch exception.""" + + def fetch_all_items(self, callback): + """Raise the same exception family handled by mixin.""" + del callback + raise ValueError("bad fetch") + + app.library_client = BrokenClient() + monkeypatch.setattr(library_mod, "get_current_worker", lambda: Worker()) + AppLibraryMixin.fetch_library.__wrapped__(app) + assert app.messages[-1] == "Error fetching library: bad fetch" diff --git a/tests/app/test_app_progress_mixin_behavior.py b/tests/app/test_app_progress_mixin_behavior.py new file mode 100644 index 0000000..1e095bb --- /dev/null +++ b/tests/app/test_app_progress_mixin_behavior.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast + +from auditui.app.progress import AppProgressMixin +from textual.events import Key +from textual.widgets import DataTable + + +@dataclass(slots=True) +class FakeKeyEvent: + """Minimal key event carrying key value and prevent_default state.""" + + key: str + prevented: bool = False + + def prevent_default(self) -> None: + """Mark event as prevented.""" + self.prevented = True + + +@dataclass(slots=True) +class FakeStatic: + """Minimal static widget with text and visibility fields.""" + + display: bool = False + text: str = "" + + def update(self, value: str) -> None: + """Store rendered text value.""" + self.text = value + + +@dataclass(slots=True) +class FakeProgressBar: + """Minimal progress bar widget storing latest progress value.""" + + progress: float = 0.0 + + def update(self, progress: float) -> None: + """Store progress value for assertions.""" + self.progress = progress + + +@dataclass(slots=True) +class FakeContainer: + """Minimal container exposing display property.""" + + display: bool = False + + +class DummyPlayback: + """Playback shim exposing only members used by AppProgressMixin.""" + + def __init__(self) -> None: + """Initialize playback state and update counters.""" + self.is_playing = False + self._status: str | None = None + self._progress: tuple[str, float, float] | None = None + self.saved_calls = 0 + + def check_status(self): + """Return configurable status check message.""" + return self._status + + def get_current_progress(self): + """Return configurable progress tuple.""" + return self._progress + + def update_position_if_needed(self) -> None: + """Record periodic save invocations.""" + self.saved_calls += 1 + + +class DummyProgressApp(AppProgressMixin): + """Mixin host that records action dispatch and widget updates.""" + + def __init__(self) -> None: + """Initialize fake widgets and playback state.""" + self.playback = DummyPlayback() + self.focused = object() + self.actions: list[str] = [] + self.messages: list[str] = [] + self.progress_info = FakeStatic() + self.progress_bar = FakeProgressBar() + self.progress_container = FakeContainer() + + def action_seek_backward(self) -> None: + """Record backward seek action dispatch.""" + self.actions.append("seek_backward") + + def action_toggle_playback(self) -> None: + """Record toggle playback action dispatch.""" + self.actions.append("toggle") + + def update_status(self, message: str) -> None: + """Capture status messages for assertions.""" + self.messages.append(message) + + def query_one(self, selector: str, _type: object): + """Return fake widgets by selector used by progress mixin.""" + return { + "#progress_info": self.progress_info, + "#progress_bar": self.progress_bar, + "#progress_bar_container": self.progress_container, + }[selector] + + +def test_on_key_dispatches_seek_when_playing() -> None: + """Ensure left key is intercepted and dispatched to seek action.""" + app = DummyProgressApp() + app.playback.is_playing = True + event = FakeKeyEvent("left") + app.on_key(cast(Key, event)) + assert event.prevented is True + assert app.actions == ["seek_backward"] + + +def test_on_key_dispatches_space_when_table_focused() -> None: + """Ensure space is intercepted and dispatched when table is focused.""" + app = DummyProgressApp() + app.focused = DataTable() + event = FakeKeyEvent("space") + app.on_key(cast(Key, event)) + assert event.prevented is True + assert app.actions == ["toggle"] + + +def test_check_playback_status_hides_progress_after_message() -> None: + """Ensure playback status message triggers hide-progress behavior.""" + app = DummyProgressApp() + app.playback._status = "Finished" + app._check_playback_status() + assert app.messages[-1] == "Finished" + assert app.progress_info.display is False + assert app.progress_container.display is False + + +def test_update_progress_renders_visible_progress_row() -> None: + """Ensure valid progress data updates widgets and makes them visible.""" + app = DummyProgressApp() + app.playback.is_playing = True + app.playback._progress = ("Chapter", 30.0, 60.0) + app._update_progress() + assert app.progress_bar.progress == 50.0 + assert app.progress_info.display is True + assert app.progress_container.display is True diff --git a/tests/app/test_app_progress_periodic_save.py b/tests/app/test_app_progress_periodic_save.py new file mode 100644 index 0000000..77bc072 --- /dev/null +++ b/tests/app/test_app_progress_periodic_save.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from auditui.app.progress import AppProgressMixin + + +class DummyPlayback: + """Playback stub exposing periodic update method.""" + + def __init__(self) -> None: + """Initialize call counter.""" + self.saved_calls = 0 + + def update_position_if_needed(self) -> None: + """Increment call counter for assertions.""" + self.saved_calls += 1 + + +class DummyProgressApp(AppProgressMixin): + """Minimal app host containing playback dependency only.""" + + def __init__(self) -> None: + """Initialize playback stub.""" + self.playback = DummyPlayback() + + +def test_save_position_periodically_delegates_to_playback() -> None: + """Ensure periodic save method delegates to playback updater.""" + app = DummyProgressApp() + app._save_position_periodically() + assert app.playback.saved_calls == 1 diff --git a/tests/app/test_app_state_initialization.py b/tests/app/test_app_state_initialization.py new file mode 100644 index 0000000..b102001 --- /dev/null +++ b/tests/app/test_app_state_initialization.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from auditui.app import state as state_mod + + +class DummyApp: + """Lightweight app object for state initialization tests.""" + + def __init__(self) -> None: + """Expose update_status to satisfy init dependencies.""" + self.messages: list[str] = [] + + def update_status(self, message: str) -> None: + """Collect status updates for assertions.""" + self.messages.append(message) + + +def test_init_state_without_auth_or_client(monkeypatch) -> None: + """Ensure baseline state is initialized when no auth/client is provided.""" + app = DummyApp() + playback_args: list[tuple[object, object]] = [] + + class FakePlayback: + """Playback constructor recorder for init tests.""" + + def __init__(self, notify, library_client) -> None: + """Capture arguments passed by init_auditui_state.""" + playback_args.append((notify, library_client)) + + monkeypatch.setattr(state_mod, "PlaybackController", FakePlayback) + state_mod.init_auditui_state(app) + assert app.library_client is None + assert app.download_manager is None + assert app.all_items == [] + assert app.current_items == [] + assert app.filter_text == "" + assert app.show_all_mode is False + assert playback_args and playback_args[0][1] is None + + +def test_init_state_with_auth_and_client_builds_dependencies(monkeypatch) -> None: + """Ensure init constructs library, downloads, and playback dependencies.""" + app = DummyApp() + auth = object() + client = object() + + class FakeLibraryClient: + """Fake library client constructor for dependency wiring checks.""" + + def __init__(self, value) -> None: + """Store constructor argument for assertions.""" + self.value = value + + class FakeDownloadManager: + """Fake download manager constructor for dependency wiring checks.""" + + def __init__(self, auth_value, client_value) -> None: + """Store constructor arguments for assertions.""" + self.args = (auth_value, client_value) + + class FakePlayback: + """Fake playback constructor for dependency wiring checks.""" + + def __init__(self, notify, library_client) -> None: + """Store constructor arguments for assertions.""" + self.notify = notify + self.library_client = library_client + + monkeypatch.setattr(state_mod, "LibraryClient", FakeLibraryClient) + monkeypatch.setattr(state_mod, "DownloadManager", FakeDownloadManager) + monkeypatch.setattr(state_mod, "PlaybackController", FakePlayback) + state_mod.init_auditui_state(app, auth=auth, client=client) + assert isinstance(app.library_client, FakeLibraryClient) + assert isinstance(app.download_manager, FakeDownloadManager) + assert isinstance(app.playback, FakePlayback) + assert app.library_client.value is client + assert app.download_manager.args == (auth, client) + assert app.playback.library_client.value is client diff --git a/tests/playback/test_playback_controller_lifecycle_mixin.py b/tests/playback/test_playback_controller_lifecycle_mixin.py new file mode 100644 index 0000000..db9cc38 --- /dev/null +++ b/tests/playback/test_playback_controller_lifecycle_mixin.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from pathlib import Path + +from auditui.playback import controller_lifecycle as lifecycle_mod +from auditui.playback.controller import PlaybackController + + +class Proc: + """Process shim used for lifecycle tests.""" + + def __init__(self, poll_value=None) -> None: + """Set initial poll result.""" + self._poll_value = poll_value + + def poll(self): + """Return process running status.""" + return self._poll_value + + +def _controller() -> tuple[PlaybackController, list[str]]: + """Build controller and message capture list.""" + messages: list[str] = [] + return PlaybackController(messages.append, None), messages + + +def test_start_reports_missing_ffplay(monkeypatch) -> None: + """Ensure start fails fast when ffplay is unavailable.""" + controller, messages = _controller() + monkeypatch.setattr(lifecycle_mod.process_mod, "is_ffplay_available", lambda: False) + assert controller.start(Path("book.aax")) is False + assert messages[-1] == "ffplay not found. Please install ffmpeg" + + +def test_start_sets_state_on_success(monkeypatch) -> None: + """Ensure successful start initializes playback state and metadata.""" + controller, messages = _controller() + monkeypatch.setattr(lifecycle_mod.process_mod, "is_ffplay_available", lambda: True) + monkeypatch.setattr( + lifecycle_mod.process_mod, "build_ffplay_cmd", lambda *args: ["ffplay"] + ) + monkeypatch.setattr( + lifecycle_mod.process_mod, "run_ffplay", lambda cmd: (Proc(None), None) + ) + monkeypatch.setattr( + lifecycle_mod, + "load_media_info", + lambda path, activation: (600.0, [{"title": "ch"}]), + ) + monkeypatch.setattr(lifecycle_mod.time, "time", lambda: 100.0) + ok = controller.start( + Path("book.aax"), activation_hex="abcd", start_position=10.0, speed=1.2 + ) + assert ok is True + assert controller.is_playing is True + assert controller.current_file_path == Path("book.aax") + assert controller.total_duration == 600.0 + assert messages[-1] == "Playing: book.aax" + + +def test_prepare_and_start_uses_last_position(monkeypatch) -> None: + """Ensure prepare flow resumes from saved position when available.""" + messages: list[str] = [] + lib = type("Lib", (), {"get_last_position": lambda self, asin: 75.0})() + controller = PlaybackController(messages.append, lib) + started: list[tuple] = [] + + class DM: + """Download manager shim returning path and activation token.""" + + def get_or_download(self, asin, notify): + """Return deterministic downloaded file path.""" + return Path("book.aax") + + def get_activation_bytes(self): + """Return deterministic activation token.""" + return "abcd" + + monkeypatch.setattr(controller, "start", lambda *args: started.append(args) or True) + monkeypatch.setattr(lifecycle_mod.time, "time", lambda: 200.0) + assert controller.prepare_and_start(DM(), "ASIN") is True + assert started and started[0][3] == 75.0 + assert "Resuming from 01:15" in messages + + +def test_toggle_playback_uses_pause_and_resume_paths(monkeypatch) -> None: + """Ensure toggle dispatches pause or resume based on paused flag.""" + controller, _ = _controller() + controller.is_playing = True + controller.playback_process = Proc(None) + called: list[str] = [] + monkeypatch.setattr(controller, "pause", lambda: called.append("pause")) + monkeypatch.setattr(controller, "resume", lambda: called.append("resume")) + controller.is_paused = False + assert controller.toggle_playback() is True + controller.is_paused = True + assert controller.toggle_playback() is True + assert called == ["pause", "resume"] + + +def test_restart_at_position_restores_state_and_notifies(monkeypatch) -> None: + """Ensure restart logic preserves metadata and emits custom message.""" + controller, messages = _controller() + controller.is_playing = True + controller.is_paused = True + controller.current_file_path = Path("book.aax") + controller.current_asin = "ASIN" + controller.activation_hex = "abcd" + controller.total_duration = 400.0 + controller.chapters = [{"title": "One"}] + controller.playback_speed = 1.0 + monkeypatch.setattr(controller, "_stop_process", lambda: None) + monkeypatch.setattr(lifecycle_mod.time, "sleep", lambda _s: None) + monkeypatch.setattr(controller, "start", lambda *args: True) + paused: list[str] = [] + monkeypatch.setattr(controller, "pause", lambda: paused.append("pause")) + assert controller._restart_at_position(120.0, message="Jumped") is True + assert controller.current_asin == "ASIN" + assert controller.chapters == [{"title": "One"}] + assert paused == ["pause"] + assert messages[-1] == "Jumped" diff --git a/tests/playback/test_playback_controller_seek_speed_mixin.py b/tests/playback/test_playback_controller_seek_speed_mixin.py new file mode 100644 index 0000000..1567d88 --- /dev/null +++ b/tests/playback/test_playback_controller_seek_speed_mixin.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from auditui.playback import controller_seek_speed as seek_speed_mod +from auditui.playback.controller import PlaybackController + + +def _controller() -> tuple[PlaybackController, list[str]]: + """Build controller and in-memory notification sink.""" + messages: list[str] = [] + return PlaybackController(messages.append, None), messages + + +def test_seek_notifies_when_target_invalid(monkeypatch) -> None: + """Ensure seek reports end-of-file condition when target is invalid.""" + controller, messages = _controller() + monkeypatch.setattr(controller, "_get_current_elapsed", lambda: 20.0) + controller.seek_offset = 100.0 + controller.total_duration = 120.0 + monkeypatch.setattr( + seek_speed_mod.seek_mod, "compute_seek_target", lambda *args: None + ) + assert controller._seek(30.0, "forward") is False + assert messages[-1] == "Already at end of file" + + +def test_seek_to_chapter_reports_bounds(monkeypatch) -> None: + """Ensure chapter seek reports first and last chapter boundaries.""" + controller, messages = _controller() + controller.is_playing = True + controller.current_file_path = object() + controller.chapters = [{"title": "One", "start_time": 0.0, "end_time": 10.0}] + monkeypatch.setattr(controller, "_get_current_elapsed", lambda: 1.0) + monkeypatch.setattr( + seek_speed_mod.chapters_mod, + "get_current_chapter_index", + lambda elapsed, chapters: 0, + ) + assert controller.seek_to_chapter("next") is False + assert messages[-1] == "Already at last chapter" + assert controller.seek_to_chapter("previous") is False + assert messages[-1] == "Already at first chapter" + + +def test_save_current_position_writes_positive_values() -> None: + """Ensure save_current_position persists elapsed time via library client.""" + calls: list[tuple[str, float]] = [] + library = type( + "Library", + (), + {"save_last_position": lambda self, asin, pos: calls.append((asin, pos))}, + )() + controller = PlaybackController(lambda _msg: None, library) + controller.current_asin = "ASIN" + controller.is_playing = True + controller.playback_start_time = 1.0 + controller.seek_offset = 10.0 + controller._get_current_elapsed = lambda: 15.0 + controller._save_current_position() + assert calls == [("ASIN", 25.0)] + + +def test_update_position_if_needed_honors_interval(monkeypatch) -> None: + """Ensure periodic save runs only when interval has elapsed.""" + controller, _ = _controller() + controller.is_playing = True + controller.current_asin = "ASIN" + controller.library_client = object() + controller.last_save_time = 10.0 + controller.position_save_interval = 30.0 + saves: list[str] = [] + monkeypatch.setattr( + controller, "_save_current_position", lambda: saves.append("save") + ) + monkeypatch.setattr(seek_speed_mod.time, "time", lambda: 20.0) + controller.update_position_if_needed() + monkeypatch.setattr(seek_speed_mod.time, "time", lambda: 45.0) + controller.update_position_if_needed() + assert saves == ["save"] + + +def test_change_speed_restarts_with_new_rate(monkeypatch) -> None: + """Ensure speed changes restart playback at current position.""" + controller, _ = _controller() + controller.playback_speed = 1.0 + controller.seek_offset = 5.0 + monkeypatch.setattr(controller, "_get_current_elapsed", lambda: 10.0) + seen: list[tuple[float, float, str]] = [] + + def fake_restart( + position: float, speed: float | None = None, message: str | None = None + ) -> bool: + """Capture restart call parameters.""" + seen.append((position, speed or 0.0, message or "")) + return True + + monkeypatch.setattr(controller, "_restart_at_position", fake_restart) + assert controller.increase_speed() is True + assert seen and seen[0][0] == 15.0 + assert seen[0][1] > 1.0 + assert seen[0][2].startswith("Speed: ") diff --git a/tests/playback/test_playback_controller_state_mixin.py b/tests/playback/test_playback_controller_state_mixin.py new file mode 100644 index 0000000..d035364 --- /dev/null +++ b/tests/playback/test_playback_controller_state_mixin.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, cast + +from auditui.playback import controller_state as state_mod +from auditui.playback.controller import PlaybackController + + +class Proc: + """Simple process shim exposing poll and pid for state tests.""" + + def __init__(self, poll_value=None) -> None: + """Store poll return value and fake pid.""" + self._poll_value = poll_value + self.pid = 123 + + def poll(self): + """Return configured process status code or None.""" + return self._poll_value + + +def _controller() -> tuple[PlaybackController, list[str]]: + """Build playback controller and collected notifications list.""" + messages: list[str] = [] + return PlaybackController(messages.append, None), messages + + +def test_get_current_elapsed_rolls_pause_into_duration(monkeypatch) -> None: + """Ensure elapsed helper absorbs stale pause_start_time when resumed.""" + controller, _ = _controller() + controller.pause_start_time = 100.0 + controller.is_paused = False + monkeypatch.setattr(state_mod.time, "time", lambda: 120.0) + monkeypatch.setattr(state_mod.elapsed_mod, "get_elapsed", lambda *args: 50.0) + assert controller._get_current_elapsed() == 50.0 + assert controller.paused_duration == 20.0 + assert controller.pause_start_time is None + + +def test_validate_playback_state_stops_when_process_ended() -> None: + """Ensure state validation stops and reports when process is gone.""" + controller, messages = _controller() + controller.playback_process = cast(Any, Proc(poll_value=1)) + controller.is_playing = True + controller.current_file_path = Path("book.aax") + ok = controller._validate_playback_state(require_paused=False) + assert ok is False + assert messages[-1] == "Playback process has ended" + + +def test_send_signal_sets_paused_state_and_notifies(monkeypatch) -> None: + """Ensure SIGSTOP updates paused state and includes filename in status.""" + controller, messages = _controller() + controller.playback_process = cast(Any, Proc()) + controller.current_file_path = Path("book.aax") + monkeypatch.setattr(state_mod.process_mod, "send_signal", lambda proc, sig: None) + controller._send_signal(state_mod.signal.SIGSTOP, "Paused", "pause") + assert controller.is_paused is True + assert messages[-1] == "Paused: book.aax" + + +def test_send_signal_handles_process_lookup(monkeypatch) -> None: + """Ensure missing process lookup errors are handled with user-facing message.""" + controller, messages = _controller() + controller.playback_process = cast(Any, Proc()) + + def raise_lookup(proc, sig): + """Raise process lookup error to exercise exception path.""" + del proc, sig + raise ProcessLookupError("gone") + + monkeypatch.setattr(state_mod.process_mod, "send_signal", raise_lookup) + monkeypatch.setattr(state_mod.process_mod, "terminate_process", lambda proc: None) + controller._send_signal(state_mod.signal.SIGCONT, "Playing", "resume") + assert messages[-1] == "Process no longer exists" From 01de75871afbe4f9a0692cca227594ea5cc3acb2 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:21:15 +0100 Subject: [PATCH 08/35] feat: versionning --- auditui/__init__.py | 4 ++-- pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/auditui/__init__.py b/auditui/__init__.py index db53994..6e23596 100644 --- a/auditui/__init__.py +++ b/auditui/__init__.py @@ -1,3 +1,3 @@ -"""Auditui: Audible TUI client. One folder per module; all code lives inside module packages.""" +"""Auditui: Audible TUI client""" -__version__ = "0.1.6" +__version__ = "0.2.0" diff --git a/pyproject.toml b/pyproject.toml index 939ee11..69f926b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "auditui" -version = "0.1.6" +version = "0.2.0" description = "An Audible TUI client" readme = "README.md" requires-python = ">=3.10,<3.13" diff --git a/uv.lock b/uv.lock index aed524d..b451823 100644 --- a/uv.lock +++ b/uv.lock @@ -35,7 +35,7 @@ wheels = [ [[package]] name = "auditui" -version = "0.1.6" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "audible" }, From 9c198914439a9166d9810e7a54596c206c38acd9 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:21:19 +0100 Subject: [PATCH 09/35] chore: update changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61ca676..4a835ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.0] - 2026-02-18 + +### Changed + +- massive code refactoring +- complete test suite revamp + ## [0.1.6] - 2026-02-16 ### Changed From 8f8cdf7bfa04c74af950a7158553c8eb01375688 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:38:41 +0100 Subject: [PATCH 10/35] fix(downloads): prefer library metadata for author_title filenames with fallback stems --- auditui/downloads/manager.py | 104 ++++++++++++++++++++++++++--------- 1 file changed, 77 insertions(+), 27 deletions(-) diff --git a/auditui/downloads/manager.py b/auditui/downloads/manager.py index 6cc3bca..d4deb8f 100644 --- a/auditui/downloads/manager.py +++ b/auditui/downloads/manager.py @@ -1,6 +1,7 @@ """Obtains AAX files from Audible (cache or download) and provides activation bytes.""" import re +import unicodedata from pathlib import Path from urllib.parse import urlparse @@ -33,26 +34,31 @@ class DownloadManager: self.cache_dir = cache_dir self.cache_dir.mkdir(parents=True, exist_ok=True) self.chunk_size = chunk_size - self._http_client = httpx.Client( - auth=auth, timeout=30.0, follow_redirects=True) + self._http_client = httpx.Client(auth=auth, timeout=30.0, follow_redirects=True) self._download_client = httpx.Client( - timeout=httpx.Timeout(connect=30.0, read=None, - write=30.0, pool=30.0), + timeout=httpx.Timeout(connect=30.0, read=None, write=30.0, pool=30.0), follow_redirects=True, ) def get_or_download( - self, asin: str, notify: StatusCallback | None = None + self, + asin: str, + notify: StatusCallback | None = None, + preferred_title: str | None = None, + preferred_author: str | None = None, ) -> Path | None: """Return local path to AAX file; download and cache if not present.""" - 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: + filename_stems = self._get_filename_stems_from_asin( + asin, + preferred_title=preferred_title, + preferred_author=preferred_author, + ) + local_path = self.cache_dir / f"{filename_stems[0]}.aax" + cached_path = self._find_cached_path(filename_stems) + if cached_path: if notify: - notify(f"Using cached file: {local_path.name}") - return local_path + notify(f"Using cached file: {cached_path.name}") + return cached_path if notify: notify(f"Downloading to {local_path.name}...") @@ -92,12 +98,7 @@ class DownloadManager: def get_cached_path(self, asin: str) -> Path | None: """Return path to cached AAX file if it exists and is valid size.""" - 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 + return self._find_cached_path(self._get_filename_stems_from_asin(asin)) def is_cached(self, asin: str) -> bool: """Return True if the title is present in cache with valid size.""" @@ -130,20 +131,68 @@ class DownloadManager: return False def _sanitize_filename(self, filename: str) -> str: - """Remove invalid characters from filename.""" - return re.sub(r'[<>:"/\\|?*]', "_", filename) + """Normalize a filename segment with ASCII letters, digits, and dashes.""" + ascii_text = unicodedata.normalize("NFKD", filename) + ascii_text = ascii_text.encode("ascii", "ignore").decode("ascii") + ascii_text = re.sub(r"[’'`]+", "", ascii_text) + ascii_text = re.sub(r"[^A-Za-z0-9]+", "-", ascii_text) + ascii_text = re.sub(r"-+", "-", ascii_text) + ascii_text = ascii_text.strip("-._") + return ascii_text or "Unknown" + + def _find_cached_path(self, filename_stems: list[str]) -> Path | None: + """Return the first valid cached path matching any candidate filename stem.""" + for filename_stem in filename_stems: + local_path = self.cache_dir / f"{filename_stem}.aax" + if local_path.exists() and local_path.stat().st_size >= MIN_FILE_SIZE: + return local_path + return None + + def _get_filename_stems_from_asin( + self, + asin: str, + preferred_title: str | None = None, + preferred_author: str | None = None, + ) -> list[str]: + """Build preferred and fallback cache filename stems for an ASIN.""" + if preferred_title: + preferred_combined = ( + f"{self._sanitize_filename(preferred_author or 'Unknown Author')}_" + f"{self._sanitize_filename(preferred_title)}" + ) + preferred_legacy = self._sanitize_filename(preferred_title) + fallback_asin = self._sanitize_filename(asin) + return list( + dict.fromkeys([preferred_combined, preferred_legacy, fallback_asin]) + ) - def _get_name_from_asin(self, asin: str) -> str | None: - """Get the title/name of a book from its ASIN.""" try: product_info = self.client.get( path=f"1.0/catalog/products/{asin}", - response_groups="product_desc,product_attrs", + **{"response_groups": "contributors,product_desc,product_attrs"}, ) product = product_info.get("product", {}) - return product.get("title") or "Unknown Title" - except (OSError, ValueError, KeyError): - return None + title = product.get("title") or "Unknown Title" + author = self._get_primary_author(product) + combined = ( + f"{self._sanitize_filename(author)}_{self._sanitize_filename(title)}" + ) + legacy_title = self._sanitize_filename(title) + fallback_asin = self._sanitize_filename(asin) + return list(dict.fromkeys([combined, legacy_title, fallback_asin])) + except (OSError, ValueError, KeyError, AttributeError): + return [self._sanitize_filename(asin)] + + def _get_primary_author(self, product: dict) -> str: + """Extract a primary author name from product metadata.""" + contributors = product.get("authors") or product.get("contributors") or [] + for contributor in contributors: + if not isinstance(contributor, dict): + continue + name = contributor.get("name") + if isinstance(name, str) and name.strip(): + return name + return "Unknown Author" def _get_download_link( self, @@ -174,7 +223,8 @@ class DownloadManager: if not link: link = str(response.url) - tld = self.auth.locale.domain + locale = getattr(self.auth, "locale", None) + tld = getattr(locale, "domain", "com") return link.replace("cds.audible.com", f"cds.audible.{tld}") except httpx.HTTPError as exc: From 95e641a527a599861320d308ca5066d5cafef9b0 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:38:46 +0100 Subject: [PATCH 11/35] fix(app): pass selected item title and author as download naming hints --- auditui/app/actions.py | 51 +++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/auditui/app/actions.py b/auditui/app/actions.py index bbbe517..95f9329 100644 --- a/auditui/app/actions.py +++ b/auditui/app/actions.py @@ -10,11 +10,8 @@ from ..ui import FilterScreen, HelpScreen, StatsScreen class AppActionsMixin: - def _get_selected_asin(self) -> str | None: - if not self.download_manager: - self.update_status( - "Not authenticated. Please restart and authenticate.") - return None + def _get_selected_item(self) -> dict | None: + """Return the currently selected library item from the table.""" table = self.query_one("#library_table", DataTable) if table.row_count == 0: self.update_status("No books available") @@ -23,10 +20,27 @@ class AppActionsMixin: if cursor_row >= len(self.current_items): self.update_status("Invalid selection") return None + return self.current_items[cursor_row] + + def _get_naming_hints(self, item: dict | None) -> tuple[str | None, str | None]: + """Return preferred title and author values used for download filenames.""" + if not item or not self.library_client: + return (None, None) + return ( + self.library_client.extract_title(item), + self.library_client.extract_authors(item), + ) + + def _get_selected_asin(self) -> str | None: + if not self.download_manager: + self.update_status("Not authenticated. Please restart and authenticate.") + return None if not self.library_client: self.update_status("Library client not available") return None - selected_item = self.current_items[cursor_row] + selected_item = self._get_selected_item() + if not selected_item: + return None asin = self.library_client.extract_asin(selected_item) if not asin: self.update_status("Could not get ASIN for selected book") @@ -36,7 +50,7 @@ class AppActionsMixin: def action_play_selected(self) -> None: asin = self._get_selected_asin() if asin: - self._start_playback_async(asin) + self._start_playback_async(asin, self._get_selected_item()) def action_toggle_playback(self) -> None: if not self.playback.toggle_playback(): @@ -86,8 +100,7 @@ class AppActionsMixin: return if self.library_client.is_finished(selected_item): - self.call_from_thread(self.update_status, - "Already marked as finished") + self.call_from_thread(self.update_status, "Already marked as finished") return success = self.library_client.mark_as_finished(asin, selected_item) @@ -132,28 +145,36 @@ class AppActionsMixin: def action_toggle_download(self) -> None: asin = self._get_selected_asin() if asin: - self._toggle_download_async(asin) + self._toggle_download_async(asin, self._get_selected_item()) @work(exclusive=True, thread=True) - def _toggle_download_async(self, asin: str) -> None: + def _toggle_download_async(self, asin: str, item: dict | None = None) -> None: if not self.download_manager: return + preferred_title, preferred_author = self._get_naming_hints(item) + if self.download_manager.is_cached(asin): - self.download_manager.remove_cached( - asin, self._thread_status_update) + self.download_manager.remove_cached(asin, self._thread_status_update) else: self.download_manager.get_or_download( - asin, self._thread_status_update) + asin, + self._thread_status_update, + preferred_title=preferred_title, + preferred_author=preferred_author, + ) self.call_from_thread(self._refresh_table) @work(exclusive=True, thread=True) - def _start_playback_async(self, asin: str) -> None: + def _start_playback_async(self, asin: str, item: dict | None = None) -> None: if not self.download_manager: return + preferred_title, preferred_author = self._get_naming_hints(item) self.playback.prepare_and_start( self.download_manager, asin, self._thread_status_update, + preferred_title, + preferred_author, ) From 76c991600ce3441dd646d7b681aafee27894b03f Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:38:50 +0100 Subject: [PATCH 12/35] fix(playback): forward preferred title and author to download manager --- auditui/playback/controller_lifecycle.py | 35 ++++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/auditui/playback/controller_lifecycle.py b/auditui/playback/controller_lifecycle.py index 23946ee..7e4095d 100644 --- a/auditui/playback/controller_lifecycle.py +++ b/auditui/playback/controller_lifecycle.py @@ -45,12 +45,16 @@ class ControllerLifecycleMixin(ControllerStateMixin): try: proc, return_code = process_mod.run_ffplay(cmd) if proc is None: - if return_code == 0 and start_position > 0 and self.total_duration and start_position >= self.total_duration - 5: + if ( + return_code == 0 + and start_position > 0 + and self.total_duration + and start_position >= self.total_duration - 5 + ): notify("Reached end of file") self._reset_state() return False - notify( - f"Playback process exited immediately (code: {return_code})") + notify(f"Playback process exited immediately (code: {return_code})") return False self.playback_process = proc self.is_playing = True @@ -114,6 +118,8 @@ class ControllerLifecycleMixin(ControllerStateMixin): download_manager: DownloadManager, asin: str, status_callback: StatusCallback | None = None, + preferred_title: str | None = None, + preferred_author: str | None = None, ) -> bool: """Download AAX if needed, get activation bytes, then start playback. Returns True on success.""" notify = status_callback or self.notify @@ -121,7 +127,12 @@ class ControllerLifecycleMixin(ControllerStateMixin): notify("Could not download file") return False notify("Preparing playback...") - local_path = download_manager.get_or_download(asin, notify) + local_path = download_manager.get_or_download( + asin, + notify, + preferred_title=preferred_title, + preferred_author=preferred_author, + ) if not local_path: notify("Could not download file") return False @@ -136,14 +147,15 @@ class ControllerLifecycleMixin(ControllerStateMixin): last = self.library_client.get_last_position(asin) if last is not None and last > 0: start_position = last - notify( - f"Resuming from {LibraryClient.format_time(start_position)}") + notify(f"Resuming from {LibraryClient.format_time(start_position)}") except (OSError, ValueError, KeyError): pass notify(f"Starting playback of {local_path.name}...") self.current_asin = asin self.last_save_time = time.time() - return self.start(local_path, activation_hex, notify, start_position, self.playback_speed) + return self.start( + local_path, activation_hex, notify, start_position, self.playback_speed + ) def toggle_playback(self) -> bool: """Toggle between pause and resume. Returns True if an action was performed.""" @@ -160,7 +172,10 @@ class ControllerLifecycleMixin(ControllerStateMixin): return True def _restart_at_position( - self, new_position: float, new_speed: float | None = None, message: str | None = None + self, + new_position: float, + new_speed: float | None = None, + message: str | None = None, ) -> bool: """Stop current process and start again at new_position; optionally set speed and notify.""" if not self.is_playing or not self.current_file_path: @@ -170,7 +185,9 @@ class ControllerLifecycleMixin(ControllerStateMixin): speed = new_speed if new_speed is not None else saved["speed"] self._stop_process() time.sleep(0.2) - if self.start(saved["file_path"], saved["activation"], self.notify, new_position, speed): + if self.start( + saved["file_path"], saved["activation"], self.notify, new_position, speed + ): self.current_asin = saved["asin"] self.total_duration = saved["duration"] self.chapters = saved["chapters"] From 25d56cf40774be1bfa588beb609d2af85fa46e97 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:38:54 +0100 Subject: [PATCH 13/35] test(app): cover selected item hint forwarding for downloads --- tests/app/test_app_actions_download_hints.py | 52 ++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/app/test_app_actions_download_hints.py diff --git a/tests/app/test_app_actions_download_hints.py b/tests/app/test_app_actions_download_hints.py new file mode 100644 index 0000000..f8a9147 --- /dev/null +++ b/tests/app/test_app_actions_download_hints.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +from auditui.app.actions import AppActionsMixin + + +@dataclass(slots=True) +class FakeTable: + """Minimal table shim exposing cursor and row count.""" + + row_count: int + cursor_row: int = 0 + + +class DummyActionsApp(AppActionsMixin): + """Minimal app host used for download naming hint tests.""" + + def __init__(self) -> None: + """Initialize state required by action helpers.""" + self.current_items: list[dict] = [] + self.download_manager = object() + self.library_client = type( + "Library", (), {"extract_asin": lambda self, item: item.get("asin")} + )() + self._table = FakeTable(row_count=0, cursor_row=0) + + def update_status(self, message: str) -> None: + """Ignore status in this focused behavior test.""" + del message + + def query_one(self, selector: str, _type: object) -> FakeTable: + """Return the fake table used in selection tests.""" + assert selector == "#library_table" + return self._table + + +def test_action_toggle_download_passes_selected_item() -> None: + """Ensure download toggle forwards selected item for naming hints.""" + app = DummyActionsApp() + seen: list[tuple[str, str | None]] = [] + + def capture_toggle(asin: str, item: dict | None = None) -> None: + """Capture download toggle arguments for assertions.""" + seen.append((asin, item.get("title") if item else None)) + + setattr(cast(Any, app), "_toggle_download_async", capture_toggle) + app._table = FakeTable(row_count=1, cursor_row=0) + app.current_items = [{"asin": "ASIN", "title": "Book"}] + app.action_toggle_download() + assert seen == [("ASIN", "Book")] From 597e82dc20ddcd3144e88ef38158e0beab90c58d Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:38:58 +0100 Subject: [PATCH 14/35] test(app): verify playback start receives selected item metadata --- ...test_app_actions_selection_and_controls.py | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/tests/app/test_app_actions_selection_and_controls.py b/tests/app/test_app_actions_selection_and_controls.py index d708eff..2a8d0da 100644 --- a/tests/app/test_app_actions_selection_and_controls.py +++ b/tests/app/test_app_actions_selection_and_controls.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any, cast from auditui.app.actions import AppActionsMixin @@ -41,7 +42,13 @@ class DummyActionsApp(AppActionsMixin): self.current_items: list[dict] = [] self.download_manager = object() self.library_client = type( - "Library", (), {"extract_asin": lambda self, item: item.get("asin")} + "Library", + (), + { + "extract_asin": lambda self, item: item.get("asin"), + "extract_title": lambda self, item: item.get("title"), + "extract_authors": lambda self, item: item.get("authors"), + }, )() self.playback = FakePlayback(True) self.filter_text = "hello" @@ -61,10 +68,6 @@ class DummyActionsApp(AppActionsMixin): """Record refresh invocations for filter tests.""" self._refreshed += 1 - def _start_playback_async(self, asin: str) -> None: - """Capture async playback launch argument.""" - self.messages.append(f"start:{asin}") - def test_get_selected_asin_requires_non_empty_table() -> None: """Ensure selection fails gracefully when table has no rows.""" @@ -85,10 +88,18 @@ def test_get_selected_asin_returns_current_row_asin() -> None: def test_action_play_selected_starts_async_playback() -> None: """Ensure play action calls async starter with selected ASIN.""" app = DummyActionsApp() + seen: list[str] = [] + + def capture_start(asin: str, item: dict | None = None) -> None: + """Capture playback start arguments for assertions.""" + suffix = f":{item.get('title')}" if item else "" + seen.append(f"start:{asin}{suffix}") + + setattr(cast(Any, app), "_start_playback_async", capture_start) app._table = FakeTable(row_count=1, cursor_row=0) - app.current_items = [{"asin": "ASIN"}] + app.current_items = [{"asin": "ASIN", "title": "Book"}] app.action_play_selected() - assert app.messages[-1] == "start:ASIN" + assert seen[-1] == "start:ASIN:Book" def test_action_toggle_playback_shows_hint_when_no_playback() -> None: From 0cf2644f55570add0a6485a19e676ad5637178d0 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:39:04 +0100 Subject: [PATCH 15/35] test(downloads): validate author_title stem generation and cache fallbacks --- ...t_download_manager_cache_and_validation.py | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/tests/downloads/test_download_manager_cache_and_validation.py b/tests/downloads/test_download_manager_cache_and_validation.py index 6c7a6ae..e78869d 100644 --- a/tests/downloads/test_download_manager_cache_and_validation.py +++ b/tests/downloads/test_download_manager_cache_and_validation.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any, cast import pytest @@ -17,9 +18,11 @@ def _manager_with_cache_dir(tmp_path: Path) -> DownloadManager: def test_sanitize_filename_replaces_invalid_characters() -> None: - """Ensure filesystem-invalid symbols are replaced with underscores.""" + """Ensure filename normalization uses ASCII words and dashes.""" manager = DownloadManager.__new__(DownloadManager) - assert manager._sanitize_filename('a<>:"/\\|?*b') == "a_________b" + assert ( + manager._sanitize_filename("Stephen King 11/22/63") == "Stephen-King-11-22-63" + ) def test_validate_download_url_accepts_only_http_schemes() -> None: @@ -35,8 +38,12 @@ def test_get_cached_path_and_remove_cached( ) -> None: """Ensure cache lookup and cache deletion work for valid files.""" manager = _manager_with_cache_dir(tmp_path) - monkeypatch.setattr(manager, "_get_name_from_asin", lambda asin: "My Book") - cached_path = tmp_path / "My Book.aax" + monkeypatch.setattr( + manager, + "_get_filename_stems_from_asin", + lambda asin: ["Stephen-King_11-22-63", "11-22-63"], + ) + cached_path = tmp_path / "Stephen-King_11-22-63.aax" cached_path.write_bytes(b"0" * MIN_FILE_SIZE) messages: list[str] = [] assert manager.get_cached_path("ASIN123") == cached_path @@ -51,7 +58,34 @@ def test_get_cached_path_ignores_small_files( ) -> None: """Ensure undersized files are not treated as valid cache entries.""" manager = _manager_with_cache_dir(tmp_path) - monkeypatch.setattr(manager, "_get_name_from_asin", lambda asin: "My Book") - cached_path = tmp_path / "My Book.aax" + monkeypatch.setattr( + manager, + "_get_filename_stems_from_asin", + lambda asin: ["Stephen-King_11-22-63", "11-22-63"], + ) + cached_path = tmp_path / "Stephen-King_11-22-63.aax" cached_path.write_bytes(b"0" * (MIN_FILE_SIZE - 1)) assert manager.get_cached_path("ASIN123") is None + + +def test_get_filename_stems_include_author_title_and_legacy_title() -> None: + """Ensure filename candidates include new author_title and legacy title names.""" + manager = DownloadManager.__new__(DownloadManager) + manager.client = cast( + Any, + type( + "Client", + (), + { + "get": lambda self, path, **kwargs: { + "product": { + "title": "11/22/63", + "authors": [{"name": "Stephen King"}], + } + } + }, + )(), + ) + stems = manager._get_filename_stems_from_asin("B00TEST") + assert stems[0] == "Stephen-King_11-22-63" + assert "11-22-63" in stems From 6335f8bbac7104ed298e1caa7a7e66abf7a3b758 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:39:09 +0100 Subject: [PATCH 16/35] test(downloads): cover preferred naming hint propagation in get_or_download --- .../test_download_manager_workflow.py | 59 ++++++++++++++++--- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/tests/downloads/test_download_manager_workflow.py b/tests/downloads/test_download_manager_workflow.py index cb146c9..0ad30a9 100644 --- a/tests/downloads/test_download_manager_workflow.py +++ b/tests/downloads/test_download_manager_workflow.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any, cast import pytest @@ -14,9 +15,14 @@ def _bare_manager(tmp_path: Path) -> DownloadManager: manager = DownloadManager.__new__(DownloadManager) manager.cache_dir = tmp_path manager.chunk_size = 1024 - manager.auth = type( - "Auth", (), {"adp_token": "x", "locale": type("Loc", (), {"domain": "fr"})()} - )() + manager.auth = cast( + Any, + type( + "Auth", + (), + {"adp_token": "x", "locale": type("Loc", (), {"domain": "fr"})()}, + )(), + ) return manager @@ -48,8 +54,12 @@ def test_get_or_download_uses_cached_file_when_available( ) -> None: """Ensure cached files bypass link generation and download work.""" manager = _bare_manager(tmp_path) - monkeypatch.setattr(manager, "_get_name_from_asin", lambda asin: "Book") - cached_path = tmp_path / "Book.aax" + monkeypatch.setattr( + manager, + "_get_filename_stems_from_asin", + lambda asin, preferred_title=None, preferred_author=None: ["Author_Book"], + ) + cached_path = tmp_path / "Author_Book.aax" cached_path.write_bytes(b"1" * MIN_FILE_SIZE) messages: list[str] = [] assert manager.get_or_download("ASIN", notify=messages.append) == cached_path @@ -61,7 +71,11 @@ def test_get_or_download_reports_invalid_url( ) -> None: """Ensure workflow reports invalid download URLs and aborts.""" manager = _bare_manager(tmp_path) - monkeypatch.setattr(manager, "_get_name_from_asin", lambda asin: "Book") + monkeypatch.setattr( + manager, + "_get_filename_stems_from_asin", + lambda asin, preferred_title=None, preferred_author=None: ["Author_Book"], + ) monkeypatch.setattr( manager, "_get_download_link", lambda asin, notify=None: "ftp://bad" ) @@ -75,7 +89,11 @@ def test_get_or_download_handles_download_failure( ) -> None: """Ensure workflow reports failures when stream download does not complete.""" manager = _bare_manager(tmp_path) - monkeypatch.setattr(manager, "_get_name_from_asin", lambda asin: "Book") + monkeypatch.setattr( + manager, + "_get_filename_stems_from_asin", + lambda asin, preferred_title=None, preferred_author=None: ["Author_Book"], + ) monkeypatch.setattr( manager, "_get_download_link", lambda asin, notify=None: "https://ok" ) @@ -83,3 +101,30 @@ def test_get_or_download_handles_download_failure( messages: list[str] = [] assert manager.get_or_download("ASIN", notify=messages.append) is None assert "Download failed" in messages + + +def test_get_or_download_uses_preferred_naming_hints( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure preferred title/author are forwarded to filename stem selection.""" + manager = _bare_manager(tmp_path) + captured: list[tuple[str | None, str | None]] = [] + + def stems( + asin: str, + preferred_title: str | None = None, + preferred_author: str | None = None, + ) -> list[str]: + """Capture naming hints and return one deterministic filename stem.""" + del asin + captured.append((preferred_title, preferred_author)) + return ["Author_Book"] + + monkeypatch.setattr(manager, "_get_filename_stems_from_asin", stems) + monkeypatch.setattr(manager, "_get_download_link", lambda asin, notify=None: None) + manager.get_or_download( + "ASIN", + preferred_title="11/22/63", + preferred_author="Stephen King", + ) + assert captured == [("11/22/63", "Stephen King")] From 3e6e31c2db33cd3956d16d900253776f48f58feb Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:39:13 +0100 Subject: [PATCH 17/35] test(playback): verify prepare_and_start passes naming hints to downloads --- .../test_playback_controller_lifecycle_mixin.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/tests/playback/test_playback_controller_lifecycle_mixin.py b/tests/playback/test_playback_controller_lifecycle_mixin.py index db9cc38..7f73956 100644 --- a/tests/playback/test_playback_controller_lifecycle_mixin.py +++ b/tests/playback/test_playback_controller_lifecycle_mixin.py @@ -1,6 +1,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any, cast from auditui.playback import controller_lifecycle as lifecycle_mod from auditui.playback.controller import PlaybackController @@ -62,14 +63,21 @@ def test_prepare_and_start_uses_last_position(monkeypatch) -> None: """Ensure prepare flow resumes from saved position when available.""" messages: list[str] = [] lib = type("Lib", (), {"get_last_position": lambda self, asin: 75.0})() - controller = PlaybackController(messages.append, lib) + controller = PlaybackController(messages.append, cast(Any, lib)) started: list[tuple] = [] class DM: """Download manager shim returning path and activation token.""" - def get_or_download(self, asin, notify): + def get_or_download( + self, + asin, + notify, + preferred_title: str | None = None, + preferred_author: str | None = None, + ): """Return deterministic downloaded file path.""" + del asin, notify, preferred_title, preferred_author return Path("book.aax") def get_activation_bytes(self): @@ -78,7 +86,7 @@ def test_prepare_and_start_uses_last_position(monkeypatch) -> None: monkeypatch.setattr(controller, "start", lambda *args: started.append(args) or True) monkeypatch.setattr(lifecycle_mod.time, "time", lambda: 200.0) - assert controller.prepare_and_start(DM(), "ASIN") is True + assert controller.prepare_and_start(cast(Any, DM()), "ASIN") is True assert started and started[0][3] == 75.0 assert "Resuming from 01:15" in messages @@ -87,7 +95,7 @@ def test_toggle_playback_uses_pause_and_resume_paths(monkeypatch) -> None: """Ensure toggle dispatches pause or resume based on paused flag.""" controller, _ = _controller() controller.is_playing = True - controller.playback_process = Proc(None) + controller.playback_process = cast(Any, Proc(None)) called: list[str] = [] monkeypatch.setattr(controller, "pause", lambda: called.append("pause")) monkeypatch.setattr(controller, "resume", lambda: called.append("resume")) From a8add309287496a990885f2c1604405e5816f97b Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:39:18 +0100 Subject: [PATCH 18/35] chore(changelog): document download filename metadata fallback fix --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a835ae..623fad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - massive code refactoring - complete test suite revamp +- Updated download cache naming to use `Author_Title` format with normalized separators. + +### Fixed + +- Reused library metadata for download filename generation to avoid `Unknown-Author_Unknown-Title` when title/author are already known in the UI. ## [0.1.6] - 2026-02-16 From 307368480a5f23a4c67f57351e3c39358d43cc5e Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:39:53 +0100 Subject: [PATCH 19/35] chore: update changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 623fad5..b12b3e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - massive code refactoring - complete test suite revamp -- Updated download cache naming to use `Author_Title` format with normalized separators. +- updated download cache naming to use `Author_Title` format with normalized separators. ### Fixed -- Reused library metadata for download filename generation to avoid `Unknown-Author_Unknown-Title` when title/author are already known in the UI. +- reused library metadata for download filename generation to avoid `Unknown-Author_Unknown-Title` when title/author are already known in the UI. ## [0.1.6] - 2026-02-16 From eca58423dc3168abcfc2a40e9d92d00e0b3494c4 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:44:26 +0100 Subject: [PATCH 20/35] refactor(constants): split constants into domain modules with compatibility exports --- auditui/constants/__init__.py | 299 +++------------------------------ auditui/constants/downloads.py | 6 + auditui/constants/library.py | 5 + auditui/constants/paths.py | 8 + auditui/constants/playback.py | 3 + auditui/constants/table.py | 9 + auditui/constants/ui.py | 255 ++++++++++++++++++++++++++++ 7 files changed, 311 insertions(+), 274 deletions(-) create mode 100644 auditui/constants/downloads.py create mode 100644 auditui/constants/library.py create mode 100644 auditui/constants/paths.py create mode 100644 auditui/constants/playback.py create mode 100644 auditui/constants/table.py create mode 100644 auditui/constants/ui.py diff --git a/auditui/constants/__init__.py b/auditui/constants/__init__.py index 8471512..0390cdd 100644 --- a/auditui/constants/__init__.py +++ b/auditui/constants/__init__.py @@ -1,278 +1,29 @@ -"""Paths, API/config values, and CSS used across the application.""" +"""Compatibility exports for constants grouped by domain modules.""" -from pathlib import Path - -AUTH_PATH = Path.home() / ".config" / "auditui" / "auth.json" -CONFIG_PATH = Path.home() / ".config" / "auditui" / "config.json" -CACHE_DIR = Path.home() / ".cache" / "auditui" / "books" -DOWNLOAD_URL = "https://cde-ta-g7g.amazon.com/FionaCDEServiceEngine/FSDownloadContent" -DEFAULT_CODEC = "LC_128_44100_stereo" -MIN_FILE_SIZE = 1024 * 1024 -DEFAULT_CHUNK_SIZE = 8192 - -TABLE_COLUMN_DEFS = ( - ("Title", 4), - ("Author", 3), - ("Length", 1), - ("Progress", 1), - ("Downloaded", 1), +from .downloads import DEFAULT_CHUNK_SIZE, DEFAULT_CODEC, DOWNLOAD_URL, MIN_FILE_SIZE +from .library import ( + AUTHOR_NAME_DISPLAY_LENGTH, + AUTHOR_NAME_MAX_LENGTH, + PROGRESS_COLUMN_INDEX, ) +from .paths import AUTH_PATH, CACHE_DIR, CONFIG_PATH +from .playback import SEEK_SECONDS +from .table import TABLE_COLUMN_DEFS +from .ui import TABLE_CSS -AUTHOR_NAME_MAX_LENGTH = 40 -AUTHOR_NAME_DISPLAY_LENGTH = 37 -PROGRESS_COLUMN_INDEX = 3 -SEEK_SECONDS = 30.0 -TABLE_CSS = """ -Screen { - background: #141622; -} - -#top_bar { - background: #10131f; - color: #d5d9f0; - text-style: bold; - height: 1; - margin: 0; - padding: 0; -} - -#top_left, -#top_center, -#top_right { - width: 1fr; - padding: 0 1; - background: #10131f; - margin: 0; -} - -#top_left { - text-align: left; -} - -#top_center { - text-align: center; -} - -#top_right { - text-align: right; -} - -DataTable { - width: 100%; - height: 1fr; - background: #141622; - color: #c7cfe8; - border: solid #262a3f; - scrollbar-size-horizontal: 0; -} - -DataTable:focus { - border: solid #7aa2f7; -} - -DataTable > .datatable--header { - background: #1b2033; - color: #b9c3e3; - text-style: bold; -} - -DataTable > .datatable--cursor { - background: #232842; - color: #e6ebff; -} - -DataTable > .datatable--odd-row { - background: #121422; -} - -DataTable > .datatable--even-row { - background: #15182a; -} - -Static { - height: 1; - text-align: center; - background: #10131f; - color: #c7cfe8; -} - -Static#status { - color: #b6bfdc; -} - -Static#progress_info { - color: #7aa2f7; - text-style: bold; - margin: 0; - padding: 0; - text-align: center; - width: 100%; -} - -#progress_bar_container { - align: center middle; - width: 100%; - height: 1; -} - -ProgressBar#progress_bar { - height: 1; - background: #10131f; - border: none; - margin: 0; - padding: 0; - width: 50%; -} - -ProgressBar#progress_bar Bar { - width: 100%; -} - -ProgressBar#progress_bar > .progress-bar--track { - background: #262a3f; -} - -ProgressBar#progress_bar > .progress-bar--bar { - background: #8bd5ca; -} - -HelpScreen, -StatsScreen, -FilterScreen { - align: center middle; - background: rgba(0, 0, 0, 0.7); -} - -HelpScreen Static, -StatsScreen Static, -FilterScreen Static { - background: transparent; -} - -StatsScreen #help_container { - width: auto; - min-width: 55; - max-width: 70; -} - -StatsScreen #help_content { - align: center middle; - width: 100%; -} - -StatsScreen .help_list { - width: 100%; -} - -StatsScreen .help_list > ListItem { - background: transparent; - height: 1; -} - -StatsScreen .help_list > ListItem:hover { - background: #232842; -} - -StatsScreen .help_list > ListItem > Label { - width: 100%; - text-align: left; - padding-left: 2; -} - -#help_container { - width: 72%; - max-width: 90; - min-width: 44; - height: auto; - max-height: 80%; - min-height: 14; - background: #181a2a; - border: heavy #7aa2f7; - padding: 1 1; -} - -#help_title { - width: 100%; - height: 2; - text-align: center; - text-style: bold; - color: #7aa2f7; - content-align: center middle; - margin-bottom: 0; - border-bottom: solid #4b5165; -} - -#help_content { - width: 100%; - height: auto; - padding: 0; - margin: 0 0 1 0; - align: center middle; -} - -.help_list { - width: 100%; - height: auto; - background: transparent; - padding: 0; - scrollbar-size: 0 0; -} - -.help_list > ListItem { - background: #1b1f33; - padding: 0 1; - height: 1; -} - -.help_list > ListItem:hover { - background: #2a2f45; -} - -.help_list > ListItem > Label { - width: 100%; - padding: 0; -} - -#help_footer { - width: 100%; - height: 2; - text-align: center; - content-align: center middle; - color: #b6bfdc; - margin-top: 0; - border-top: solid #4b5165; -} - -#filter_container { - width: 60; - height: auto; - background: #181a2a; - border: heavy #7aa2f7; - padding: 1 2; -} - -#filter_title { - width: 100%; - height: 2; - text-align: center; - text-style: bold; - color: #7aa2f7; - content-align: center middle; - margin-bottom: 1; -} - -#filter_input { - width: 100%; - margin: 1 0; -} - -#filter_footer { - width: 100%; - height: 2; - text-align: center; - content-align: center middle; - color: #b6bfdc; - margin-top: 1; -} -""" +__all__ = [ + "AUTH_PATH", + "CONFIG_PATH", + "CACHE_DIR", + "DOWNLOAD_URL", + "DEFAULT_CODEC", + "MIN_FILE_SIZE", + "DEFAULT_CHUNK_SIZE", + "TABLE_COLUMN_DEFS", + "AUTHOR_NAME_MAX_LENGTH", + "AUTHOR_NAME_DISPLAY_LENGTH", + "PROGRESS_COLUMN_INDEX", + "SEEK_SECONDS", + "TABLE_CSS", +] diff --git a/auditui/constants/downloads.py b/auditui/constants/downloads.py new file mode 100644 index 0000000..4549e15 --- /dev/null +++ b/auditui/constants/downloads.py @@ -0,0 +1,6 @@ +"""Download-related constants for Audible file retrieval.""" + +DOWNLOAD_URL = "https://cde-ta-g7g.amazon.com/FionaCDEServiceEngine/FSDownloadContent" +DEFAULT_CODEC = "LC_128_44100_stereo" +MIN_FILE_SIZE = 1024 * 1024 +DEFAULT_CHUNK_SIZE = 8192 diff --git a/auditui/constants/library.py b/auditui/constants/library.py new file mode 100644 index 0000000..cc7e3b9 --- /dev/null +++ b/auditui/constants/library.py @@ -0,0 +1,5 @@ +"""Library and table formatting constants.""" + +AUTHOR_NAME_MAX_LENGTH = 40 +AUTHOR_NAME_DISPLAY_LENGTH = 37 +PROGRESS_COLUMN_INDEX = 3 diff --git a/auditui/constants/paths.py b/auditui/constants/paths.py new file mode 100644 index 0000000..252fc77 --- /dev/null +++ b/auditui/constants/paths.py @@ -0,0 +1,8 @@ +"""Filesystem paths used by configuration and caching.""" + +from pathlib import Path + + +AUTH_PATH = Path.home() / ".config" / "auditui" / "auth.json" +CONFIG_PATH = Path.home() / ".config" / "auditui" / "config.json" +CACHE_DIR = Path.home() / ".cache" / "auditui" / "books" diff --git a/auditui/constants/playback.py b/auditui/constants/playback.py new file mode 100644 index 0000000..36a4df7 --- /dev/null +++ b/auditui/constants/playback.py @@ -0,0 +1,3 @@ +"""Playback behavior constants.""" + +SEEK_SECONDS = 30.0 diff --git a/auditui/constants/table.py b/auditui/constants/table.py new file mode 100644 index 0000000..c546a17 --- /dev/null +++ b/auditui/constants/table.py @@ -0,0 +1,9 @@ +"""Main library table column definitions.""" + +TABLE_COLUMN_DEFS = ( + ("Title", 4), + ("Author", 3), + ("Length", 1), + ("Progress", 1), + ("Downloaded", 1), +) diff --git a/auditui/constants/ui.py b/auditui/constants/ui.py new file mode 100644 index 0000000..05668b7 --- /dev/null +++ b/auditui/constants/ui.py @@ -0,0 +1,255 @@ +"""Textual CSS constants for the application UI.""" + +TABLE_CSS = """ +Screen { + background: #141622; +} + +#top_bar { + background: #10131f; + color: #d5d9f0; + text-style: bold; + height: 1; + margin: 0; + padding: 0; +} + +#top_left, +#top_center, +#top_right { + width: 1fr; + padding: 0 1; + background: #10131f; + margin: 0; +} + +#top_left { + text-align: left; +} + +#top_center { + text-align: center; +} + +#top_right { + text-align: right; +} + +DataTable { + width: 100%; + height: 1fr; + background: #141622; + color: #c7cfe8; + border: solid #262a3f; + scrollbar-size-horizontal: 0; +} + +DataTable:focus { + border: solid #7aa2f7; +} + +DataTable > .datatable--header { + background: #1b2033; + color: #b9c3e3; + text-style: bold; +} + +DataTable > .datatable--cursor { + background: #232842; + color: #e6ebff; +} + +DataTable > .datatable--odd-row { + background: #121422; +} + +DataTable > .datatable--even-row { + background: #15182a; +} + +Static { + height: 1; + text-align: center; + background: #10131f; + color: #c7cfe8; +} + +Static#status { + color: #b6bfdc; +} + +Static#progress_info { + color: #7aa2f7; + text-style: bold; + margin: 0; + padding: 0; + text-align: center; + width: 100%; +} + +#progress_bar_container { + align: center middle; + width: 100%; + height: 1; +} + +ProgressBar#progress_bar { + height: 1; + background: #10131f; + border: none; + margin: 0; + padding: 0; + width: 50%; +} + +ProgressBar#progress_bar Bar { + width: 100%; +} + +ProgressBar#progress_bar > .progress-bar--track { + background: #262a3f; +} + +ProgressBar#progress_bar > .progress-bar--bar { + background: #8bd5ca; +} + +HelpScreen, +StatsScreen, +FilterScreen { + align: center middle; + background: rgba(0, 0, 0, 0.7); +} + +HelpScreen Static, +StatsScreen Static, +FilterScreen Static { + background: transparent; +} + +StatsScreen #help_container { + width: auto; + min-width: 55; + max-width: 70; +} + +StatsScreen #help_content { + align: center middle; + width: 100%; +} + +StatsScreen .help_list { + width: 100%; +} + +StatsScreen .help_list > ListItem { + background: transparent; + height: 1; +} + +StatsScreen .help_list > ListItem:hover { + background: #232842; +} + +StatsScreen .help_list > ListItem > Label { + width: 100%; + text-align: left; + padding-left: 2; +} + +#help_container { + width: 72%; + max-width: 90; + min-width: 44; + height: auto; + max-height: 80%; + min-height: 14; + background: #181a2a; + border: heavy #7aa2f7; + padding: 1 1; +} + +#help_title { + width: 100%; + height: 2; + text-align: center; + text-style: bold; + color: #7aa2f7; + content-align: center middle; + margin-bottom: 0; + border-bottom: solid #4b5165; +} + +#help_content { + width: 100%; + height: auto; + padding: 0; + margin: 0 0 1 0; + align: center middle; +} + +.help_list { + width: 100%; + height: auto; + background: transparent; + padding: 0; + scrollbar-size: 0 0; +} + +.help_list > ListItem { + background: #1b1f33; + padding: 0 1; + height: 1; +} + +.help_list > ListItem:hover { + background: #2a2f45; +} + +.help_list > ListItem > Label { + width: 100%; + padding: 0; +} + +#help_footer { + width: 100%; + height: 2; + text-align: center; + content-align: center middle; + color: #b6bfdc; + margin-top: 0; + border-top: solid #4b5165; +} + +#filter_container { + width: 60; + height: auto; + background: #181a2a; + border: heavy #7aa2f7; + padding: 1 2; +} + +#filter_title { + width: 100%; + height: 2; + text-align: center; + text-style: bold; + color: #7aa2f7; + content-align: center middle; + margin-bottom: 1; +} + +#filter_input { + width: 100%; + margin: 1 0; +} + +#filter_footer { + width: 100%; + height: 2; + text-align: center; + content-align: center middle; + color: #b6bfdc; + margin-top: 1; +} +""" From e813267d5ede8ca90808011600f55294f4945a70 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:54:16 +0100 Subject: [PATCH 21/35] refactor(library): decompose monolithic LibraryClient into fetch/extract/positions/finished/format mixins while preserving public behavior --- auditui/library/client.py | 372 ++-------------------------- auditui/library/client_extract.py | 84 +++++++ auditui/library/client_fetch.py | 127 ++++++++++ auditui/library/client_finished.py | 70 ++++++ auditui/library/client_format.py | 37 +++ auditui/library/client_positions.py | 85 +++++++ 6 files changed, 419 insertions(+), 356 deletions(-) create mode 100644 auditui/library/client_extract.py create mode 100644 auditui/library/client_fetch.py create mode 100644 auditui/library/client_finished.py create mode 100644 auditui/library/client_format.py create mode 100644 auditui/library/client_positions.py diff --git a/auditui/library/client.py b/auditui/library/client.py index 58ae618..74da5cc 100644 --- a/auditui/library/client.py +++ b/auditui/library/client.py @@ -1,365 +1,25 @@ -"""Client for the Audible library API.""" +"""Client facade for Audible library fetch, extraction, and progress updates.""" -from concurrent.futures import ThreadPoolExecutor, as_completed +from __future__ import annotations import audible -from ..types import LibraryItem, StatusCallback +from .client_extract import LibraryClientExtractMixin +from .client_fetch import LibraryClientFetchMixin +from .client_finished import LibraryClientFinishedMixin +from .client_format import LibraryClientFormatMixin +from .client_positions import LibraryClientPositionsMixin -class LibraryClient: - """Client for the Audible library API. Fetches items, extracts metadata, and updates positions.""" +class LibraryClient( + LibraryClientFetchMixin, + LibraryClientExtractMixin, + LibraryClientPositionsMixin, + LibraryClientFinishedMixin, + LibraryClientFormatMixin, +): + """Audible library client composed from focused behavior mixins.""" def __init__(self, client: audible.Client) -> None: + """Store authenticated Audible client used by all operations.""" self.client = client - - def fetch_all_items(self, on_progress: StatusCallback | None = None) -> list[LibraryItem]: - """Fetch all library items from the API.""" - response_groups = ( - "contributors,media,product_attrs,product_desc,product_details," - "is_finished,listening_status,percent_complete" - ) - return self._fetch_all_pages(response_groups, on_progress) - - def _fetch_page( - self, page: int, page_size: int, response_groups: str - ) -> tuple[int, list[LibraryItem]]: - """Fetch a single page of library items from the API.""" - library = self.client.get( - path="library", - num_results=page_size, - page=page, - response_groups=response_groups, - ) - items = library.get("items", []) - return page, list(items) - - def _fetch_all_pages( - self, response_groups: str, on_progress: StatusCallback | None = None - ) -> list[LibraryItem]: - """Fetch all pages of library items using parallel requests.""" - library_response = None - page_size = 200 - - for attempt_size in [200, 100, 50]: - try: - library_response = self.client.get( - path="library", - num_results=attempt_size, - page=1, - response_groups=response_groups, - ) - page_size = attempt_size - break - except Exception: - continue - - if not library_response: - return [] - - first_page_items = library_response.get("items", []) - if not first_page_items: - return [] - - all_items: list[LibraryItem] = list(first_page_items) - if on_progress: - on_progress(f"Fetched page 1 ({len(first_page_items)} items)...") - - if len(first_page_items) < page_size: - return all_items - - total_items_estimate = library_response.get( - "total_results") or library_response.get("total") - if total_items_estimate: - estimated_pages = (total_items_estimate + - page_size - 1) // page_size - estimated_pages = min(estimated_pages, 1000) - else: - estimated_pages = 500 - - max_workers = 50 - page_results: dict[int, list[LibraryItem]] = {} - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_to_page: dict = {} - - for page in range(2, estimated_pages + 1): - future = executor.submit( - self._fetch_page, page, page_size, response_groups - ) - future_to_page[future] = page - - completed_count = 0 - total_items = len(first_page_items) - - for future in as_completed(future_to_page): - page_num = future_to_page.pop(future) - try: - fetched_page, items = future.result() - if not items or len(items) < page_size: - for remaining_future in list(future_to_page.keys()): - remaining_future.cancel() - break - - page_results[fetched_page] = items - total_items += len(items) - completed_count += 1 - if on_progress and completed_count % 20 == 0: - on_progress( - f"Fetched {completed_count} pages ({total_items} items)..." - ) - - except Exception: - pass - - for page_num in sorted(page_results.keys()): - all_items.extend(page_results[page_num]) - - return all_items - - def extract_title(self, item: LibraryItem) -> str: - """Return the book title from a 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: LibraryItem) -> str: - """Return comma-separated author names from a 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", []) - - author_names = [a.get("name", "") - for a in authors if isinstance(a, dict)] - return ", ".join(author_names) or "Unknown" - - def extract_runtime_minutes(self, item: LibraryItem) -> int | None: - """Return runtime in minutes if present.""" - 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) or item.get(field) - if runtime is not None: - break - - if runtime is None: - return None - - if isinstance(runtime, dict): - return int(runtime.get("min", 0)) - if isinstance(runtime, (int, float)): - return int(runtime) - return None - - def extract_progress_info(self, item: LibraryItem) -> float | None: - """Return progress percentage (0–100) if present.""" - percent_complete = item.get("percent_complete") - listening_status = item.get("listening_status", {}) - - if isinstance(listening_status, dict) and percent_complete is None: - percent_complete = listening_status.get("percent_complete") - - return float(percent_complete) if percent_complete is not None else None - - def extract_asin(self, item: LibraryItem) -> str | None: - """Return the ASIN for a library item.""" - product = item.get("product", {}) - return item.get("asin") or product.get("asin") - - def is_finished(self, item: LibraryItem) -> bool: - """Return True if the item is marked or inferred as finished.""" - 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) - - return bool(is_finished_flag) or ( - isinstance(percent_complete, (int, float)) - and percent_complete >= 100 - ) - - def get_last_position(self, asin: str) -> float | None: - """Get the last playback position for a book in seconds.""" - try: - response = self.client.get( - path="1.0/annotations/lastpositions", - asins=asin, - ) - annotations = response.get("asin_last_position_heard_annots", []) - - for annot in annotations: - if annot.get("asin") != asin: - continue - - last_position_heard = annot.get("last_position_heard", {}) - if not isinstance(last_position_heard, dict): - continue - - if last_position_heard.get("status") == "DoesNotExist": - return None - - position_ms = last_position_heard.get("position_ms") - if position_ms is not None: - return float(position_ms) / 1000.0 - - return None - except (OSError, ValueError, KeyError): - return None - - def _get_content_reference(self, asin: str) -> dict | None: - """Fetch content reference (ACR and version) for position updates.""" - try: - response = self.client.get( - path=f"1.0/content/{asin}/metadata", - response_groups="content_reference", - ) - content_metadata = response.get("content_metadata", {}) - content_reference = content_metadata.get("content_reference", {}) - if isinstance(content_reference, dict): - return content_reference - return None - except (OSError, ValueError, KeyError): - return None - - def _update_position(self, asin: str, position_seconds: float) -> bool: - """Persist playback position to the API. Returns True on success.""" - if position_seconds < 0: - return False - - content_ref = self._get_content_reference(asin) - if not content_ref: - return False - - acr = content_ref.get("acr") - if not acr: - return False - - body = { - "acr": acr, - "asin": asin, - "position_ms": int(position_seconds * 1000), - } - - if version := content_ref.get("version"): - body["version"] = version - - try: - self.client.put( - path=f"1.0/lastpositions/{asin}", - body=body, - ) - return True - except (OSError, ValueError, KeyError): - return False - - def save_last_position(self, asin: str, position_seconds: float) -> bool: - """Save playback position to Audible. Returns True on success.""" - if position_seconds <= 0: - return False - return self._update_position(asin, position_seconds) - - @staticmethod - def format_duration( - value: int | None, unit: str = "minutes", default_none: str | None = None - ) -> str | None: - """Format a duration value as e.g. 2h30m or 45m.""" - if value is None or value <= 0: - return default_none - - total_minutes = int(value) - if unit == "seconds": - total_minutes //= 60 - - hours, minutes = divmod(total_minutes, 60) - - if hours > 0: - return f"{hours}h{minutes:02d}" if minutes else f"{hours}h" - return f"{minutes}m" - - def mark_as_finished(self, asin: str, item: LibraryItem | None = None) -> bool: - """Mark a book as finished on Audible. Optionally mutates item in place.""" - total_ms = self._get_runtime_ms(asin, item) - if not total_ms: - return False - - position_ms = total_ms - acr = self._get_acr(asin) - if not acr: - return False - - try: - self.client.put( - path=f"1.0/lastpositions/{asin}", - body={"asin": asin, "acr": acr, "position_ms": position_ms}, - ) - if item: - item["is_finished"] = True - listening_status = item.get("listening_status", {}) - if isinstance(listening_status, dict): - listening_status["is_finished"] = True - return True - except Exception: - return False - - def _get_runtime_ms(self, asin: str, item: LibraryItem | None = None) -> int | None: - """Return total runtime in ms from item or API.""" - if item: - runtime_min = self.extract_runtime_minutes(item) - if runtime_min: - return runtime_min * 60 * 1000 - - try: - response = self.client.get( - path=f"1.0/content/{asin}/metadata", - response_groups="chapter_info", - ) - chapter_info = response.get( - "content_metadata", {}).get("chapter_info", {}) - return chapter_info.get("runtime_length_ms") - except Exception: - return None - - def _get_acr(self, asin: str) -> str | None: - """Fetch ACR token required for position and finish updates.""" - try: - response = self.client.post( - path=f"1.0/content/{asin}/licenserequest", - body={ - "response_groups": "content_reference", - "consumption_type": "Download", - "drm_type": "Adrm", - }, - ) - return response.get("content_license", {}).get("acr") - except Exception: - return None - - @staticmethod - def format_time(seconds: float) -> str: - """Format seconds as HH:MM:SS or MM:SS for display.""" - 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}" diff --git a/auditui/library/client_extract.py b/auditui/library/client_extract.py new file mode 100644 index 0000000..8c0d76e --- /dev/null +++ b/auditui/library/client_extract.py @@ -0,0 +1,84 @@ +"""Metadata extraction helpers for library items.""" + +from __future__ import annotations + +from ..types import LibraryItem + + +class LibraryClientExtractMixin: + """Extracts display and status fields from library items.""" + + def extract_title(self, item: LibraryItem) -> str: + """Return the book title from a 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: LibraryItem) -> str: + """Return comma-separated author names from a 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", []) + author_names = [ + author.get("name", "") for author in authors if isinstance(author, dict) + ] + return ", ".join(author_names) or "Unknown" + + def extract_runtime_minutes(self, item: LibraryItem) -> int | None: + """Return runtime in minutes if present.""" + 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) or item.get(field) + if runtime is not None: + break + + if runtime is None: + return None + if isinstance(runtime, dict): + return int(runtime.get("min", 0)) + if isinstance(runtime, (int, float)): + return int(runtime) + return None + + def extract_progress_info(self, item: LibraryItem) -> float | None: + """Return progress percentage (0-100) if present.""" + percent_complete = item.get("percent_complete") + listening_status = item.get("listening_status", {}) + if isinstance(listening_status, dict) and percent_complete is None: + percent_complete = listening_status.get("percent_complete") + return float(percent_complete) if percent_complete is not None else None + + def extract_asin(self, item: LibraryItem) -> str | None: + """Return the ASIN for a library item.""" + product = item.get("product", {}) + return item.get("asin") or product.get("asin") + + def is_finished(self, item: LibraryItem) -> bool: + """Return True if the item is marked or inferred as finished.""" + 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) + + return bool(is_finished_flag) or ( + isinstance(percent_complete, (int, float)) and percent_complete >= 100 + ) diff --git a/auditui/library/client_fetch.py b/auditui/library/client_fetch.py new file mode 100644 index 0000000..4294aa5 --- /dev/null +++ b/auditui/library/client_fetch.py @@ -0,0 +1,127 @@ +"""Library page fetching helpers for the Audible API client.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor, as_completed + +from typing import Any + +from ..types import LibraryItem, StatusCallback + + +class LibraryClientFetchMixin: + """Fetches all library items from paginated Audible endpoints.""" + + client: Any + + def fetch_all_items( + self, on_progress: StatusCallback | None = None + ) -> list[LibraryItem]: + """Fetch all library items from the API.""" + response_groups = ( + "contributors,media,product_attrs,product_desc,product_details," + "is_finished,listening_status,percent_complete" + ) + return self._fetch_all_pages(response_groups, on_progress) + + def _fetch_page( + self, + page: int, + page_size: int, + response_groups: str, + ) -> tuple[int, list[LibraryItem]]: + """Fetch one library page and return its index with items.""" + library = self.client.get( + path="library", + num_results=page_size, + page=page, + response_groups=response_groups, + ) + items = library.get("items", []) + return page, list(items) + + def _fetch_all_pages( + self, + response_groups: str, + on_progress: StatusCallback | None = None, + ) -> list[LibraryItem]: + """Fetch all library pages using parallel requests after page one.""" + library_response = None + page_size = 200 + + for attempt_size in [200, 100, 50]: + try: + library_response = self.client.get( + path="library", + num_results=attempt_size, + page=1, + response_groups=response_groups, + ) + page_size = attempt_size + break + except Exception: + continue + + if not library_response: + return [] + + first_page_items = library_response.get("items", []) + if not first_page_items: + return [] + + all_items: list[LibraryItem] = list(first_page_items) + if on_progress: + on_progress(f"Fetched page 1 ({len(first_page_items)} items)...") + + if len(first_page_items) < page_size: + return all_items + + total_items_estimate = library_response.get( + "total_results" + ) or library_response.get("total") + if total_items_estimate: + estimated_pages = (total_items_estimate + page_size - 1) // page_size + estimated_pages = min(estimated_pages, 1000) + else: + estimated_pages = 500 + + max_workers = 50 + page_results: dict[int, list[LibraryItem]] = {} + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + future_to_page: dict = {} + + for page in range(2, estimated_pages + 1): + future = executor.submit( + self._fetch_page, page, page_size, response_groups + ) + future_to_page[future] = page + + completed_count = 0 + total_items = len(first_page_items) + + for future in as_completed(future_to_page): + try: + fetched_page, items = future.result() + future_to_page.pop(future, None) + if not items or len(items) < page_size: + for remaining_future in list(future_to_page.keys()): + remaining_future.cancel() + break + + page_results[fetched_page] = items + total_items += len(items) + completed_count += 1 + if on_progress and completed_count % 20 == 0: + on_progress( + f"Fetched {completed_count} pages ({total_items} items)..." + ) + + except Exception: + future_to_page.pop(future, None) + pass + + for page_num in sorted(page_results.keys()): + all_items.extend(page_results[page_num]) + + return all_items diff --git a/auditui/library/client_finished.py b/auditui/library/client_finished.py new file mode 100644 index 0000000..82848c3 --- /dev/null +++ b/auditui/library/client_finished.py @@ -0,0 +1,70 @@ +"""Helpers for marking content as finished through Audible APIs.""" + +from __future__ import annotations + +from typing import Any + +from ..types import LibraryItem + + +class LibraryClientFinishedMixin: + """Marks titles as finished and mutates in-memory item state.""" + + client: Any + + def mark_as_finished(self, asin: str, item: LibraryItem | None = None) -> bool: + """Mark a book as finished on Audible and optionally update item state.""" + total_ms = self._get_runtime_ms(asin, item) + if not total_ms: + return False + + acr = self._get_acr(asin) + if not acr: + return False + + try: + self.client.put( + path=f"1.0/lastpositions/{asin}", + body={"asin": asin, "acr": acr, "position_ms": total_ms}, + ) + if item: + item["is_finished"] = True + listening_status = item.get("listening_status", {}) + if isinstance(listening_status, dict): + listening_status["is_finished"] = True + return True + except Exception: + return False + + def _get_runtime_ms(self, asin: str, item: LibraryItem | None = None) -> int | None: + """Return total runtime in milliseconds from item or metadata endpoint.""" + if item: + extract_runtime_minutes = getattr(self, "extract_runtime_minutes") + runtime_min = extract_runtime_minutes(item) + if runtime_min: + return runtime_min * 60 * 1000 + + try: + response = self.client.get( + path=f"1.0/content/{asin}/metadata", + response_groups="chapter_info", + ) + chapter_info = response.get("content_metadata", {}).get("chapter_info", {}) + return chapter_info.get("runtime_length_ms") + except Exception: + return None + + def _get_acr(self, asin: str) -> str | None: + """Fetch the ACR token required by finish/update write operations.""" + try: + response = self.client.post( + path=f"1.0/content/{asin}/licenserequest", + body={ + "response_groups": "content_reference", + "consumption_type": "Download", + "drm_type": "Adrm", + }, + ) + return response.get("content_license", {}).get("acr") + except Exception: + return None diff --git a/auditui/library/client_format.py b/auditui/library/client_format.py new file mode 100644 index 0000000..c4dc422 --- /dev/null +++ b/auditui/library/client_format.py @@ -0,0 +1,37 @@ +"""Formatting helpers exposed by the library client.""" + +from __future__ import annotations + + +class LibraryClientFormatMixin: + """Formats durations and timestamps for display usage.""" + + @staticmethod + def format_duration( + value: int | None, + unit: str = "minutes", + default_none: str | None = None, + ) -> str | None: + """Format duration values as compact hour-minute strings.""" + if value is None or value <= 0: + return default_none + + total_minutes = int(value) + if unit == "seconds": + total_minutes //= 60 + + hours, minutes = divmod(total_minutes, 60) + if hours > 0: + return f"{hours}h{minutes:02d}" if minutes else f"{hours}h" + return f"{minutes}m" + + @staticmethod + def format_time(seconds: float) -> str: + """Format seconds as HH:MM:SS or MM:SS for display.""" + 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}" diff --git a/auditui/library/client_positions.py b/auditui/library/client_positions.py new file mode 100644 index 0000000..0c3b7c4 --- /dev/null +++ b/auditui/library/client_positions.py @@ -0,0 +1,85 @@ +"""Playback position read and write helpers for library content.""" + +from __future__ import annotations + +from typing import Any + + +class LibraryClientPositionsMixin: + """Handles last-position retrieval and persistence.""" + + client: Any + + def get_last_position(self, asin: str) -> float | None: + """Get the last playback position for a book in seconds.""" + try: + response = self.client.get( + path="1.0/annotations/lastpositions", + asins=asin, + ) + annotations = response.get("asin_last_position_heard_annots", []) + for annotation in annotations: + if annotation.get("asin") != asin: + continue + last_position_heard = annotation.get("last_position_heard", {}) + if not isinstance(last_position_heard, dict): + continue + if last_position_heard.get("status") == "DoesNotExist": + return None + position_ms = last_position_heard.get("position_ms") + if position_ms is not None: + return float(position_ms) / 1000.0 + return None + except (OSError, ValueError, KeyError): + return None + + def _get_content_reference(self, asin: str) -> dict | None: + """Fetch content reference payload used by position update calls.""" + try: + response = self.client.get( + path=f"1.0/content/{asin}/metadata", + response_groups="content_reference", + ) + content_metadata = response.get("content_metadata", {}) + content_reference = content_metadata.get("content_reference", {}) + if isinstance(content_reference, dict): + return content_reference + return None + except (OSError, ValueError, KeyError): + return None + + def _update_position(self, asin: str, position_seconds: float) -> bool: + """Persist playback position to the API and return success state.""" + if position_seconds < 0: + return False + + content_ref = self._get_content_reference(asin) + if not content_ref: + return False + + acr = content_ref.get("acr") + if not acr: + return False + + body = { + "acr": acr, + "asin": asin, + "position_ms": int(position_seconds * 1000), + } + if version := content_ref.get("version"): + body["version"] = version + + try: + self.client.put( + path=f"1.0/lastpositions/{asin}", + body=body, + ) + return True + except (OSError, ValueError, KeyError): + return False + + def save_last_position(self, asin: str, position_seconds: float) -> bool: + """Save playback position to Audible and return success state.""" + if position_seconds <= 0: + return False + return self._update_position(asin, position_seconds) From beca8ee0856e2ea16679c32d8fca803c7fd6db54 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:56:44 +0100 Subject: [PATCH 22/35] perf(library): optimize paginated fetch with bounded concurrent scheduling --- auditui/library/client_fetch.py | 94 ++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 26 deletions(-) diff --git a/auditui/library/client_fetch.py b/auditui/library/client_fetch.py index 4294aa5..5475deb 100644 --- a/auditui/library/client_fetch.py +++ b/auditui/library/client_fetch.py @@ -3,7 +3,6 @@ from __future__ import annotations from concurrent.futures import ThreadPoolExecutor, as_completed - from typing import Any from ..types import LibraryItem, StatusCallback @@ -76,39 +75,73 @@ class LibraryClientFetchMixin: if len(first_page_items) < page_size: return all_items + estimated_pages = self._estimate_total_pages( + library_response, page_size) + page_results = self._fetch_remaining_pages( + response_groups=response_groups, + page_size=page_size, + estimated_pages=estimated_pages, + initial_total=len(first_page_items), + on_progress=on_progress, + ) + + for page_num in sorted(page_results.keys()): + all_items.extend(page_results[page_num]) + + return all_items + + def _estimate_total_pages(self, library_response: dict, page_size: int) -> int: + """Estimate total pages from API metadata with a conservative cap.""" total_items_estimate = library_response.get( "total_results" ) or library_response.get("total") - if total_items_estimate: - estimated_pages = (total_items_estimate + page_size - 1) // page_size - estimated_pages = min(estimated_pages, 1000) - else: - estimated_pages = 500 + if not total_items_estimate: + return 500 + estimated_pages = (total_items_estimate + page_size - 1) // page_size + return min(estimated_pages, 1000) - max_workers = 50 + def _fetch_remaining_pages( + self, + response_groups: str, + page_size: int, + estimated_pages: int, + initial_total: int, + on_progress: StatusCallback | None = None, + ) -> dict[int, list[LibraryItem]]: + """Fetch pages 2..N with bounded in-flight requests for faster startup.""" page_results: dict[int, list[LibraryItem]] = {} + max_workers = min(16, max(1, estimated_pages - 1)) + next_page_to_submit = 2 + stop_page = estimated_pages + 1 + completed_count = 0 + total_items = initial_total with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_page: dict = {} - for page in range(2, estimated_pages + 1): + while ( + next_page_to_submit <= estimated_pages + and next_page_to_submit < stop_page + and len(future_to_page) < max_workers + ): future = executor.submit( - self._fetch_page, page, page_size, response_groups + self._fetch_page, + next_page_to_submit, + page_size, + response_groups, ) - future_to_page[future] = page + future_to_page[future] = next_page_to_submit + next_page_to_submit += 1 - completed_count = 0 - total_items = len(first_page_items) - - for future in as_completed(future_to_page): + while future_to_page: + future = next(as_completed(future_to_page)) + page_num = future_to_page.pop(future) try: fetched_page, items = future.result() - future_to_page.pop(future, None) - if not items or len(items) < page_size: - for remaining_future in list(future_to_page.keys()): - remaining_future.cancel() - break + except Exception: + continue + if items: page_results[fetched_page] = items total_items += len(items) completed_count += 1 @@ -116,12 +149,21 @@ class LibraryClientFetchMixin: on_progress( f"Fetched {completed_count} pages ({total_items} items)..." ) + if len(items) < page_size: + stop_page = min(stop_page, fetched_page) - except Exception: - future_to_page.pop(future, None) - pass + while ( + next_page_to_submit <= estimated_pages + and next_page_to_submit < stop_page + and len(future_to_page) < max_workers + ): + next_future = executor.submit( + self._fetch_page, + next_page_to_submit, + page_size, + response_groups, + ) + future_to_page[next_future] = next_page_to_submit + next_page_to_submit += 1 - for page_num in sorted(page_results.keys()): - all_items.extend(page_results[page_num]) - - return all_items + return page_results From dcb43f65dde0abd30c408db62e1ce324c8a05f3a Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 03:57:51 +0100 Subject: [PATCH 23/35] chore: fix wording --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b12b3e1..f53992d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - reused library metadata for download filename generation to avoid `Unknown-Author_Unknown-Title` when title/author are already known in the UI. +- optimize pagination fetch with bounded concurrent scheduling in library ## [0.1.6] - 2026-02-16 From da20e84513b414b19e3d9d45cb97f7400404fa4a Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:02:20 +0100 Subject: [PATCH 24/35] docs: update readme --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9a81576..aa2e617 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A terminal-based user interface (TUI) client for [Audible](https://www.audible.fr/), written in Python 3. -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. +The interface currently ships with a single built-in theme. ## Requirements @@ -36,9 +36,9 @@ auditui --version All set, run `auditui configure` to set up authentication, and then `auditui` to start the TUI. -### Workaround for Python 3.13 linux distribution +### Workaround for Python 3.13 Linux distributions -On some Linux distributions, Python 3.13 is already the default. So you have to install Python 3.12 manually before using `pipx`. +On some Linux distributions, Python 3.13 is already the default. In that case, install Python 3.12 manually before using `pipx`. For Arch Linux: @@ -52,7 +52,7 @@ Once you have Python 3.12, run: pipx install git+https://git.kharec.info/Kharec/auditui.git --python python3.12 ``` -As Python <3.14 is supported on `master` branch of the upstream [`audible`](https://github.com/mkb79/Audible), this should be temporary until the next version. +This workaround is temporary and depends on upstream `audible` compatibility updates. ## Upgrade @@ -90,6 +90,8 @@ pipx upgrade auditui Books are downloaded to `~/.cache/auditui/books`. +Downloaded files use a normalized `Author_Title.aax` naming format. For example, `Stephen King` and `11/22/63` become `Stephen-King_11-22-63.aax`. + The `d` key toggles the download state for the selected book: if the book is not cached, pressing `d` will download it; if it's already cached, pressing `d` will delete it from the cache. To check the total size of your cache: From 4b1924edd8f4864427ac786fb7a5aeabf214540e Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:08:41 +0100 Subject: [PATCH 25/35] chore: update changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f53992d..310db85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - massive code refactoring - complete test suite revamp -- updated download cache naming to use `Author_Title` format with normalized separators. +- updated download cache naming to use `Author_Title` format with normalized separators ### Fixed -- reused library metadata for download filename generation to avoid `Unknown-Author_Unknown-Title` when title/author are already known in the UI. +- reused library metadata for download filename generation to avoid `Unknown-Author_Unknown-Title` when title/author are already known in the UI - optimize pagination fetch with bounded concurrent scheduling in library ## [0.1.6] - 2026-02-16 From 4ba2c43c93b96f86f227338178d3f909915de005 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:09:04 +0100 Subject: [PATCH 26/35] clean: remove unused import --- auditui/app/layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auditui/app/layout.py b/auditui/app/layout.py index 2f012fd..37972c2 100644 --- a/auditui/app/layout.py +++ b/auditui/app/layout.py @@ -8,7 +8,7 @@ from textual.events import Resize from textual.widgets import DataTable, ProgressBar, Static from .. import __version__ -from ..constants import TABLE_COLUMN_DEFS, TABLE_CSS +from ..constants import TABLE_COLUMN_DEFS class AppLayoutMixin: From ecdd953ff4e392d40c4e2730b1bacdd77ee3f974 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:12:54 +0100 Subject: [PATCH 27/35] refactor(downloads): split download streaming into focused helpers and reduce complexity --- auditui/downloads/manager.py | 72 +++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/auditui/downloads/manager.py b/auditui/downloads/manager.py index d4deb8f..477b7c5 100644 --- a/auditui/downloads/manager.py +++ b/auditui/downloads/manager.py @@ -3,6 +3,7 @@ import re import unicodedata from pathlib import Path +from typing import Any from urllib.parse import urlparse import audible @@ -30,7 +31,7 @@ class DownloadManager: chunk_size: int = DEFAULT_CHUNK_SIZE, ) -> None: self.auth = auth - self.client = client + self.client: Any = client self.cache_dir = cache_dir self.cache_dir.mkdir(parents=True, exist_ok=True) self.chunk_size = chunk_size @@ -244,19 +245,7 @@ class DownloadManager: with self._download_client.stream("GET", url) as response: response.raise_for_status() total_size = int(response.headers.get("content-length", 0)) - downloaded = 0 - - with open(dest_path, "wb") as file_handle: - for chunk in response.iter_bytes(chunk_size=self.chunk_size): - file_handle.write(chunk) - downloaded += len(chunk) - if total_size > 0 and notify: - percent = (downloaded / total_size) * 100 - downloaded_mb = downloaded / (1024 * 1024) - total_mb = total_size / (1024 * 1024) - notify( - f"Downloading: {percent:.1f}% ({downloaded_mb:.1f}/{total_mb:.1f} MB)" - ) + self._stream_to_file(response, dest_path, total_size, notify) return dest_path except httpx.HTTPStatusError as exc: @@ -264,31 +253,56 @@ class DownloadManager: notify( f"Download HTTP error: {exc.response.status_code} {exc.response.reason_phrase}" ) - try: - if dest_path.exists() and dest_path.stat().st_size < MIN_FILE_SIZE: - dest_path.unlink() - except OSError: - pass + self._cleanup_partial_file(dest_path) return None except httpx.HTTPError as exc: if notify: notify(f"Download network error: {exc!s}") - try: - if dest_path.exists() and dest_path.stat().st_size < MIN_FILE_SIZE: - dest_path.unlink() - except OSError: - pass + self._cleanup_partial_file(dest_path) return None except (OSError, ValueError, KeyError) as exc: if notify: notify(f"Download error: {exc!s}") - try: - if dest_path.exists() and dest_path.stat().st_size < MIN_FILE_SIZE: - dest_path.unlink() - except OSError: - pass + self._cleanup_partial_file(dest_path) return None + def _stream_to_file( + self, + response: httpx.Response, + dest_path: Path, + total_size: int, + notify: StatusCallback | None = None, + ) -> None: + """Write streamed response bytes to disk and emit progress messages.""" + downloaded = 0 + with open(dest_path, "wb") as file_handle: + for chunk in response.iter_bytes(chunk_size=self.chunk_size): + file_handle.write(chunk) + downloaded += len(chunk) + self._notify_download_progress(downloaded, total_size, notify) + + def _notify_download_progress( + self, + downloaded: int, + total_size: int, + notify: StatusCallback | None = None, + ) -> None: + """Emit a formatted progress message when total size is known.""" + if total_size <= 0 or not notify: + return + percent = (downloaded / total_size) * 100 + downloaded_mb = downloaded / (1024 * 1024) + total_mb = total_size / (1024 * 1024) + notify(f"Downloading: {percent:.1f}% ({downloaded_mb:.1f}/{total_mb:.1f} MB)") + + def _cleanup_partial_file(self, dest_path: Path) -> None: + """Remove undersized partial download files after transfer failures.""" + try: + if dest_path.exists() and dest_path.stat().st_size < MIN_FILE_SIZE: + dest_path.unlink() + except OSError: + return + def close(self) -> None: """Close internal HTTP clients. Safe to call multiple times.""" if hasattr(self, "_http_client"): From 0a909484e35a7989ae6ebc382c7cb0cf5d032d63 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:19:33 +0100 Subject: [PATCH 28/35] fix(downloads): retry undersized downloads and surface precise size failure messages --- auditui/downloads/manager.py | 51 +++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/auditui/downloads/manager.py b/auditui/downloads/manager.py index 477b7c5..0ffeba3 100644 --- a/auditui/downloads/manager.py +++ b/auditui/downloads/manager.py @@ -64,28 +64,61 @@ class DownloadManager: if notify: notify(f"Downloading to {local_path.name}...") + if not self._download_to_valid_file(asin, local_path, notify): + return None + + return local_path + + def _download_to_valid_file( + self, + asin: str, + local_path: Path, + notify: StatusCallback | None = None, + ) -> bool: + """Download with one retry and ensure resulting file has a valid size.""" + for attempt in range(1, 3): + if not self._attempt_download(asin, local_path, notify): + return False + if local_path.exists() and local_path.stat().st_size >= MIN_FILE_SIZE: + return True + + downloaded_size = local_path.stat().st_size if local_path.exists() else 0 + if notify and attempt == 1: + notify( + f"Downloaded file too small ({downloaded_size} bytes), retrying..." + ) + if notify and attempt == 2: + notify( + f"Download failed: file too small ({downloaded_size} bytes, expected >= {MIN_FILE_SIZE})" + ) + self._cleanup_partial_file(local_path) + + return False + + def _attempt_download( + self, + asin: str, + local_path: Path, + notify: StatusCallback | None = None, + ) -> bool: + """Perform one download attempt including link lookup and URL validation.""" dl_link = self._get_download_link(asin, notify=notify) if not dl_link: if notify: notify("Failed to get download link") - return None + return False if not self._validate_download_url(dl_link): if notify: notify("Invalid download URL") - return None + return False if not self._download_file(dl_link, local_path, notify): if notify: notify("Download failed") - return None + return False - if not local_path.exists() or local_path.stat().st_size < MIN_FILE_SIZE: - if notify: - notify("Download failed or file too small") - return None - - return local_path + return True def get_activation_bytes(self) -> str | None: """Return activation bytes as hex string for ffplay/ffmpeg.""" From bed0ac4feaeec324b2c3011d6db79bd5f98c9d52 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:19:39 +0100 Subject: [PATCH 29/35] test(downloads): add regression coverage for too-small download retry behavior --- .../test_download_manager_workflow.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/downloads/test_download_manager_workflow.py b/tests/downloads/test_download_manager_workflow.py index 0ad30a9..e8c9856 100644 --- a/tests/downloads/test_download_manager_workflow.py +++ b/tests/downloads/test_download_manager_workflow.py @@ -128,3 +128,33 @@ def test_get_or_download_uses_preferred_naming_hints( preferred_author="Stephen King", ) assert captured == [("11/22/63", "Stephen King")] + + +def test_get_or_download_retries_when_file_is_too_small( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Ensure small downloads are retried and then reported with exact byte size.""" + manager = _bare_manager(tmp_path) + monkeypatch.setattr( + manager, + "_get_filename_stems_from_asin", + lambda asin, preferred_title=None, preferred_author=None: ["Author_Book"], + ) + monkeypatch.setattr( + manager, "_get_download_link", lambda asin, notify=None: "https://ok" + ) + attempts = {"count": 0} + + def write_small_file(url: str, path: Path, notify=None) -> Path: + """Write an undersized file to trigger retry and final failure messages.""" + del url, notify + attempts["count"] += 1 + path.write_bytes(b"x" * 100) + return path + + monkeypatch.setattr(manager, "_download_file", write_small_file) + messages: list[str] = [] + assert manager.get_or_download("ASIN", notify=messages.append) is None + assert attempts["count"] == 2 + assert any("retrying" in message for message in messages) + assert any("file too small" in message for message in messages) From 5ba0fafbc126aede625b82e89b830efb71bbade2 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:20:56 +0100 Subject: [PATCH 30/35] chore: update changelog --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 310db85..501be63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - massive code refactoring - complete test suite revamp - updated download cache naming to use `Author_Title` format with normalized separators +- optimized library pagination fetch with bounded concurrent scheduling ### Fixed - reused library metadata for download filename generation to avoid `Unknown-Author_Unknown-Title` when title/author are already known in the UI -- optimize pagination fetch with bounded concurrent scheduling in library +- fixed Audible last-position request parameter handling after library client refactor +- added retry behavior and explicit size diagnostics when downloaded files are too small ## [0.1.6] - 2026-02-16 From 570639e9886404afac7df2c4455d1471bd417674 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:28:06 +0100 Subject: [PATCH 31/35] perf(library): tune first-page probe order for faster medium-library loads --- auditui/library/client_fetch.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/auditui/library/client_fetch.py b/auditui/library/client_fetch.py index 5475deb..2554172 100644 --- a/auditui/library/client_fetch.py +++ b/auditui/library/client_fetch.py @@ -17,10 +17,7 @@ class LibraryClientFetchMixin: self, on_progress: StatusCallback | None = None ) -> list[LibraryItem]: """Fetch all library items from the API.""" - response_groups = ( - "contributors,media,product_attrs,product_desc,product_details," - "is_finished,listening_status,percent_complete" - ) + response_groups = "contributors,product_attrs,product_desc,is_finished,listening_status,percent_complete" return self._fetch_all_pages(response_groups, on_progress) def _fetch_page( @@ -75,8 +72,7 @@ class LibraryClientFetchMixin: if len(first_page_items) < page_size: return all_items - estimated_pages = self._estimate_total_pages( - library_response, page_size) + estimated_pages = self._estimate_total_pages(library_response, page_size) page_results = self._fetch_remaining_pages( response_groups=response_groups, page_size=page_size, From cb4104e59a1eb5f439275cb35cac91b2a516f636 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:28:13 +0100 Subject: [PATCH 32/35] perf(app): remove eager search cache priming during library load --- auditui/app/library.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/auditui/app/library.py b/auditui/app/library.py index bd2075b..daa3741 100644 --- a/auditui/app/library.py +++ b/auditui/app/library.py @@ -16,16 +16,15 @@ class AppLibraryMixin: return try: - all_items = self.library_client.fetch_all_items( - self._thread_status_update) + all_items = self.library_client.fetch_all_items(self._thread_status_update) self.call_from_thread(self.on_library_loaded, all_items) except (OSError, ValueError, KeyError) as exc: self.call_from_thread(self.on_library_error, str(exc)) def on_library_loaded(self, items: list[LibraryItem]) -> None: + """Store fetched items and refresh the active library view.""" self.all_items = items self._search_text_cache.clear() - self._prime_search_cache(items) self.update_status(f"Loaded {len(items)} books") if self.show_all_mode: self.show_all() From bf0e70e9d95e0a3e12aa04a71cdcf45aae695e90 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:28:20 +0100 Subject: [PATCH 33/35] fix(table): use unique row keys to avoid duplicate title crashes --- auditui/app/table.py | 37 ++++++++++++++++++++++++++++++------- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/auditui/app/table.py b/auditui/app/table.py index eeac42a..b5b2e20 100644 --- a/auditui/app/table.py +++ b/auditui/app/table.py @@ -15,6 +15,7 @@ from textual.widgets import DataTable, Static class AppTableMixin: def _populate_table(self, items: list[LibraryItem]) -> None: + """Render library items into the table with stable unique row keys.""" table = self.query_one("#library_table", DataTable) table.clear() @@ -22,18 +23,41 @@ class AppTableMixin: self.update_status("No books found.") return - for item in items: + used_keys: set[str] = set() + for index, item in enumerate(items): 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) + row_key = self._build_row_key(item, title, index, used_keys) + table.add_row(title, author, runtime, progress, downloaded, key=row_key) self.current_items = items status = self.query_one("#status", Static) status.display = False self._apply_column_widths(table) + def _build_row_key( + self, + item: LibraryItem, + title: str, + index: int, + used_keys: set[str], + ) -> str: + """Return a unique table row key derived from ASIN when available.""" + asin = self.library_client.extract_asin(item) if self.library_client else None + base_key = asin or f"{title}#{index}" + if base_key not in used_keys: + used_keys.add(base_key) + return base_key + + suffix = 2 + candidate = f"{base_key}#{suffix}" + while candidate in used_keys: + suffix += 1 + candidate = f"{base_key}#{suffix}" + used_keys.add(candidate) + return candidate + def _refresh_table(self) -> None: if self.current_items: self._populate_table(self.current_items) @@ -79,11 +103,9 @@ class AppTableMixin: items = self.all_items if self.filter_text: - items = filter_items(items, self.filter_text, - self._get_search_text) + items = filter_items(items, self.filter_text, self._get_search_text) self._populate_table(items) - self.update_status( - f"Filter: '{self.filter_text}' ({len(items)} books)") + self.update_status(f"Filter: '{self.filter_text}' ({len(items)} books)") return if not self.show_all_mode and self.library_client: @@ -97,6 +119,7 @@ class AppTableMixin: if cached is not None: return cached from ..library import build_search_text + search_text = build_search_text(item, self.library_client) self._search_text_cache[cache_key] = search_text return search_text From 175bb7cbdcafb2e838577c6c5db61b85872877af Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:28:28 +0100 Subject: [PATCH 34/35] test(app): add coverage for unique table row key generation --- tests/app/test_app_table_row_keys.py | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 tests/app/test_app_table_row_keys.py diff --git a/tests/app/test_app_table_row_keys.py b/tests/app/test_app_table_row_keys.py new file mode 100644 index 0000000..a373498 --- /dev/null +++ b/tests/app/test_app_table_row_keys.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from auditui.app.table import AppTableMixin + + +class DummyTableApp(AppTableMixin): + """Minimal host exposing library client for row key helper tests.""" + + def __init__(self) -> None: + """Initialize a fake library client with ASIN extraction.""" + self.library_client = type( + "Library", + (), + {"extract_asin": lambda self, item: item.get("asin")}, + )() + + +def test_build_row_key_prefers_asin_and_remains_unique() -> None: + """Ensure duplicate ASINs receive deterministic unique key suffixes.""" + app = DummyTableApp() + used: set[str] = set() + item = {"asin": "ASIN1"} + first = app._build_row_key(item, "Title", 0, used) + second = app._build_row_key(item, "Title", 1, used) + assert first == "ASIN1" + assert second == "ASIN1#2" + + +def test_build_row_key_falls_back_to_title_and_index() -> None: + """Ensure missing ASIN values use title-index fallback keys.""" + app = DummyTableApp() + used: set[str] = set() + key = app._build_row_key({"asin": None}, "Unknown Title", 3, used) + assert key == "Unknown Title#3" From 26cba97cbdae11eaf4e41ea04d0a780192799023 Mon Sep 17 00:00:00 2001 From: Kharec Date: Wed, 18 Feb 2026 04:28:35 +0100 Subject: [PATCH 35/35] chore(changelog): document load-time tuning and duplicate row key fix --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 501be63..8c7a53f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - complete test suite revamp - updated download cache naming to use `Author_Title` format with normalized separators - optimized library pagination fetch with bounded concurrent scheduling +- adjusted library first-page probe order to prefer larger page sizes for medium libraries +- removed eager search cache priming during library load to reduce startup work ### Fixed - reused library metadata for download filename generation to avoid `Unknown-Author_Unknown-Title` when title/author are already known in the UI - fixed Audible last-position request parameter handling after library client refactor - added retry behavior and explicit size diagnostics when downloaded files are too small +- prevented table rendering crashes by generating unique row keys instead of using title-only keys ## [0.1.6] - 2026-02-16