Compare commits
35 Commits
bc24439da8
...
new-archit
| Author | SHA1 | Date | |
|---|---|---|---|
| 26cba97cbd | |||
| 175bb7cbdc | |||
| bf0e70e9d9 | |||
| cb4104e59a | |||
| 570639e988 | |||
| 5ba0fafbc1 | |||
| bed0ac4fea | |||
| 0a909484e3 | |||
| ecdd953ff4 | |||
| 4ba2c43c93 | |||
| 4b1924edd8 | |||
| da20e84513 | |||
| dcb43f65dd | |||
| beca8ee085 | |||
| e813267d5e | |||
| eca58423dc | |||
| 307368480a | |||
| a8add30928 | |||
| 3e6e31c2db | |||
| 6335f8bbac | |||
| 0cf2644f55 | |||
| 597e82dc20 | |||
| 25d56cf407 | |||
| 76c991600c | |||
| 95e641a527 | |||
| 8f8cdf7bfa | |||
| 9c19891443 | |||
| 01de75871a | |||
| e88dcee155 | |||
| 4bc9b3fd3f | |||
| cd99960f2f | |||
| bd2bd43e7f | |||
| 7f5e3266be | |||
| 184585bed0 | |||
| 8e73e45e2d |
18
CHANGELOG.md
18
CHANGELOG.md
@@ -5,6 +5,24 @@ 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/),
|
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).
|
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
|
||||||
|
- 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
|
## [0.1.6] - 2026-02-16
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A terminal-based user interface (TUI) client for [Audible](https://www.audible.fr/), written in Python 3.
|
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
|
## Requirements
|
||||||
|
|
||||||
@@ -36,9 +36,9 @@ auditui --version
|
|||||||
|
|
||||||
All set, run `auditui configure` to set up authentication, and then `auditui` to start the TUI.
|
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:
|
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
|
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
|
## Upgrade
|
||||||
|
|
||||||
@@ -90,6 +90,8 @@ pipx upgrade auditui
|
|||||||
|
|
||||||
Books are downloaded to `~/.cache/auditui/books`.
|
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.
|
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:
|
To check the total size of your cache:
|
||||||
@@ -145,7 +147,7 @@ This project uses [uv](https://github.com/astral-sh/uv) for dependency managemen
|
|||||||
$ uv sync
|
$ uv sync
|
||||||
# modify the code...
|
# modify the code...
|
||||||
# ...and run the TUI
|
# ...and run the TUI
|
||||||
$ uv run python -m auditui.cli
|
$ uv run auditui
|
||||||
```
|
```
|
||||||
|
|
||||||
Don't forget to run the tests.
|
Don't forget to run the tests.
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""Auditui package"""
|
"""Auditui: Audible TUI client"""
|
||||||
|
|
||||||
__version__ = "0.1.6"
|
__version__ = "0.2.0"
|
||||||
|
|||||||
614
auditui/app.py
614
auditui/app.py
@@ -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,
|
|
||||||
)
|
|
||||||
30
auditui/app/__init__.py
Normal file
30
auditui/app/__init__.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Main Textual app: table, bindings, and orchestration of library, playback, and downloads."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
|
||||||
|
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 compose(self) -> ComposeResult:
|
||||||
|
yield from AppLayoutMixin.compose(self)
|
||||||
|
|
||||||
|
def __init__(self, auth=None, client=None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
init_auditui_state(self, auth, client)
|
||||||
180
auditui/app/actions.py
Normal file
180
auditui/app/actions.py
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
"""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_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")
|
||||||
|
return None
|
||||||
|
cursor_row = table.cursor_row
|
||||||
|
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._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")
|
||||||
|
return None
|
||||||
|
return asin
|
||||||
|
|
||||||
|
def action_play_selected(self) -> None:
|
||||||
|
asin = self._get_selected_asin()
|
||||||
|
if asin:
|
||||||
|
self._start_playback_async(asin, self._get_selected_item())
|
||||||
|
|
||||||
|
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, self._get_selected_item())
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=True)
|
||||||
|
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)
|
||||||
|
else:
|
||||||
|
self.download_manager.get_or_download(
|
||||||
|
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, 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,
|
||||||
|
)
|
||||||
25
auditui/app/bindings.py
Normal file
25
auditui/app/bindings.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Key bindings for the main app."""
|
||||||
|
|
||||||
|
from textual.binding import Binding
|
||||||
|
|
||||||
|
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"),
|
||||||
|
Binding("space", "toggle_playback", "Pause/Resume", priority=True),
|
||||||
|
("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"),
|
||||||
|
]
|
||||||
111
auditui/app/layout.py
Normal file
111
auditui/app/layout.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
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(id="library_table")
|
||||||
|
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"
|
||||||
|
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))
|
||||||
|
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("#library_table", 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)
|
||||||
35
auditui/app/library.py
Normal file
35
auditui/app/library.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""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:
|
||||||
|
"""Store fetched items and refresh the active library view."""
|
||||||
|
self.all_items = items
|
||||||
|
self._search_text_cache.clear()
|
||||||
|
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}")
|
||||||
94
auditui/app/progress.py
Normal file
94
auditui/app/progress.py
Normal file
@@ -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()
|
||||||
31
auditui/app/state.py
Normal file
31
auditui/app/state.py
Normal file
@@ -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)
|
||||||
129
auditui/app/table.py
Normal file
129
auditui/app/table.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""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:
|
||||||
|
"""Render library items into the table with stable unique row keys."""
|
||||||
|
table = self.query_one("#library_table", DataTable)
|
||||||
|
table.clear()
|
||||||
|
|
||||||
|
if not items or not self.library_client:
|
||||||
|
self.update_status("No books found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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("#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("#library_table", 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)
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
"""Authentication helpers for the Auditui app."""
|
"""Load saved Audible credentials and build authenticator and API client."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import audible
|
import audible
|
||||||
|
|
||||||
from .constants import AUTH_PATH
|
from ..constants import AUTH_PATH
|
||||||
|
|
||||||
|
|
||||||
def authenticate(
|
def authenticate(
|
||||||
auth_path: Path = AUTH_PATH,
|
auth_path: Path = AUTH_PATH,
|
||||||
) -> tuple[audible.Authenticator, audible.Client]:
|
) -> 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():
|
if not auth_path.exists():
|
||||||
raise FileNotFoundError(
|
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:
|
try:
|
||||||
authenticator = audible.Authenticator.from_file(str(auth_path))
|
authenticator = audible.Authenticator.from_file(str(auth_path))
|
||||||
@@ -21,4 +22,5 @@ def authenticate(
|
|||||||
return authenticator, audible_client
|
return authenticator, audible_client
|
||||||
except (OSError, ValueError, KeyError) as exc:
|
except (OSError, ValueError, KeyError) as exc:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Failed to load existing authentication: {exc}") from exc
|
f"Failed to load existing authentication: {exc}"
|
||||||
|
) from exc
|
||||||
5
auditui/cli/__init__.py
Normal file
5
auditui/cli/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""CLI package; entry point is main() from .main."""
|
||||||
|
|
||||||
|
from .main import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
#!/usr/bin/env python3
|
"""CLI entrypoint: configure subcommand or authenticate and run the TUI."""
|
||||||
"""Auditui entrypoint."""
|
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
@@ -12,7 +11,6 @@ from auditui.constants import AUTH_PATH
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
"""Authenticate and launch the app."""
|
|
||||||
parser = argparse.ArgumentParser(prog="auditui")
|
parser = argparse.ArgumentParser(prog="auditui")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-v",
|
"-v",
|
||||||
@@ -52,7 +50,3 @@ def main() -> None:
|
|||||||
|
|
||||||
app = Auditui(auth=auth, client=client)
|
app = Auditui(auth=auth, client=client)
|
||||||
app.run()
|
app.run()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Configuration helpers for the Auditui app."""
|
"""Interactive setup of Audible credentials; writes auth and config files."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
@@ -6,13 +6,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
import audible
|
import audible
|
||||||
|
|
||||||
from .constants import AUTH_PATH, CONFIG_PATH
|
from ..constants import AUTH_PATH, CONFIG_PATH
|
||||||
|
|
||||||
|
|
||||||
def configure(
|
def configure(
|
||||||
auth_path: Path = AUTH_PATH,
|
auth_path: Path = AUTH_PATH,
|
||||||
) -> tuple[audible.Authenticator, audible.Client]:
|
) -> 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():
|
if auth_path.exists():
|
||||||
response = input(
|
response = input(
|
||||||
"Configuration already exists. Are you sure you want to overwrite it? (y/N): "
|
"Configuration already exists. Are you sure you want to overwrite it? (y/N): "
|
||||||
@@ -26,7 +26,8 @@ def configure(
|
|||||||
email = input("\nEmail: ")
|
email = input("\nEmail: ")
|
||||||
password = getpass("Password: ")
|
password = getpass("Password: ")
|
||||||
marketplace = input(
|
marketplace = input(
|
||||||
"Marketplace locale (default: US): ").strip().upper() or "US"
|
"Marketplace locale (default: US): "
|
||||||
|
).strip().upper() or "US"
|
||||||
|
|
||||||
authenticator = audible.Authenticator.from_login(
|
authenticator = audible.Authenticator.from_login(
|
||||||
username=email, password=password, locale=marketplace
|
username=email, password=password, locale=marketplace
|
||||||
29
auditui/constants/__init__.py
Normal file
29
auditui/constants/__init__.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""Compatibility exports for constants grouped by domain modules."""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
__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",
|
||||||
|
]
|
||||||
6
auditui/constants/downloads.py
Normal file
6
auditui/constants/downloads.py
Normal file
@@ -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
|
||||||
5
auditui/constants/library.py
Normal file
5
auditui/constants/library.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Library and table formatting constants."""
|
||||||
|
|
||||||
|
AUTHOR_NAME_MAX_LENGTH = 40
|
||||||
|
AUTHOR_NAME_DISPLAY_LENGTH = 37
|
||||||
|
PROGRESS_COLUMN_INDEX = 3
|
||||||
8
auditui/constants/paths.py
Normal file
8
auditui/constants/paths.py
Normal file
@@ -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"
|
||||||
3
auditui/constants/playback.py
Normal file
3
auditui/constants/playback.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Playback behavior constants."""
|
||||||
|
|
||||||
|
SEEK_SECONDS = 30.0
|
||||||
9
auditui/constants/table.py
Normal file
9
auditui/constants/table.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Main library table column definitions."""
|
||||||
|
|
||||||
|
TABLE_COLUMN_DEFS = (
|
||||||
|
("Title", 4),
|
||||||
|
("Author", 3),
|
||||||
|
("Length", 1),
|
||||||
|
("Progress", 1),
|
||||||
|
("Downloaded", 1),
|
||||||
|
)
|
||||||
@@ -1,27 +1,4 @@
|
|||||||
"""Shared constants for the Auditui application."""
|
"""Textual CSS constants for the application UI."""
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
AUTHOR_NAME_MAX_LENGTH = 40
|
|
||||||
AUTHOR_NAME_DISPLAY_LENGTH = 37
|
|
||||||
PROGRESS_COLUMN_INDEX = 3
|
|
||||||
SEEK_SECONDS = 30.0
|
|
||||||
|
|
||||||
TABLE_CSS = """
|
TABLE_CSS = """
|
||||||
Screen {
|
Screen {
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
"""Download helpers for Audible content."""
|
|
||||||
|
|
||||||
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 (
|
|
||||||
CACHE_DIR,
|
|
||||||
DEFAULT_CHUNK_SIZE,
|
|
||||||
DEFAULT_CODEC,
|
|
||||||
DOWNLOAD_URL,
|
|
||||||
MIN_FILE_SIZE,
|
|
||||||
)
|
|
||||||
|
|
||||||
StatusCallback = Callable[[str], None]
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadManager:
|
|
||||||
"""Handle retrieval and download of Audible titles."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
auth: audible.Authenticator,
|
|
||||||
client: audible.Client,
|
|
||||||
cache_dir: Path = CACHE_DIR,
|
|
||||||
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
||||||
) -> None:
|
|
||||||
self.auth = auth
|
|
||||||
self.client = client
|
|
||||||
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._download_client = httpx.Client(
|
|
||||||
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."""
|
|
||||||
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:
|
|
||||||
if notify:
|
|
||||||
notify(f"Using cached file: {local_path.name}")
|
|
||||||
return local_path
|
|
||||||
|
|
||||||
if notify:
|
|
||||||
notify(f"Downloading to {local_path.name}...")
|
|
||||||
|
|
||||||
dl_link = self._get_download_link(asin, notify=notify)
|
|
||||||
if not dl_link:
|
|
||||||
if notify:
|
|
||||||
notify("Failed to get download link")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not self._validate_download_url(dl_link):
|
|
||||||
if notify:
|
|
||||||
notify("Invalid download URL")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not self._download_file(dl_link, local_path, notify):
|
|
||||||
if notify:
|
|
||||||
notify("Download failed")
|
|
||||||
return None
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
def get_activation_bytes(self) -> str | None:
|
|
||||||
"""Get activation bytes as hex string."""
|
|
||||||
try:
|
|
||||||
activation_bytes = get_activation_bytes(self.auth)
|
|
||||||
if isinstance(activation_bytes, bytes):
|
|
||||||
return activation_bytes.hex()
|
|
||||||
return str(activation_bytes)
|
|
||||||
except (OSError, ValueError, KeyError, AttributeError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_cached_path(self, asin: str) -> Path | None:
|
|
||||||
"""Get the cached file path for a book if it exists."""
|
|
||||||
title = self._get_name_from_asin(asin) or asin
|
|
||||||
safe_title = self._sanitize_filename(title)
|
|
||||||
local_path = self.cache_dir / f"{safe_title}.aax"
|
|
||||||
if local_path.exists() and local_path.stat().st_size >= MIN_FILE_SIZE:
|
|
||||||
return local_path
|
|
||||||
return None
|
|
||||||
|
|
||||||
def is_cached(self, asin: str) -> bool:
|
|
||||||
"""Check if a book is already cached."""
|
|
||||||
return self.get_cached_path(asin) is not None
|
|
||||||
|
|
||||||
def remove_cached(self, asin: str, notify: StatusCallback | None = None) -> bool:
|
|
||||||
"""Remove a cached book file."""
|
|
||||||
cached_path = self.get_cached_path(asin)
|
|
||||||
if not cached_path:
|
|
||||||
if notify:
|
|
||||||
notify("Book is not cached")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
cached_path.unlink()
|
|
||||||
if notify:
|
|
||||||
notify(f"Removed from cache: {cached_path.name}")
|
|
||||||
return True
|
|
||||||
except OSError as exc:
|
|
||||||
if notify:
|
|
||||||
notify(f"Failed to remove cache: {exc}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _validate_download_url(self, url: str) -> bool:
|
|
||||||
"""Validate that the URL is a valid HTTP/HTTPS URL."""
|
|
||||||
try:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _sanitize_filename(self, filename: str) -> str:
|
|
||||||
"""Remove invalid characters from filename."""
|
|
||||||
return re.sub(r'[<>:"/\\|?*]', "_", filename)
|
|
||||||
|
|
||||||
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",
|
|
||||||
)
|
|
||||||
product = product_info.get("product", {})
|
|
||||||
return product.get("title") or "Unknown Title"
|
|
||||||
except (OSError, ValueError, KeyError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_download_link(
|
|
||||||
self,
|
|
||||||
asin: str,
|
|
||||||
codec: str = DEFAULT_CODEC,
|
|
||||||
notify: StatusCallback | None = None,
|
|
||||||
) -> str | None:
|
|
||||||
"""Get download link for book."""
|
|
||||||
if self.auth.adp_token is None:
|
|
||||||
if notify:
|
|
||||||
notify("Missing ADP token (not authenticated?)")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
params = {
|
|
||||||
"type": "AUDI",
|
|
||||||
"currentTransportMethod": "WIFI",
|
|
||||||
"key": asin,
|
|
||||||
"codec": codec,
|
|
||||||
}
|
|
||||||
response = self._http_client.get(
|
|
||||||
url=DOWNLOAD_URL,
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
link = response.headers.get("Location")
|
|
||||||
if not link:
|
|
||||||
link = str(response.url)
|
|
||||||
|
|
||||||
tld = self.auth.locale.domain
|
|
||||||
return link.replace("cds.audible.com", f"cds.audible.{tld}")
|
|
||||||
|
|
||||||
except httpx.HTTPError as exc:
|
|
||||||
if notify:
|
|
||||||
notify(f"Download-link request failed: {exc!s}")
|
|
||||||
return None
|
|
||||||
except (OSError, ValueError, KeyError, AttributeError) as exc:
|
|
||||||
if notify:
|
|
||||||
notify(f"Download-link error: {exc!s}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _download_file(
|
|
||||||
self, url: str, dest_path: Path, notify: StatusCallback | None = None
|
|
||||||
) -> Path | None:
|
|
||||||
"""Download file from URL to destination."""
|
|
||||||
try:
|
|
||||||
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)"
|
|
||||||
)
|
|
||||||
|
|
||||||
return dest_path
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
if notify:
|
|
||||||
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
|
|
||||||
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
|
|
||||||
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
|
|
||||||
return None
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""Close the HTTP clients and release resources."""
|
|
||||||
if hasattr(self, "_http_client"):
|
|
||||||
self._http_client.close()
|
|
||||||
if hasattr(self, "_download_client"):
|
|
||||||
self._download_client.close()
|
|
||||||
5
auditui/downloads/__init__.py
Normal file
5
auditui/downloads/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Download and cache of Audible AAX files."""
|
||||||
|
|
||||||
|
from .manager import DownloadManager
|
||||||
|
|
||||||
|
__all__ = ["DownloadManager"]
|
||||||
344
auditui/downloads/manager.py
Normal file
344
auditui/downloads/manager.py
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
"""Obtains AAX files from Audible (cache or download) and provides activation bytes."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import audible
|
||||||
|
import httpx
|
||||||
|
from audible.activation_bytes import get_activation_bytes
|
||||||
|
|
||||||
|
from ..constants import (
|
||||||
|
CACHE_DIR,
|
||||||
|
DEFAULT_CHUNK_SIZE,
|
||||||
|
DEFAULT_CODEC,
|
||||||
|
DOWNLOAD_URL,
|
||||||
|
MIN_FILE_SIZE,
|
||||||
|
)
|
||||||
|
from ..types import StatusCallback
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadManager:
|
||||||
|
"""Obtains AAX files from Audible (cache or download) and provides activation bytes."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
auth: audible.Authenticator,
|
||||||
|
client: audible.Client,
|
||||||
|
cache_dir: Path = CACHE_DIR,
|
||||||
|
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||||
|
) -> None:
|
||||||
|
self.auth = auth
|
||||||
|
self.client: Any = client
|
||||||
|
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._download_client = httpx.Client(
|
||||||
|
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,
|
||||||
|
preferred_title: str | None = None,
|
||||||
|
preferred_author: str | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""Return local path to AAX file; download and cache if not present."""
|
||||||
|
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: {cached_path.name}")
|
||||||
|
return cached_path
|
||||||
|
|
||||||
|
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 False
|
||||||
|
|
||||||
|
if not self._validate_download_url(dl_link):
|
||||||
|
if notify:
|
||||||
|
notify("Invalid download URL")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self._download_file(dl_link, local_path, notify):
|
||||||
|
if notify:
|
||||||
|
notify("Download failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_activation_bytes(self) -> str | None:
|
||||||
|
"""Return activation bytes as hex string for ffplay/ffmpeg."""
|
||||||
|
try:
|
||||||
|
activation_bytes = get_activation_bytes(self.auth)
|
||||||
|
if isinstance(activation_bytes, bytes):
|
||||||
|
return activation_bytes.hex()
|
||||||
|
return str(activation_bytes)
|
||||||
|
except (OSError, ValueError, KeyError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_cached_path(self, asin: str) -> Path | None:
|
||||||
|
"""Return path to cached AAX file if it exists and is valid size."""
|
||||||
|
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."""
|
||||||
|
return self.get_cached_path(asin) is not None
|
||||||
|
|
||||||
|
def remove_cached(self, asin: str, notify: StatusCallback | None = None) -> bool:
|
||||||
|
"""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:
|
||||||
|
notify("Book is not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
cached_path.unlink()
|
||||||
|
if notify:
|
||||||
|
notify(f"Removed from cache: {cached_path.name}")
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
if notify:
|
||||||
|
notify(f"Failed to remove cache: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _validate_download_url(self, url: str) -> bool:
|
||||||
|
"""Validate that the URL is a valid HTTP/HTTPS URL."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _sanitize_filename(self, filename: str) -> str:
|
||||||
|
"""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])
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
product_info = self.client.get(
|
||||||
|
path=f"1.0/catalog/products/{asin}",
|
||||||
|
**{"response_groups": "contributors,product_desc,product_attrs"},
|
||||||
|
)
|
||||||
|
product = product_info.get("product", {})
|
||||||
|
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,
|
||||||
|
asin: str,
|
||||||
|
codec: str = DEFAULT_CODEC,
|
||||||
|
notify: StatusCallback | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""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?)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"type": "AUDI",
|
||||||
|
"currentTransportMethod": "WIFI",
|
||||||
|
"key": asin,
|
||||||
|
"codec": codec,
|
||||||
|
}
|
||||||
|
response = self._http_client.get(
|
||||||
|
url=DOWNLOAD_URL,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
link = response.headers.get("Location")
|
||||||
|
if not link:
|
||||||
|
link = str(response.url)
|
||||||
|
|
||||||
|
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:
|
||||||
|
if notify:
|
||||||
|
notify(f"Download-link request failed: {exc!s}")
|
||||||
|
return None
|
||||||
|
except (OSError, ValueError, KeyError, AttributeError) as exc:
|
||||||
|
if notify:
|
||||||
|
notify(f"Download-link error: {exc!s}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _download_file(
|
||||||
|
self, url: str, dest_path: Path, notify: StatusCallback | None = None
|
||||||
|
) -> Path | None:
|
||||||
|
"""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()
|
||||||
|
total_size = int(response.headers.get("content-length", 0))
|
||||||
|
self._stream_to_file(response, dest_path, total_size, notify)
|
||||||
|
|
||||||
|
return dest_path
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if notify:
|
||||||
|
notify(
|
||||||
|
f"Download HTTP error: {exc.response.status_code} {exc.response.reason_phrase}"
|
||||||
|
)
|
||||||
|
self._cleanup_partial_file(dest_path)
|
||||||
|
return None
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
if notify:
|
||||||
|
notify(f"Download network error: {exc!s}")
|
||||||
|
self._cleanup_partial_file(dest_path)
|
||||||
|
return None
|
||||||
|
except (OSError, ValueError, KeyError) as exc:
|
||||||
|
if notify:
|
||||||
|
notify(f"Download error: {exc!s}")
|
||||||
|
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"):
|
||||||
|
self._http_client.close()
|
||||||
|
if hasattr(self, "_download_client"):
|
||||||
|
self._download_client.close()
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
"""Library helpers for fetching and formatting Audible data."""
|
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
import audible
|
|
||||||
|
|
||||||
|
|
||||||
ProgressCallback = Callable[[str], None]
|
|
||||||
|
|
||||||
|
|
||||||
class LibraryClient:
|
|
||||||
"""Helper for interacting with the Audible library."""
|
|
||||||
|
|
||||||
def __init__(self, client: audible.Client) -> None:
|
|
||||||
self.client = client
|
|
||||||
|
|
||||||
def fetch_all_items(self, on_progress: ProgressCallback | None = None) -> list:
|
|
||||||
"""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[dict]]:
|
|
||||||
"""Fetch a single page of library 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: ProgressCallback | None = None
|
|
||||||
) -> list:
|
|
||||||
"""Fetch all pages of library items from the API using maximum parallel fetching."""
|
|
||||||
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[dict] = 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[dict]] = {}
|
|
||||||
|
|
||||||
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: dict) -> str:
|
|
||||||
"""Extract title from library item."""
|
|
||||||
product = item.get("product", {})
|
|
||||||
return (
|
|
||||||
product.get("title")
|
|
||||||
or item.get("title")
|
|
||||||
or product.get("asin", "Unknown Title")
|
|
||||||
)
|
|
||||||
|
|
||||||
def extract_authors(self, item: dict) -> str:
|
|
||||||
"""Extract author names from library item."""
|
|
||||||
product = item.get("product", {})
|
|
||||||
authors = product.get("authors") or product.get("contributors") or []
|
|
||||||
if not authors and "authors" in item:
|
|
||||||
authors = item.get("authors", [])
|
|
||||||
|
|
||||||
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: dict) -> int | None:
|
|
||||||
"""Extract runtime in minutes from library item."""
|
|
||||||
product = item.get("product", {})
|
|
||||||
runtime_fields = [
|
|
||||||
"runtime_length_min",
|
|
||||||
"runtime_length",
|
|
||||||
"vLength",
|
|
||||||
"length",
|
|
||||||
"duration",
|
|
||||||
]
|
|
||||||
|
|
||||||
runtime = None
|
|
||||||
for field in runtime_fields:
|
|
||||||
runtime = product.get(field) 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: dict) -> float | None:
|
|
||||||
"""Extract progress percentage from library item."""
|
|
||||||
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: dict) -> str | None:
|
|
||||||
"""Extract ASIN from 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."""
|
|
||||||
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:
|
|
||||||
"""Get content reference data including ACR and version."""
|
|
||||||
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:
|
|
||||||
"""Update the playback position for a book."""
|
|
||||||
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 the last playback position for a book."""
|
|
||||||
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 duration value into a compact string."""
|
|
||||||
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: dict | None = None) -> bool:
|
|
||||||
"""Mark a book as finished by setting position to the end."""
|
|
||||||
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: dict | None = None) -> int | None:
|
|
||||||
"""Get total runtime in milliseconds."""
|
|
||||||
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:
|
|
||||||
"""Get ACR token needed for position 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."""
|
|
||||||
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}"
|
|
||||||
22
auditui/library/__init__.py
Normal file
22
auditui/library/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
25
auditui/library/client.py
Normal file
25
auditui/library/client.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""Client facade for Audible library fetch, extraction, and progress updates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import audible
|
||||||
|
|
||||||
|
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(
|
||||||
|
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
|
||||||
84
auditui/library/client_extract.py
Normal file
84
auditui/library/client_extract.py
Normal file
@@ -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
|
||||||
|
)
|
||||||
165
auditui/library/client_fetch.py
Normal file
165
auditui/library/client_fetch.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
"""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,product_attrs,product_desc,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
|
||||||
|
|
||||||
|
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 not total_items_estimate:
|
||||||
|
return 500
|
||||||
|
estimated_pages = (total_items_estimate + page_size - 1) // page_size
|
||||||
|
return min(estimated_pages, 1000)
|
||||||
|
|
||||||
|
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 = {}
|
||||||
|
|
||||||
|
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,
|
||||||
|
next_page_to_submit,
|
||||||
|
page_size,
|
||||||
|
response_groups,
|
||||||
|
)
|
||||||
|
future_to_page[future] = next_page_to_submit
|
||||||
|
next_page_to_submit += 1
|
||||||
|
|
||||||
|
while future_to_page:
|
||||||
|
future = next(as_completed(future_to_page))
|
||||||
|
page_num = future_to_page.pop(future)
|
||||||
|
try:
|
||||||
|
fetched_page, items = future.result()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if items:
|
||||||
|
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)..."
|
||||||
|
)
|
||||||
|
if len(items) < page_size:
|
||||||
|
stop_page = min(stop_page, fetched_page)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
return page_results
|
||||||
70
auditui/library/client_finished.py
Normal file
70
auditui/library/client_finished.py
Normal file
@@ -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
|
||||||
37
auditui/library/client_format.py
Normal file
37
auditui/library/client_format.py
Normal file
@@ -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}"
|
||||||
85
auditui/library/client_positions.py
Normal file
85
auditui/library/client_positions.py
Normal file
@@ -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)
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
"""Search helpers for filtering library items."""
|
"""Text search over library items for the filter feature."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Callable
|
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:
|
def build_search_text(item: LibraryItem, library_client: LibraryClient | None) -> str:
|
||||||
"""Build a lowercase search string for an item."""
|
"""Build a single lowercase string from title and authors for matching."""
|
||||||
if library_client:
|
if library_client:
|
||||||
title = library_client.extract_title(item)
|
title = library_client.extract_title(item)
|
||||||
authors = library_client.extract_authors(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(
|
def filter_items(
|
||||||
items: list[dict],
|
items: list[LibraryItem],
|
||||||
filter_text: str,
|
filter_text: str,
|
||||||
get_search_text: Callable[[dict], str],
|
get_search_text: Callable[[LibraryItem], str],
|
||||||
) -> list[dict]:
|
) -> list[LibraryItem]:
|
||||||
"""Filter items by a search string."""
|
"""Return items whose search text contains filter_text (case-insensitive)."""
|
||||||
if not filter_text:
|
if not filter_text:
|
||||||
return items
|
return items
|
||||||
filter_lower = filter_text.lower()
|
filter_lower = filter_text.lower()
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
"""Utils for table operations."""
|
"""Formatting and sorting of library items for the main table."""
|
||||||
|
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from typing import TYPE_CHECKING, Callable
|
from typing import TYPE_CHECKING, Callable
|
||||||
|
|
||||||
from .constants import (
|
from ..constants import (
|
||||||
AUTHOR_NAME_DISPLAY_LENGTH,
|
AUTHOR_NAME_DISPLAY_LENGTH,
|
||||||
AUTHOR_NAME_MAX_LENGTH,
|
AUTHOR_NAME_MAX_LENGTH,
|
||||||
PROGRESS_COLUMN_INDEX,
|
PROGRESS_COLUMN_INDEX,
|
||||||
)
|
)
|
||||||
|
from ..types import LibraryItem
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .downloads import DownloadManager
|
from ..downloads import DownloadManager
|
||||||
|
|
||||||
|
|
||||||
def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]:
|
def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]:
|
||||||
"""Create a sort key function for sorting by title."""
|
"""Return a (key_fn, reverse) pair for DataTable sort by title column."""
|
||||||
def title_key(row_values):
|
def title_key(row_values):
|
||||||
title_cell = row_values[0]
|
title_cell = row_values[0]
|
||||||
if isinstance(title_cell, str):
|
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]:
|
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):
|
def progress_key(row_values):
|
||||||
progress_cell = row_values[progress_column_index]
|
progress_cell = row_values[progress_column_index]
|
||||||
if isinstance(progress_cell, str):
|
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:
|
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:
|
if author_names and len(author_names) > AUTHOR_NAME_MAX_LENGTH:
|
||||||
return f"{author_names[:AUTHOR_NAME_DISPLAY_LENGTH]}..."
|
return f"{author_names[:AUTHOR_NAME_DISPLAY_LENGTH]}..."
|
||||||
return author_names
|
return author_names
|
||||||
|
|
||||||
|
|
||||||
def format_item_as_row(item: dict, library_client, download_manager: "DownloadManager | None" = None) -> tuple[str, str, str, str, str]:
|
def format_item_as_row(item: LibraryItem, library_client, download_manager: "DownloadManager | None" = None) -> tuple[str, str, str, str, str]:
|
||||||
"""Format a library item into table row data.
|
"""Turn a library item into (title, author, runtime, progress, downloaded) for the table."""
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (title, author, runtime, progress, downloaded) strings
|
|
||||||
"""
|
|
||||||
title = library_client.extract_title(item)
|
title = library_client.extract_title(item)
|
||||||
|
|
||||||
author_names = library_client.extract_authors(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)
|
return (title, author_display, runtime_str, progress_str, downloaded_str)
|
||||||
|
|
||||||
|
|
||||||
def filter_unfinished_items(items: list[dict], library_client) -> list[dict]:
|
def filter_unfinished_items(items: list[LibraryItem], library_client) -> list[LibraryItem]:
|
||||||
"""Filter out finished items from the list."""
|
"""Return only items that are not marked as finished."""
|
||||||
return [
|
return [
|
||||||
item for item in items
|
item for item in items
|
||||||
if not library_client.is_finished(item)
|
if not library_client.is_finished(item)
|
||||||
@@ -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)
|
|
||||||
6
auditui/playback/__init__.py
Normal file
6
auditui/playback/__init__.py
Normal file
@@ -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"]
|
||||||
30
auditui/playback/chapters.py
Normal file
30
auditui/playback/chapters.py
Normal file
@@ -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
|
||||||
5
auditui/playback/constants.py
Normal file
5
auditui/playback/constants.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Speed limits and increment for playback."""
|
||||||
|
|
||||||
|
MIN_SPEED = 0.5
|
||||||
|
MAX_SPEED = 2.0
|
||||||
|
SPEED_INCREMENT = 0.5
|
||||||
14
auditui/playback/controller.py
Normal file
14
auditui/playback/controller.py
Normal file
@@ -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."""
|
||||||
200
auditui/playback/controller_lifecycle.py
Normal file
200
auditui/playback/controller_lifecycle.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""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,
|
||||||
|
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
|
||||||
|
if not download_manager:
|
||||||
|
notify("Could not download file")
|
||||||
|
return False
|
||||||
|
notify("Preparing playback...")
|
||||||
|
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
|
||||||
|
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
|
||||||
127
auditui/playback/controller_seek_speed.py
Normal file
127
auditui/playback/controller_seek_speed.py
Normal file
@@ -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)
|
||||||
124
auditui/playback/controller_state.py
Normal file
124
auditui/playback/controller_state.py
Normal file
@@ -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
|
||||||
23
auditui/playback/elapsed.py
Normal file
23
auditui/playback/elapsed.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Media information loading for Audible content."""
|
"""Duration and chapter list for AAX files via ffprobe."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import shutil
|
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]]:
|
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"):
|
if not shutil.which("ffprobe"):
|
||||||
return None, []
|
return None, []
|
||||||
|
|
||||||
68
auditui/playback/process.py
Normal file
68
auditui/playback/process.py
Normal file
@@ -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)
|
||||||
19
auditui/playback/seek.py
Normal file
19
auditui/playback/seek.py
Normal file
@@ -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")
|
||||||
5
auditui/stats/__init__.py
Normal file
5
auditui/stats/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Listening and account statistics for the stats screen."""
|
||||||
|
|
||||||
|
from .aggregator import StatsAggregator
|
||||||
|
|
||||||
|
__all__ = ["StatsAggregator"]
|
||||||
71
auditui/stats/account.py
Normal file
71
auditui/stats/account.py
Normal file
@@ -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"
|
||||||
85
auditui/stats/aggregator.py
Normal file
85
auditui/stats/aggregator.py
Normal file
@@ -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
|
||||||
155
auditui/stats/email.py
Normal file
155
auditui/stats/email.py
Normal file
@@ -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"
|
||||||
22
auditui/stats/format.py
Normal file
22
auditui/stats/format.py
Normal file
@@ -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
|
||||||
75
auditui/stats/listening.py
Normal file
75
auditui/stats/listening.py
Normal file
@@ -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))
|
||||||
8
auditui/types/__init__.py
Normal file
8
auditui/types/__init__.py
Normal file
@@ -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]
|
||||||
570
auditui/ui.py
570
auditui/ui.py
@@ -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()
|
|
||||||
7
auditui/ui/__init__.py
Normal file
7
auditui/ui/__init__.py
Normal file
@@ -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"]
|
||||||
30
auditui/ui/common.py
Normal file
30
auditui/ui/common.py
Normal file
@@ -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)
|
||||||
66
auditui/ui/filter_screen.py
Normal file
66
auditui/ui/filter_screen.py
Normal file
@@ -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()
|
||||||
54
auditui/ui/help_screen.py
Normal file
54
auditui/ui/help_screen.py
Normal file
@@ -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)
|
||||||
54
auditui/ui/stats_screen.py
Normal file
54
auditui/ui/stats_screen.py
Normal file
@@ -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)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "auditui"
|
name = "auditui"
|
||||||
version = "0.1.6"
|
version = "0.2.0"
|
||||||
description = "An Audible TUI client"
|
description = "An Audible TUI client"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10,<3.13"
|
requires-python = ">=3.10,<3.13"
|
||||||
|
|||||||
52
tests/app/test_app_actions_download_hints.py
Normal file
52
tests/app/test_app_actions_download_hints.py
Normal file
@@ -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")]
|
||||||
135
tests/app/test_app_actions_selection_and_controls.py
Normal file
135
tests/app/test_app_actions_selection_and_controls.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
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 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"),
|
||||||
|
"extract_title": lambda self, item: item.get("title"),
|
||||||
|
"extract_authors": lambda self, item: item.get("authors"),
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
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 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()
|
||||||
|
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", "title": "Book"}]
|
||||||
|
app.action_play_selected()
|
||||||
|
assert seen[-1] == "start:ASIN:Book"
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
56
tests/app/test_app_bindings_contract.py
Normal file
56
tests/app/test_app_bindings_contract.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
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 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 _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 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 only once to avoid dispatch ambiguity."""
|
||||||
|
keys = [binding[0] for binding in _all_bindings()]
|
||||||
|
assert len(keys) == len(set(keys))
|
||||||
114
tests/app/test_app_library_mixin_behavior.py
Normal file
114
tests/app/test_app_library_mixin_behavior.py
Normal file
@@ -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"
|
||||||
148
tests/app/test_app_progress_mixin_behavior.py
Normal file
148
tests/app/test_app_progress_mixin_behavior.py
Normal file
@@ -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
|
||||||
30
tests/app/test_app_progress_periodic_save.py
Normal file
30
tests/app/test_app_progress_periodic_save.py
Normal file
@@ -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
|
||||||
@@ -4,26 +4,33 @@ from dataclasses import dataclass, field
|
|||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
|
|
||||||
from auditui.app import Auditui
|
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:
|
class StubLibrary:
|
||||||
|
"""Minimal library facade used by search-related app helpers."""
|
||||||
|
|
||||||
def extract_title(self, item: dict) -> str:
|
def extract_title(self, item: dict) -> str:
|
||||||
|
"""Return title from a synthetic item."""
|
||||||
return item.get("title", "")
|
return item.get("title", "")
|
||||||
|
|
||||||
def extract_authors(self, item: dict) -> str:
|
def extract_authors(self, item: dict) -> str:
|
||||||
|
"""Return authors from a synthetic item."""
|
||||||
return item.get("authors", "")
|
return item.get("authors", "")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@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)
|
_search_text_cache: dict[int, str] = field(default_factory=dict)
|
||||||
library_client: StubLibrary = field(default_factory=StubLibrary)
|
library_client: StubLibrary = field(default_factory=StubLibrary)
|
||||||
|
|
||||||
|
|
||||||
def test_get_search_text_is_cached() -> None:
|
def test_get_search_text_is_cached() -> None:
|
||||||
|
"""Ensure repeated text extraction for one item reuses cache entries."""
|
||||||
item = {"title": "Title", "authors": "Author"}
|
item = {"title": "Title", "authors": "Author"}
|
||||||
dummy = Dummy()
|
dummy = DummyAuditui()
|
||||||
first = Auditui._get_search_text(cast(Auditui, dummy), item)
|
first = Auditui._get_search_text(cast(Auditui, dummy), item)
|
||||||
second = Auditui._get_search_text(cast(Auditui, dummy), item)
|
second = Auditui._get_search_text(cast(Auditui, dummy), item)
|
||||||
assert first == "title author"
|
assert first == "title author"
|
||||||
@@ -31,7 +38,8 @@ def test_get_search_text_is_cached() -> None:
|
|||||||
assert len(dummy._search_text_cache) == 1
|
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()
|
library = StubLibrary()
|
||||||
cache: dict[int, str] = {}
|
cache: dict[int, str] = {}
|
||||||
items = [
|
items = [
|
||||||
@@ -40,6 +48,7 @@ def test_filter_items_uses_cache() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
def cached(item: dict) -> str:
|
def cached(item: dict) -> str:
|
||||||
|
"""Build and cache normalized search text per object identity."""
|
||||||
cache_key = id(item)
|
cache_key = id(item)
|
||||||
if cache_key not in cache:
|
if cache_key not in cache:
|
||||||
cache[cache_key] = build_search_text(item, cast(Any, library))
|
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]]
|
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"}]}
|
item = {"title": "Title", "authors": [{"name": "A"}, {"name": "B"}]}
|
||||||
assert build_search_text(item, None) == "title a, b"
|
assert build_search_text(item, None) == "title a, b"
|
||||||
78
tests/app/test_app_state_initialization.py
Normal file
78
tests/app/test_app_state_initialization.py
Normal file
@@ -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
|
||||||
34
tests/app/test_app_table_row_keys.py
Normal file
34
tests/app/test_app_table_row_keys.py
Normal file
@@ -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"
|
||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
@@ -15,21 +16,26 @@ try:
|
|||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
audible_stub = ModuleType("audible")
|
audible_stub = ModuleType("audible")
|
||||||
|
|
||||||
class Authenticator: # minimal stub for type usage
|
class Authenticator:
|
||||||
|
"""Minimal audible authenticator test stub."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class Client: # minimal stub for type usage
|
class Client:
|
||||||
|
"""Minimal audible client test stub."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
audible_stub.Authenticator = Authenticator
|
setattr(cast(Any, audible_stub), "Authenticator", Authenticator)
|
||||||
audible_stub.Client = Client
|
setattr(cast(Any, audible_stub), "Client", Client)
|
||||||
|
|
||||||
activation_bytes = ModuleType("audible.activation_bytes")
|
activation_bytes = ModuleType("audible.activation_bytes")
|
||||||
|
|
||||||
def get_activation_bytes(_auth: Authenticator | None = None) -> bytes:
|
def get_activation_bytes(_auth: Authenticator | None = None) -> bytes:
|
||||||
|
"""Return deterministic empty activation bytes for tests."""
|
||||||
return b""
|
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"] = audible_stub
|
||||||
sys.modules["audible.activation_bytes"] = activation_bytes
|
sys.modules["audible.activation_bytes"] = activation_bytes
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
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 filename normalization uses ASCII words and dashes."""
|
||||||
|
manager = DownloadManager.__new__(DownloadManager)
|
||||||
|
assert (
|
||||||
|
manager._sanitize_filename("Stephen King 11/22/63") == "Stephen-King-11-22-63"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_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
|
||||||
|
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_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
|
||||||
160
tests/downloads/test_download_manager_workflow.py
Normal file
160
tests/downloads/test_download_manager_workflow.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
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 = cast(
|
||||||
|
Any,
|
||||||
|
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_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
|
||||||
|
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_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"
|
||||||
|
)
|
||||||
|
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_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"
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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")]
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
111
tests/library/test_library_client_extractors.py
Normal file
111
tests/library/test_library_client_extractors.py
Normal file
@@ -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"
|
||||||
103
tests/library/test_library_client_progress_updates.py
Normal file
103
tests/library/test_library_client_progress_updates.py
Normal file
@@ -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
|
||||||
34
tests/library/test_library_search_filters.py
Normal file
34
tests/library/test_library_search_filters.py
Normal file
@@ -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]]
|
||||||
99
tests/library/test_library_table_formatting.py
Normal file
99
tests/library/test_library_table_formatting.py
Normal file
@@ -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]]
|
||||||
34
tests/playback/test_playback_chapter_selection.py
Normal file
34
tests/playback/test_playback_chapter_selection.py
Normal file
@@ -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
|
||||||
129
tests/playback/test_playback_controller_lifecycle_mixin.py
Normal file
129
tests/playback/test_playback_controller_lifecycle_mixin.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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, cast(Any, lib))
|
||||||
|
started: list[tuple] = []
|
||||||
|
|
||||||
|
class DM:
|
||||||
|
"""Download manager shim returning path and activation token."""
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""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(cast(Any, 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 = cast(Any, 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"
|
||||||
100
tests/playback/test_playback_controller_seek_speed_mixin.py
Normal file
100
tests/playback/test_playback_controller_seek_speed_mixin.py
Normal file
@@ -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: ")
|
||||||
76
tests/playback/test_playback_controller_state_mixin.py
Normal file
76
tests/playback/test_playback_controller_state_mixin.py
Normal file
@@ -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"
|
||||||
21
tests/playback/test_playback_elapsed_math.py
Normal file
21
tests/playback/test_playback_elapsed_math.py
Normal file
@@ -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
|
||||||
67
tests/playback/test_playback_process_helpers.py
Normal file
67
tests/playback/test_playback_process_helpers.py
Normal file
@@ -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
|
||||||
20
tests/playback/test_playback_seek_targets.py
Normal file
20
tests/playback/test_playback_seek_targets.py
Normal file
@@ -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")
|
||||||
54
tests/stats/test_stats_account_data.py
Normal file
54
tests/stats/test_stats_account_data.py
Normal file
@@ -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"
|
||||||
67
tests/stats/test_stats_aggregator_output.py
Normal file
67
tests/stats/test_stats_aggregator_output.py
Normal file
@@ -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"
|
||||||
64
tests/stats/test_stats_email_resolution.py
Normal file
64
tests/stats/test_stats_email_resolution.py
Normal file
@@ -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"
|
||||||
16
tests/stats/test_stats_formatting.py
Normal file
16
tests/stats/test_stats_formatting.py
Normal file
@@ -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"
|
||||||
64
tests/stats/test_stats_listening_metrics.py
Normal file
64
tests/stats/test_stats_listening_metrics.py
Normal file
@@ -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
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from auditui import downloads
|
|
||||||
from auditui.constants import MIN_FILE_SIZE
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_filename() -> None:
|
|
||||||
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
|
|
||||||
assert dm._sanitize_filename('a<>:"/\\|?*b') == "a_________b"
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_download_url() -> None:
|
|
||||||
dm = downloads.DownloadManager.__new__(downloads.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.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 = downloads.DownloadManager.__new__(downloads.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
|
|
||||||
@@ -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
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, cast
|
|
||||||
|
|
||||||
from auditui import table_utils
|
|
||||||
|
|
||||||
|
|
||||||
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, _ = table_utils.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()
|
|
||||||
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)
|
|
||||||
assert truncated.endswith("...")
|
|
||||||
assert len(truncated) <= table_utils.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 = table_utils.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, _ = table_utils.format_item_as_row(item, library, None)
|
|
||||||
assert progress == "0%"
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_email_from_config(
|
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dummy_app: DummyApp
|
|
||||||
) -> None:
|
|
||||||
screen = ui.StatsScreen()
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_email_from_auth_file(
|
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dummy_app: DummyApp
|
|
||||||
) -> None:
|
|
||||||
screen = ui.StatsScreen()
|
|
||||||
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"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_email_from_auth(dummy_app: DummyApp) -> None:
|
|
||||||
screen = ui.StatsScreen()
|
|
||||||
|
|
||||||
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"
|
|
||||||
@@ -9,40 +9,54 @@ from textual.widgets import Input
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class DummyEvent:
|
class DummyEvent:
|
||||||
|
"""Minimal event object carrying an input value for tests."""
|
||||||
|
|
||||||
value: str
|
value: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class FakeTimer:
|
class FakeTimer:
|
||||||
|
"""Timer substitute recording whether stop() was called."""
|
||||||
|
|
||||||
callback: Callable[[], None]
|
callback: Callable[[], None]
|
||||||
stopped: bool = False
|
stopped: bool = False
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
|
"""Mark timer as stopped."""
|
||||||
self.stopped = True
|
self.stopped = True
|
||||||
|
|
||||||
|
|
||||||
def test_filter_debounce_uses_latest_value(monkeypatch) -> None:
|
def test_filter_debounce_uses_latest_value(monkeypatch) -> None:
|
||||||
|
"""Ensure debounce cancels previous timer and emits latest input value."""
|
||||||
seen: list[str] = []
|
seen: list[str] = []
|
||||||
timers: list[FakeTimer] = []
|
timers: list[FakeTimer] = []
|
||||||
|
|
||||||
def on_change(value: str) -> None:
|
def on_change(value: str) -> None:
|
||||||
|
"""Capture emitted filter values."""
|
||||||
seen.append(value)
|
seen.append(value)
|
||||||
|
|
||||||
screen = FilterScreen(on_change=on_change, debounce_seconds=0.2)
|
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)
|
timer = FakeTimer(callback)
|
||||||
timers.append(timer)
|
timers.append(timer)
|
||||||
return timer
|
return timer
|
||||||
|
|
||||||
monkeypatch.setattr(screen, "set_timer", fake_set_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("a")))
|
||||||
screen.on_input_changed(cast(Input.Changed, DummyEvent("ab")))
|
screen.on_input_changed(cast(Input.Changed, DummyEvent("ab")))
|
||||||
|
|
||||||
assert len(timers) == 2
|
assert len(timers) == 2
|
||||||
assert timers[0].stopped is True
|
assert timers[0].stopped is True
|
||||||
assert timers[1].stopped is False
|
assert timers[1].stopped is False
|
||||||
|
|
||||||
timers[1].callback()
|
timers[1].callback()
|
||||||
assert seen == ["ab"]
|
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
|
||||||
Reference in New Issue
Block a user