Compare commits
122 Commits
e663401151
...
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 | |||
| bc24439da8 | |||
| c9d6be6847 | |||
| f85a2d3cda | |||
| 7e4a57d18e | |||
| 4a7fa69c2e | |||
| 78f15b4622 | |||
| b63525060a | |||
| 79355f3bdf | |||
| 7602638ffe | |||
| dd8e513063 | |||
| bdccc3a2eb | |||
| 3ab73de2aa | |||
| f2c9d683b6 | |||
| b530238494 | |||
| 89073aaf95 | |||
| 4c27d1864c | |||
| 81814246d0 | |||
| b71e15d54c | |||
| 181b8314e6 | |||
| c6edfa5572 | |||
| ac99643dbc | |||
| 889ac62a9a | |||
| 0bf6db7980 | |||
| 6aa4ebb33f | |||
| ca43ea8858 | |||
| 733e35b0d2 | |||
| f3573dfffc | |||
| d17cb6f4d2 | |||
| 6e3eb87f76 | |||
| b5f82d6e33 | |||
| 8bddca2f75 | |||
| bb8571df8a | |||
| f528df49a9 | |||
| d40ad4534a | |||
| c9a8764286 | |||
| 1976b5d88c | |||
| a8e3972f34 | |||
| eea6f26bcf | |||
| ca70661bf6 | |||
| 7930bf6941 | |||
| 6d3e818b01 | |||
| 02c6e4cb88 | |||
| b63956c08f | |||
| f024128f85 | |||
| 6d246944a3 | |||
| e975654d87 | |||
| fbad34cc24 | |||
| c6a1374e21 | |||
| db92450c7e | |||
| c0004c554f | |||
| f565ee9dc9 | |||
| 67c44b2cb7 | |||
| 7128e3e7d4 | |||
| 290e76d289 | |||
| 678f3dac77 | |||
| 24146c8db6 | |||
| d996b1d523 | |||
| cf3dc315d7 | |||
| 3806c35140 | |||
| 974c671012 | |||
| 0cf9884c6c | |||
| 124a962d72 | |||
| bcad61d78a | |||
| f9c4771ee4 | |||
| 964b888e4c | |||
| e620ea8369 | |||
| c1dd38fbe6 | |||
| fca7329ba1 | |||
| 8fdd517933 | |||
| bec7ba5ec0 | |||
| 0505086e11 | |||
| b6c483623d | |||
| 8ee3ccfc1c | |||
| 837bb12a89 | |||
| 009111e57d | |||
| b65047d9f7 | |||
| b3ebd56151 | |||
| 2d765bbf04 | |||
| 8e41d0b002 | |||
| 74691f3322 | |||
| ff1030f4bd | |||
| 1bbd28888b | |||
| 20ef60b1e4 | |||
| d2cfebddf7 | |||
| 43c0215a6f | |||
| 7741c8adba | |||
| eaa1628fcc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
.venv
|
||||
auditui.egg-info
|
||||
__pycache__
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.13
|
||||
3.12
|
||||
94
CHANGELOG.md
Normal file
94
CHANGELOG.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.2.0] - 2026-02-18
|
||||
|
||||
### Changed
|
||||
|
||||
- massive code refactoring
|
||||
- complete test suite revamp
|
||||
- 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
|
||||
|
||||
### Changed
|
||||
|
||||
- Updated compatibility for Textual 8 APIs and typing.
|
||||
|
||||
## [0.1.5] - 2026-02-12
|
||||
|
||||
### Added
|
||||
|
||||
- Display download progress in megabytes in the main view.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Re-centered the progress bar in the updated responsive layout.
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved responsive behavior of the main layout and column proportions.
|
||||
- Polished modal styling and help screen density/alignment for better readability.
|
||||
|
||||
## [0.1.4] - 2026-01-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Centered the progress bar container.
|
||||
- Constrained progress bar width to prevent layout overflow.
|
||||
|
||||
## [0.1.3] - 2026-01-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved library fetching performance by requesting pages concurrently.
|
||||
|
||||
## [0.1.2] - 2026-01-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added a shared search helper module.
|
||||
- Added test configuration and development dependencies.
|
||||
- Added test coverage for filter/search helpers, cache and URL helpers, library parsing, table utilities, email extraction, and filter debounce behavior.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored existing search/filter logic to use shared helpers.
|
||||
- Improved packaging and CI setup for project distribution.
|
||||
|
||||
## [0.1.1] - 2026-01-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added playback speed controls with up/down key bindings.
|
||||
- Added an `f` key binding to toggle finished/unfinished status.
|
||||
- Added responsive table columns and a redesigned top bar/help experience.
|
||||
- Added a stats screen with listening/account statistics and persisted email config.
|
||||
- Added a debounced filter view with cached search and refresh toggle.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refined UI layout and styling with a broad responsive redesign.
|
||||
- Updated behavior so finished books are removed from cache.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Corrected Python version and installation compatibility details for Audible/pipx environments.
|
||||
|
||||
## [0.1.0] - 2025-12-25
|
||||
|
||||
FIRST VERSION
|
||||
212
README.md
212
README.md
@@ -1,84 +1,172 @@
|
||||
# auditui
|
||||
|
||||
A terminal-based user interface (TUI) client for Audible, written in Python 3 : listen to your audiobooks (even offline), browse and manage your library, and more!
|
||||
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.
|
||||
|
||||
Look at the [roadmap](#roadmap) for more details.
|
||||
## Requirements
|
||||
|
||||
It's still a work in progress, so expect bugs and missing features.
|
||||
- [Python](https://www.python.org/) 3.10-3.12 (3.13+ is not yet supported by `audible` module)
|
||||
- [ffmpeg](https://ffmpeg.org/) installed to play audio files.
|
||||
|
||||
## How to run
|
||||
## Features
|
||||
|
||||
This project uses [uv](https://github.com/astral-sh/uv) for dependency management.
|
||||
- **Browse your library**: View all your Audible audiobooks in a clean, terminal-based interface
|
||||
- **Offline playback**: Download audiobooks to your local cache and listen without an internet connection
|
||||
- **Playback controls**: Play, pause, seek, adjust playback speed, and navigate between chapters
|
||||
- **Library management**: Filter your library, sort by name or progress, and mark books as finished
|
||||
- **Progress tracking**: See your listening progress for each book and resume where you left off
|
||||
- **Statistics**: View listening statistics and library overview
|
||||
- **Keyboard-driven**: Fully navigable with keyboard shortcuts for efficient use
|
||||
- **Two-factor authentication**: Supports OTP for secure login
|
||||
|
||||
## Installation
|
||||
|
||||
Use [`pipx`](https://pipx.pypa.io/latest/installation/) to install `auditui`:
|
||||
|
||||
```bash
|
||||
pipx install git+https://git.kharec.info/Kharec/auditui.git
|
||||
```
|
||||
|
||||
Check the version to ensure installation was successful:
|
||||
|
||||
```bash
|
||||
auditui --version
|
||||
```
|
||||
|
||||
All set, run `auditui configure` to set up authentication, and then `auditui` to start the TUI.
|
||||
|
||||
### Workaround for Python 3.13 Linux distributions
|
||||
|
||||
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:
|
||||
|
||||
```bash
|
||||
yay -S python312
|
||||
```
|
||||
|
||||
Once you have Python 3.12, run:
|
||||
|
||||
```bash
|
||||
pipx install git+https://git.kharec.info/Kharec/auditui.git --python python3.12
|
||||
```
|
||||
|
||||
This workaround is temporary and depends on upstream `audible` compatibility updates.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Assuming it's already installed, use `pipx` to upgrade auditui:
|
||||
|
||||
```bash
|
||||
pipx upgrade auditui
|
||||
```
|
||||
|
||||
## Keybindings
|
||||
|
||||
| Key | Action |
|
||||
| ------------ | -------------------------- |
|
||||
| `/` | Filter library |
|
||||
| `?` | Show help screen |
|
||||
| `enter` | Play the selected book |
|
||||
| `space` | Pause/resume the playback |
|
||||
| `escape` | Clear filter |
|
||||
| `ctrl+left` | Go to the previous chapter |
|
||||
| `ctrl+right` | Go to the next chapter |
|
||||
| `up` | Increase playback speed |
|
||||
| `down` | Decrease playback speed |
|
||||
| `left` | Seek backward 30 seconds |
|
||||
| `right` | Seek forward 30 seconds |
|
||||
| `a` | Show all/unfinished |
|
||||
| `d` | Toggle download/delete |
|
||||
| `f` | Mark as finished |
|
||||
| `n` | Sort by name |
|
||||
| `p` | Sort by progress |
|
||||
| `q` | Quit the application |
|
||||
| `r` | Refresh view |
|
||||
| `s` | Show stats screen |
|
||||
|
||||
## Cache
|
||||
|
||||
Books are downloaded to `~/.cache/auditui/books`.
|
||||
|
||||
Downloaded files use a normalized `Author_Title.aax` naming format. For example, `Stephen King` and `11/22/63` become `Stephen-King_11-22-63.aax`.
|
||||
|
||||
The `d` key toggles the download state for the selected book: if the book is not cached, pressing `d` will download it; if it's already cached, pressing `d` will delete it from the cache.
|
||||
|
||||
To check the total size of your cache:
|
||||
|
||||
```bash
|
||||
du -sh ~/.cache/auditui/books
|
||||
```
|
||||
|
||||
Or the size of individual books:
|
||||
|
||||
```bash
|
||||
du -h ~/.cache/auditui/books/*
|
||||
```
|
||||
|
||||
Clean all the cache (if necessary) with:
|
||||
|
||||
```bash
|
||||
rm -rf ~/.cache/auditui/books/*
|
||||
```
|
||||
|
||||
## Authentication / credentials
|
||||
|
||||
Login is handled and credentials are stored in `~/.config/auditui/auth.json`.
|
||||
|
||||
When running `auditui configure`, you will be prompted for:
|
||||
|
||||
- **Email**: Your Audible account email address
|
||||
- **Password**: Your Audible account password (input is hidden)
|
||||
- **Marketplace locale**: The regional marketplace you want to connect to (defaults to `US` if left empty)
|
||||
|
||||
The marketplace locale determines which Audible region you access, affecting available audiobooks in your library. Common marketplace codes include:
|
||||
|
||||
- `US` - United States (default)
|
||||
- `UK` - United Kingdom
|
||||
- `DE` - Germany
|
||||
- `FR` - France
|
||||
- `CA` - Canada
|
||||
- `AU` - Australia
|
||||
- `IT` - Italy
|
||||
- `ES` - Spain
|
||||
- `JP` - Japan
|
||||
|
||||
To change your marketplace after initial configuration, simply run `auditui configure` again and select a different locale when prompted. But you should probably just stick with the marketplace you used when you first created your Audible account.
|
||||
|
||||
OTP is supported if you use a two-factor authentication device.
|
||||
|
||||
## Hacking
|
||||
|
||||
This project uses [uv](https://github.com/astral-sh/uv) for dependency management, the TUI is built with [Textual](https://textual.textualize.io/) (currently `textual>=8.0.0`).
|
||||
|
||||
```bash
|
||||
# install dependencies (creates .venv)
|
||||
$ uv sync
|
||||
|
||||
# run the TUI
|
||||
$ uv run python -m auditui.cli
|
||||
# modify the code...
|
||||
# ...and run the TUI
|
||||
$ uv run auditui
|
||||
```
|
||||
|
||||
(`stats.py` is a playground for the stats functionality)
|
||||
Don't forget to run the tests.
|
||||
|
||||
Please also note that as of now, you need to have [ffmpeg](https://ffmpeg.org/) installed to play audio files.
|
||||
## Testing
|
||||
|
||||
### Bindings
|
||||
As usual, tests are located in `tests` directory and use `pytest`.
|
||||
|
||||
| Key | Action |
|
||||
| ------------ | --------------------------- |
|
||||
| `?` | Show help screen |
|
||||
| `n` | Sort by name |
|
||||
| `p` | Sort by progress |
|
||||
| `a` | Show all/unfinished |
|
||||
| `enter` | Play the selected book |
|
||||
| `space` | Pause/resume the playback |
|
||||
| `left` | Seek backward 30 seconds |
|
||||
| `right` | Seek forward 30 seconds |
|
||||
| `ctrl+left` | Go to the previous chapter |
|
||||
| `ctrl+right` | Go to the next chapter |
|
||||
| `up` | Increase playback speed |
|
||||
| `down` | Decrease playback speed |
|
||||
| `f` | Mark as finished/unfinished |
|
||||
| `d` | Download/delete from cache |
|
||||
| `q` | Quit the application |
|
||||
Get the dev dependencies:
|
||||
|
||||
## Roadmap
|
||||
```bash
|
||||
uv sync --extra dev
|
||||
```
|
||||
|
||||
- [x] list your library
|
||||
- [x] list your unfinished books with progress information
|
||||
- [x] play/pause a book
|
||||
- [x] catppuccin mocha theme
|
||||
- [x] print chapter and progress in the footer of the app while a book is playing
|
||||
- [x] chapter progress bar in footer
|
||||
- [x] add a control to jump 30s earlier/later
|
||||
- [x] add control to go to the previous/next chapter
|
||||
- [x] save/resume playback of a book from the last position, regardless of which device was used previously
|
||||
- [x] download/remove a book in the cache without having to play it
|
||||
- [x] add a help screen with all the keybindings
|
||||
- [x] increase/decrease reading speed
|
||||
- [x] mark a book as finished or unfinished
|
||||
- [ ] get your stats in a separated pane
|
||||
- [ ] filter books on views
|
||||
- [ ] search in your book library
|
||||
And run the tests:
|
||||
|
||||
And after that:
|
||||
|
||||
- [ ] search the marketplace for books
|
||||
- [ ] add a book in your wishlist
|
||||
|
||||
All of this, and of course:
|
||||
|
||||
- [ ] installation setup
|
||||
- [ ] build a decent TUI interface using [Textual](https://textual.textualize.io/)
|
||||
- [ ] code cleanup / organization
|
||||
|
||||
## Auth / credentials
|
||||
|
||||
Login is handled and credentials are stored in `~/.config/auditui/auth.json`.
|
||||
|
||||
OTP is supported if you use a two-factor authentication device.
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
"""Auditui package"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
"""Auditui: Audible TUI client"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
|
||||
477
auditui/app.py
477
auditui/app.py
@@ -1,477 +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.events import Key
|
||||
from textual.widgets import DataTable, Footer, Header, ProgressBar, Static
|
||||
from textual.worker import get_current_worker
|
||||
|
||||
from .constants import PROGRESS_COLUMN_INDEX, SEEK_SECONDS, TABLE_CSS, TABLE_COLUMNS
|
||||
from .downloads import DownloadManager
|
||||
from .library import LibraryClient
|
||||
from .playback import PlaybackController
|
||||
from .table_utils import (
|
||||
create_progress_sort_key,
|
||||
create_title_sort_key,
|
||||
filter_unfinished_items,
|
||||
format_item_as_row,
|
||||
)
|
||||
from .ui import HelpScreen
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.widgets._data_table import ColumnKey
|
||||
|
||||
|
||||
class Auditui(App):
|
||||
"""Main application class for the Audible TUI app."""
|
||||
|
||||
theme = "textual-dark"
|
||||
SHOW_PALETTE = False
|
||||
|
||||
BINDINGS = [
|
||||
("?", "show_help", "Help"),
|
||||
("n", "sort", "Sort by name"),
|
||||
("p", "sort_by_progress", "Sort by progress"),
|
||||
("a", "show_all", "All/Unfinished"),
|
||||
("enter", "play_selected", "Play"),
|
||||
("space", "toggle_playback", "Pause/Resume"),
|
||||
("left", "seek_backward", "-30s"),
|
||||
("right", "seek_forward", "+30s"),
|
||||
("ctrl+left", "previous_chapter", "Previous chapter"),
|
||||
("ctrl+right", "next_chapter", "Next chapter"),
|
||||
("up", "increase_speed", "Increase speed"),
|
||||
("down", "decrease_speed", "Decrease speed"),
|
||||
("f", "toggle_finished", "Mark finished/unfinished"),
|
||||
("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.show_all_mode = False
|
||||
self.title_sort_reverse = False
|
||||
self.progress_sort_reverse = False
|
||||
self.title_column_key: ColumnKey | None = None
|
||||
self.progress_column_key: ColumnKey | None = None
|
||||
self.progress_column_index = PROGRESS_COLUMN_INDEX
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Static("Loading...", id="status")
|
||||
table: DataTable = DataTable()
|
||||
table.zebra_stripes = True
|
||||
table.cursor_type = "row"
|
||||
yield table
|
||||
yield Static("", id="progress_info")
|
||||
yield ProgressBar(id="progress_bar", show_eta=False, show_percentage=False, total=100)
|
||||
yield Footer(show_command_palette=False)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Initialize the table and start fetching library data."""
|
||||
table = self.query_one(DataTable)
|
||||
table.add_columns(*TABLE_COLUMNS)
|
||||
column_keys = list(table.columns.keys())
|
||||
self.title_column_key = column_keys[0]
|
||||
self.progress_column_key = column_keys[3]
|
||||
|
||||
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_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.update(message)
|
||||
|
||||
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.update_status(f"Loaded {len(items)} books")
|
||||
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
|
||||
mode = "all" if self.show_all_mode else "unfinished"
|
||||
self.update_status(f"Showing {len(items)} books ({mode})")
|
||||
|
||||
def _refresh_table(self) -> None:
|
||||
"""Refresh the table with current items."""
|
||||
if self.current_items:
|
||||
self._populate_table(self.current_items)
|
||||
|
||||
def show_all(self) -> None:
|
||||
"""Display all books in the table."""
|
||||
if not self.all_items:
|
||||
return
|
||||
self.show_all_mode = True
|
||||
self._populate_table(self.all_items)
|
||||
|
||||
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
|
||||
unfinished_items = filter_unfinished_items(
|
||||
self.all_items, self.library_client)
|
||||
self._populate_table(unfinished_items)
|
||||
|
||||
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_show_unfinished(self) -> None:
|
||||
"""Show unfinished books."""
|
||||
self.show_unfinished()
|
||||
|
||||
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:
|
||||
success = self.library_client.mark_as_unfinished(
|
||||
asin, selected_item)
|
||||
message = "Marked as unfinished" if success else "Failed to mark as unfinished"
|
||||
else:
|
||||
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:
|
||||
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 _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_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.display = True
|
||||
|
||||
def _hide_progress(self) -> None:
|
||||
"""Hide the progress widget."""
|
||||
progress_info = self.query_one("#progress_info", Static)
|
||||
progress_bar = self.query_one("#progress_bar", ProgressBar)
|
||||
progress_info.display = False
|
||||
progress_bar.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
|
||||
|
||||
import audible
|
||||
|
||||
from .constants import AUTH_PATH
|
||||
from ..constants import AUTH_PATH
|
||||
|
||||
|
||||
def authenticate(
|
||||
auth_path: Path = AUTH_PATH,
|
||||
) -> tuple[audible.Authenticator, audible.Client]:
|
||||
"""Authenticate with Audible and return authenticator and client."""
|
||||
"""Load auth from file and return (Authenticator, Client). Raises if file missing or invalid."""
|
||||
if not auth_path.exists():
|
||||
raise FileNotFoundError(
|
||||
"Authentication file not found. Please run 'auditui configure' to set up authentication.")
|
||||
"Authentication file not found. Please run 'auditui configure' to set up authentication."
|
||||
)
|
||||
|
||||
try:
|
||||
authenticator = audible.Authenticator.from_file(str(auth_path))
|
||||
@@ -21,4 +22,5 @@ def authenticate(
|
||||
return authenticator, audible_client
|
||||
except (OSError, ValueError, KeyError) as exc:
|
||||
raise ValueError(
|
||||
f"Failed to load existing authentication: {exc}") from exc
|
||||
f"Failed to load existing authentication: {exc}"
|
||||
) from exc
|
||||
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
|
||||
"""Auditui entrypoint."""
|
||||
"""CLI entrypoint: configure subcommand or authenticate and run the TUI."""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
@@ -12,7 +11,6 @@ from auditui.constants import AUTH_PATH
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Authenticate and launch the app."""
|
||||
parser = argparse.ArgumentParser(prog="auditui")
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
@@ -52,7 +50,3 @@ def main() -> None:
|
||||
|
||||
app = Auditui(auth=auth, client=client)
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,17 +1,18 @@
|
||||
"""Configuration helpers for the Auditui app."""
|
||||
"""Interactive setup of Audible credentials; writes auth and config files."""
|
||||
|
||||
import json
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
|
||||
import audible
|
||||
|
||||
from .constants import AUTH_PATH
|
||||
from ..constants import AUTH_PATH, CONFIG_PATH
|
||||
|
||||
|
||||
def configure(
|
||||
auth_path: Path = AUTH_PATH,
|
||||
) -> tuple[audible.Authenticator, audible.Client]:
|
||||
"""Force re-authentication and save credentials."""
|
||||
"""Prompt for email/password/locale, authenticate, and save auth.json and config.json."""
|
||||
if auth_path.exists():
|
||||
response = input(
|
||||
"Configuration already exists. Are you sure you want to overwrite it? (y/N): "
|
||||
@@ -25,7 +26,8 @@ def configure(
|
||||
email = input("\nEmail: ")
|
||||
password = getpass("Password: ")
|
||||
marketplace = input(
|
||||
"Marketplace locale (default: US): ").strip().upper() or "US"
|
||||
"Marketplace locale (default: US): "
|
||||
).strip().upper() or "US"
|
||||
|
||||
authenticator = audible.Authenticator.from_login(
|
||||
username=email, password=password, locale=marketplace
|
||||
@@ -33,6 +35,11 @@ def configure(
|
||||
|
||||
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
authenticator.to_file(str(auth_path))
|
||||
|
||||
config = {"email": email}
|
||||
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f)
|
||||
|
||||
print("Authentication successful!")
|
||||
audible_client = audible.Client(auth=authenticator)
|
||||
return authenticator, audible_client
|
||||
@@ -1,236 +0,0 @@
|
||||
"""Shared constants for the Auditui application."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
AUTH_PATH = Path.home() / ".config" / "auditui" / "auth.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_COLUMNS = ("Title", "Author", "Length", "Progress", "Downloaded")
|
||||
|
||||
AUTHOR_NAME_MAX_LENGTH = 40
|
||||
AUTHOR_NAME_DISPLAY_LENGTH = 37
|
||||
PROGRESS_COLUMN_INDEX = 3
|
||||
SEEK_SECONDS = 30.0
|
||||
|
||||
TABLE_CSS = """
|
||||
Screen {
|
||||
background: #1e1e2e;
|
||||
}
|
||||
|
||||
Header {
|
||||
background: #181825;
|
||||
color: #cdd6f4;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
Footer {
|
||||
background: #181825;
|
||||
color: #bac2de;
|
||||
height: 2;
|
||||
padding: 0 1;
|
||||
scrollbar-size: 0 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
Footer > HorizontalGroup > KeyGroup,
|
||||
Footer > HorizontalGroup > KeyGroup.-compact {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #181825;
|
||||
}
|
||||
|
||||
FooterKey,
|
||||
FooterKey.-grouped,
|
||||
Footer.-compact FooterKey {
|
||||
background: #181825;
|
||||
padding: 0;
|
||||
margin: 0 1 0 0;
|
||||
}
|
||||
|
||||
FooterKey .footer-key--key {
|
||||
color: #f9e2af;
|
||||
background: #181825;
|
||||
text-style: bold;
|
||||
padding: 0 1 0 0;
|
||||
}
|
||||
|
||||
FooterKey .footer-key--description {
|
||||
color: #cdd6f4;
|
||||
background: #181825;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
FooterKey:hover {
|
||||
background: #313244;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
FooterKey:hover .footer-key--key,
|
||||
FooterKey:hover .footer-key--description {
|
||||
background: #313244;
|
||||
}
|
||||
|
||||
DataTable {
|
||||
height: 1fr;
|
||||
background: #1e1e2e;
|
||||
color: #cdd6f4;
|
||||
border: solid #585b70;
|
||||
}
|
||||
|
||||
DataTable:focus {
|
||||
border: solid #89b4fa;
|
||||
}
|
||||
|
||||
DataTable > .datatable--header {
|
||||
background: #45475a;
|
||||
color: #bac2de;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
DataTable > .datatable--cursor {
|
||||
background: #313244;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
DataTable > .datatable--odd-row {
|
||||
background: #181825;
|
||||
}
|
||||
|
||||
DataTable > .datatable--even-row {
|
||||
background: #1e1e2e;
|
||||
}
|
||||
|
||||
Static {
|
||||
height: 1;
|
||||
text-align: center;
|
||||
background: #181825;
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
Static#status {
|
||||
color: #bac2de;
|
||||
}
|
||||
|
||||
Static#progress_info {
|
||||
color: #89b4fa;
|
||||
text-style: bold;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ProgressBar#progress_bar {
|
||||
height: 1;
|
||||
background: #181825;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0 1;
|
||||
width: 100%;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
ProgressBar#progress_bar > .progress-bar--track {
|
||||
background: #45475a;
|
||||
}
|
||||
|
||||
ProgressBar#progress_bar > .progress-bar--bar {
|
||||
background: #a6e3a1;
|
||||
}
|
||||
|
||||
HelpScreen {
|
||||
align: center middle;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
#help_container {
|
||||
width: 70;
|
||||
height: auto;
|
||||
max-height: 85%;
|
||||
min-height: 20;
|
||||
background: #1e1e2e;
|
||||
border: thick #89b4fa;
|
||||
padding: 2;
|
||||
}
|
||||
|
||||
#help_title {
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
color: #89b4fa;
|
||||
margin-bottom: 2;
|
||||
padding-bottom: 1;
|
||||
border-bottom: solid #585b70;
|
||||
height: 3;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
#help_content {
|
||||
width: 100%;
|
||||
height: 1fr;
|
||||
padding: 1 0;
|
||||
margin: 1 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-size: 0 1;
|
||||
}
|
||||
|
||||
#help_content > .scrollbar--vertical {
|
||||
background: #313244;
|
||||
}
|
||||
|
||||
#help_content > .scrollbar--vertical > .scrollbar--track {
|
||||
background: #181825;
|
||||
}
|
||||
|
||||
#help_content > .scrollbar--vertical > .scrollbar--handle {
|
||||
background: #585b70;
|
||||
}
|
||||
|
||||
#help_content > .scrollbar--vertical > .scrollbar--handle:hover {
|
||||
background: #45475a;
|
||||
}
|
||||
|
||||
.help_row {
|
||||
height: 3;
|
||||
margin: 0 0 1 0;
|
||||
padding: 0 1;
|
||||
background: #181825;
|
||||
border: solid #313244;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
.help_row:hover {
|
||||
background: #313244;
|
||||
border: solid #45475a;
|
||||
}
|
||||
|
||||
.help_key {
|
||||
width: 20;
|
||||
text-align: right;
|
||||
padding: 0 2 0 0;
|
||||
color: #f9e2af;
|
||||
text-style: bold;
|
||||
align: right middle;
|
||||
}
|
||||
|
||||
.help_action {
|
||||
width: 1fr;
|
||||
text-align: left;
|
||||
padding: 0 0 0 2;
|
||||
color: #cdd6f4;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
#help_footer {
|
||||
text-align: center;
|
||||
color: #bac2de;
|
||||
margin-top: 2;
|
||||
padding-top: 1;
|
||||
border-top: solid #585b70;
|
||||
height: 3;
|
||||
align: center middle;
|
||||
}
|
||||
"""
|
||||
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),
|
||||
)
|
||||
255
auditui/constants/ui.py
Normal file
255
auditui/constants/ui.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Textual CSS constants for the application UI."""
|
||||
|
||||
TABLE_CSS = """
|
||||
Screen {
|
||||
background: #141622;
|
||||
}
|
||||
|
||||
#top_bar {
|
||||
background: #10131f;
|
||||
color: #d5d9f0;
|
||||
text-style: bold;
|
||||
height: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#top_left,
|
||||
#top_center,
|
||||
#top_right {
|
||||
width: 1fr;
|
||||
padding: 0 1;
|
||||
background: #10131f;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#top_left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#top_center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#top_right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
DataTable {
|
||||
width: 100%;
|
||||
height: 1fr;
|
||||
background: #141622;
|
||||
color: #c7cfe8;
|
||||
border: solid #262a3f;
|
||||
scrollbar-size-horizontal: 0;
|
||||
}
|
||||
|
||||
DataTable:focus {
|
||||
border: solid #7aa2f7;
|
||||
}
|
||||
|
||||
DataTable > .datatable--header {
|
||||
background: #1b2033;
|
||||
color: #b9c3e3;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
DataTable > .datatable--cursor {
|
||||
background: #232842;
|
||||
color: #e6ebff;
|
||||
}
|
||||
|
||||
DataTable > .datatable--odd-row {
|
||||
background: #121422;
|
||||
}
|
||||
|
||||
DataTable > .datatable--even-row {
|
||||
background: #15182a;
|
||||
}
|
||||
|
||||
Static {
|
||||
height: 1;
|
||||
text-align: center;
|
||||
background: #10131f;
|
||||
color: #c7cfe8;
|
||||
}
|
||||
|
||||
Static#status {
|
||||
color: #b6bfdc;
|
||||
}
|
||||
|
||||
Static#progress_info {
|
||||
color: #7aa2f7;
|
||||
text-style: bold;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#progress_bar_container {
|
||||
align: center middle;
|
||||
width: 100%;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
ProgressBar#progress_bar {
|
||||
height: 1;
|
||||
background: #10131f;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
ProgressBar#progress_bar Bar {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ProgressBar#progress_bar > .progress-bar--track {
|
||||
background: #262a3f;
|
||||
}
|
||||
|
||||
ProgressBar#progress_bar > .progress-bar--bar {
|
||||
background: #8bd5ca;
|
||||
}
|
||||
|
||||
HelpScreen,
|
||||
StatsScreen,
|
||||
FilterScreen {
|
||||
align: center middle;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
HelpScreen Static,
|
||||
StatsScreen Static,
|
||||
FilterScreen Static {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
StatsScreen #help_container {
|
||||
width: auto;
|
||||
min-width: 55;
|
||||
max-width: 70;
|
||||
}
|
||||
|
||||
StatsScreen #help_content {
|
||||
align: center middle;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
StatsScreen .help_list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
StatsScreen .help_list > ListItem {
|
||||
background: transparent;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
StatsScreen .help_list > ListItem:hover {
|
||||
background: #232842;
|
||||
}
|
||||
|
||||
StatsScreen .help_list > ListItem > Label {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding-left: 2;
|
||||
}
|
||||
|
||||
#help_container {
|
||||
width: 72%;
|
||||
max-width: 90;
|
||||
min-width: 44;
|
||||
height: auto;
|
||||
max-height: 80%;
|
||||
min-height: 14;
|
||||
background: #181a2a;
|
||||
border: heavy #7aa2f7;
|
||||
padding: 1 1;
|
||||
}
|
||||
|
||||
#help_title {
|
||||
width: 100%;
|
||||
height: 2;
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
color: #7aa2f7;
|
||||
content-align: center middle;
|
||||
margin-bottom: 0;
|
||||
border-bottom: solid #4b5165;
|
||||
}
|
||||
|
||||
#help_content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
margin: 0 0 1 0;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
.help_list {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
scrollbar-size: 0 0;
|
||||
}
|
||||
|
||||
.help_list > ListItem {
|
||||
background: #1b1f33;
|
||||
padding: 0 1;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
.help_list > ListItem:hover {
|
||||
background: #2a2f45;
|
||||
}
|
||||
|
||||
.help_list > ListItem > Label {
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#help_footer {
|
||||
width: 100%;
|
||||
height: 2;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
color: #b6bfdc;
|
||||
margin-top: 0;
|
||||
border-top: solid #4b5165;
|
||||
}
|
||||
|
||||
#filter_container {
|
||||
width: 60;
|
||||
height: auto;
|
||||
background: #181a2a;
|
||||
border: heavy #7aa2f7;
|
||||
padding: 1 2;
|
||||
}
|
||||
|
||||
#filter_title {
|
||||
width: 100%;
|
||||
height: 2;
|
||||
text-align: center;
|
||||
text-style: bold;
|
||||
color: #7aa2f7;
|
||||
content-align: center middle;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
#filter_input {
|
||||
width: 100%;
|
||||
margin: 1 0;
|
||||
}
|
||||
|
||||
#filter_footer {
|
||||
width: 100%;
|
||||
height: 2;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
color: #b6bfdc;
|
||||
margin-top: 1;
|
||||
}
|
||||
"""
|
||||
@@ -1,236 +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
|
||||
notify(
|
||||
f"Downloading: {percent:.1f}% ({downloaded}/{total_size} bytes)"
|
||||
)
|
||||
|
||||
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,304 +0,0 @@
|
||||
"""Library helpers for fetching and formatting Audible data."""
|
||||
|
||||
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
|
||||
self._saved_positions: dict[str, float] = {}
|
||||
|
||||
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,"
|
||||
"rating,is_finished,listening_status,percent_complete"
|
||||
)
|
||||
return self._fetch_all_pages(response_groups, on_progress)
|
||||
|
||||
def _fetch_all_pages(
|
||||
self, response_groups: str, on_progress: ProgressCallback | None = None
|
||||
) -> list:
|
||||
"""Fetch all pages of library items from the API."""
|
||||
all_items: list[dict] = []
|
||||
page = 1
|
||||
page_size = 50
|
||||
|
||||
while True:
|
||||
library = self.client.get(
|
||||
path="library",
|
||||
num_results=page_size,
|
||||
page=page,
|
||||
response_groups=response_groups,
|
||||
)
|
||||
|
||||
items = list(library.get("items", []))
|
||||
if not items:
|
||||
break
|
||||
|
||||
all_items.extend(items)
|
||||
if on_progress:
|
||||
on_progress(f"Fetched page {page} ({len(items)} items)...")
|
||||
|
||||
if len(items) < page_size:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
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 human-readable 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)
|
||||
|
||||
parts = []
|
||||
if hours:
|
||||
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||
if minutes:
|
||||
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||
|
||||
return " ".join(parts) if parts else default_none
|
||||
|
||||
def _get_total_duration(self, asin: str, item: dict | None = None) -> float | None:
|
||||
"""Get total duration in seconds, trying item data first, then API."""
|
||||
if item:
|
||||
duration = self._get_total_duration_from_item(item)
|
||||
if duration:
|
||||
return duration
|
||||
return self._get_total_duration_from_api(asin)
|
||||
|
||||
def mark_as_finished(self, asin: str, item: dict | None = None) -> bool:
|
||||
"""Mark a book as finished (100% complete) by setting position near end."""
|
||||
current_position = self.get_last_position(asin)
|
||||
if current_position and current_position > 0:
|
||||
self._saved_positions[asin] = current_position
|
||||
|
||||
total_duration_seconds = self._get_total_duration(asin, item)
|
||||
if total_duration_seconds and total_duration_seconds > 0:
|
||||
position_seconds = max(0, total_duration_seconds - 10)
|
||||
else:
|
||||
position_seconds = 999999
|
||||
|
||||
return self._update_position(asin, position_seconds)
|
||||
|
||||
def _get_total_duration_from_api(self, asin: str) -> float | None:
|
||||
"""Get total duration in seconds from API."""
|
||||
try:
|
||||
response = self.client.get(
|
||||
path=f"1.0/content/{asin}/metadata",
|
||||
response_groups="runtime",
|
||||
)
|
||||
content_metadata = response.get("content_metadata", {})
|
||||
runtime = content_metadata.get("runtime", {})
|
||||
if isinstance(runtime, dict):
|
||||
runtime_ms = runtime.get("runtime_ms")
|
||||
if runtime_ms:
|
||||
return float(runtime_ms) / 1000.0
|
||||
return None
|
||||
except (OSError, ValueError, KeyError):
|
||||
return None
|
||||
|
||||
def mark_as_unfinished(self, asin: str, item: dict | None = None) -> bool:
|
||||
"""Mark a book as unfinished by restoring saved position."""
|
||||
saved_position = self._saved_positions.pop(asin, None)
|
||||
if saved_position is None:
|
||||
saved_position = self.get_last_position(asin)
|
||||
if saved_position is None or saved_position <= 0:
|
||||
return False
|
||||
|
||||
return self._update_position(asin, saved_position)
|
||||
|
||||
def _get_total_duration_from_item(self, item: dict) -> float | None:
|
||||
"""Get total duration in seconds from library item data."""
|
||||
runtime_minutes = self.extract_runtime_minutes(item)
|
||||
if runtime_minutes:
|
||||
return float(runtime_minutes * 60)
|
||||
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)
|
||||
36
auditui/library/search.py
Normal file
36
auditui/library/search.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Text search over library items for the filter feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from ..types import LibraryItem
|
||||
|
||||
from .client import LibraryClient
|
||||
|
||||
|
||||
def build_search_text(item: LibraryItem, library_client: LibraryClient | None) -> str:
|
||||
"""Build a single lowercase string from title and authors for matching."""
|
||||
if library_client:
|
||||
title = library_client.extract_title(item)
|
||||
authors = library_client.extract_authors(item)
|
||||
else:
|
||||
title = item.get("title", "")
|
||||
authors = ", ".join(
|
||||
a.get("name", "")
|
||||
for a in item.get("authors", [])
|
||||
if isinstance(a, dict) and a.get("name")
|
||||
)
|
||||
return f"{title} {authors}".lower()
|
||||
|
||||
|
||||
def filter_items(
|
||||
items: list[LibraryItem],
|
||||
filter_text: str,
|
||||
get_search_text: Callable[[LibraryItem], str],
|
||||
) -> list[LibraryItem]:
|
||||
"""Return items whose search text contains filter_text (case-insensitive)."""
|
||||
if not filter_text:
|
||||
return items
|
||||
filter_lower = filter_text.lower()
|
||||
return [item for item in items if filter_lower in get_search_text(item)]
|
||||
@@ -1,20 +1,21 @@
|
||||
"""Utils for table operations."""
|
||||
"""Formatting and sorting of library items for the main table."""
|
||||
|
||||
import unicodedata
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
from .constants import (
|
||||
from ..constants import (
|
||||
AUTHOR_NAME_DISPLAY_LENGTH,
|
||||
AUTHOR_NAME_MAX_LENGTH,
|
||||
PROGRESS_COLUMN_INDEX,
|
||||
)
|
||||
from ..types import LibraryItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .downloads import DownloadManager
|
||||
from ..downloads import DownloadManager
|
||||
|
||||
|
||||
def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]:
|
||||
"""Create a sort key function for sorting by title."""
|
||||
"""Return a (key_fn, reverse) pair for DataTable sort by title column."""
|
||||
def title_key(row_values):
|
||||
title_cell = row_values[0]
|
||||
if isinstance(title_cell, str):
|
||||
@@ -26,7 +27,7 @@ def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]:
|
||||
|
||||
|
||||
def create_progress_sort_key(progress_column_index: int = PROGRESS_COLUMN_INDEX, reverse: bool = False) -> tuple[Callable, bool]:
|
||||
"""Create a sort key function for sorting by progress percentage."""
|
||||
"""Return a (key_fn, reverse) pair for DataTable sort by progress column."""
|
||||
def progress_key(row_values):
|
||||
progress_cell = row_values[progress_column_index]
|
||||
if isinstance(progress_cell, str):
|
||||
@@ -40,18 +41,14 @@ def create_progress_sort_key(progress_column_index: int = PROGRESS_COLUMN_INDEX,
|
||||
|
||||
|
||||
def truncate_author_name(author_names: str) -> str:
|
||||
"""Truncate author name if it exceeds maximum length."""
|
||||
"""Truncate author string to display length with ellipsis if over max."""
|
||||
if author_names and len(author_names) > AUTHOR_NAME_MAX_LENGTH:
|
||||
return f"{author_names[:AUTHOR_NAME_DISPLAY_LENGTH]}..."
|
||||
return author_names
|
||||
|
||||
|
||||
def format_item_as_row(item: dict, library_client, download_manager: "DownloadManager | None" = None) -> tuple[str, str, str, str, str]:
|
||||
"""Format a library item into table row data.
|
||||
|
||||
Returns:
|
||||
Tuple of (title, author, runtime, progress, downloaded) strings
|
||||
"""
|
||||
def format_item_as_row(item: LibraryItem, library_client, download_manager: "DownloadManager | None" = None) -> tuple[str, str, str, str, str]:
|
||||
"""Turn a library item into (title, author, runtime, progress, downloaded) for the table."""
|
||||
title = library_client.extract_title(item)
|
||||
|
||||
author_names = library_client.extract_authors(item)
|
||||
@@ -79,8 +76,8 @@ def format_item_as_row(item: dict, library_client, download_manager: "DownloadMa
|
||||
return (title, author_display, runtime_str, progress_str, downloaded_str)
|
||||
|
||||
|
||||
def filter_unfinished_items(items: list[dict], library_client) -> list[dict]:
|
||||
"""Filter out finished items from the list."""
|
||||
def filter_unfinished_items(items: list[LibraryItem], library_client) -> list[LibraryItem]:
|
||||
"""Return only items that are not marked as finished."""
|
||||
return [
|
||||
item for item in items
|
||||
if not library_client.is_finished(item)
|
||||
@@ -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 shutil
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
|
||||
def load_media_info(path: Path, activation_hex: str | None = None) -> tuple[float | None, list[dict]]:
|
||||
"""Load media information including duration and chapters using ffprobe."""
|
||||
"""Return (total_duration_seconds, chapters) for the AAX file. Chapters have start_time, end_time, title."""
|
||||
if not shutil.which("ffprobe"):
|
||||
return None, []
|
||||
|
||||
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]
|
||||
@@ -1,33 +0,0 @@
|
||||
"""UI components for the Auditui application."""
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Container, Horizontal, ScrollableContainer
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class HelpScreen(ModalScreen):
|
||||
"""Help screen displaying all available keybindings."""
|
||||
|
||||
BINDINGS = [("escape", "dismiss", "Close"), ("?", "dismiss", "Close")]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id="help_container"):
|
||||
yield Static("Key Bindings", id="help_title")
|
||||
with ScrollableContainer(id="help_content"):
|
||||
bindings = self.app.BINDINGS
|
||||
for binding in bindings:
|
||||
if isinstance(binding, tuple):
|
||||
key, action, description = binding
|
||||
else:
|
||||
key = binding.key
|
||||
description = binding.description
|
||||
key_display = key.replace(
|
||||
"ctrl+", "^").replace("left", "←").replace("right", "→").replace("up", "↑").replace("down", "↓").replace("space", "Space").replace("enter", "Enter")
|
||||
with Horizontal(classes="help_row"):
|
||||
yield Static(f"[bold #f9e2af]{key_display}[/]", classes="help_key")
|
||||
yield Static(description, classes="help_action")
|
||||
yield Static("Press [bold #f9e2af]?[/] or [bold #f9e2af]Escape[/] to close", id="help_footer")
|
||||
|
||||
def action_dismiss(self) -> None:
|
||||
self.dismiss()
|
||||
7
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,10 +1,32 @@
|
||||
[project]
|
||||
name = "auditui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
description = "An Audible TUI client"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = ["audible>=0.10.0", "httpx>=0.28.1", "textual>=6.7.1"]
|
||||
requires-python = ">=3.10,<3.13"
|
||||
dependencies = ["audible>=0.10.0", "httpx>=0.28.1", "textual>=8.0.0"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"coverage[toml]>=7.0",
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
auditui = "auditui.cli:main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.coverage.run]
|
||||
branch = true
|
||||
source = ["auditui"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
skip_covered = true
|
||||
|
||||
[tool.uv]
|
||||
package = true
|
||||
|
||||
141
stats.py
141
stats.py
@@ -1,141 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stats playground for Audible TUI - get your listening stats"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
|
||||
import audible
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logging.getLogger("audible").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
class AudibleStats:
|
||||
"""Class to handle Audible authentication and stats retrieval."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the stats handler with authentication."""
|
||||
self.auth: audible.Authenticator | None = None
|
||||
self.client: audible.Client | None = None
|
||||
self.home = Path.home()
|
||||
|
||||
def authenticate(self) -> None:
|
||||
"""Authenticate with Audible and store auth and client."""
|
||||
auth_path = self.home / ".config" / "auditui" / "auth.json"
|
||||
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if auth_path.exists():
|
||||
try:
|
||||
self.auth = audible.Authenticator.from_file(str(auth_path))
|
||||
self.client = audible.Client(auth=self.auth)
|
||||
return
|
||||
except Exception:
|
||||
logger.info(
|
||||
"Failed to load existing auth. Re-authenticating.\n")
|
||||
|
||||
email = input("Email: ")
|
||||
password = getpass("Password: ")
|
||||
marketplace = (
|
||||
input("Marketplace locale (default: US): ").strip().upper() or "US"
|
||||
)
|
||||
|
||||
self.auth = audible.Authenticator.from_login(
|
||||
username=email, password=password, locale=marketplace
|
||||
)
|
||||
self.auth.to_file(str(auth_path))
|
||||
self.client = audible.Client(auth=self.auth)
|
||||
|
||||
def get_signup_year(self) -> int:
|
||||
"""Get signup year by checking activity in each month, each year."""
|
||||
current_year = date.today().year
|
||||
start_year = 1995
|
||||
|
||||
try:
|
||||
stats = self.client.get(
|
||||
"1.0/stats/aggregates",
|
||||
monthly_listening_interval_duration="12",
|
||||
monthly_listening_interval_start_date=f"{current_year}-01",
|
||||
store="Audible",
|
||||
)
|
||||
monthly_stats = stats.get("aggregated_monthly_listening_stats", [])
|
||||
if not monthly_stats or not any(
|
||||
stat.get("aggregated_sum", 0) > 0 for stat in monthly_stats
|
||||
):
|
||||
logger.warning("Could not determine signup year")
|
||||
return 0
|
||||
except Exception:
|
||||
logger.warning("Could not determine signup year")
|
||||
return 0
|
||||
|
||||
left, right = start_year, current_year
|
||||
earliest_year = current_year
|
||||
|
||||
while left <= right:
|
||||
middle = (left + right) // 2
|
||||
try:
|
||||
stats = self.client.get(
|
||||
"1.0/stats/aggregates",
|
||||
monthly_listening_interval_duration="12",
|
||||
monthly_listening_interval_start_date=f"{middle}-01",
|
||||
store="Audible",
|
||||
)
|
||||
monthly_stats = stats.get(
|
||||
"aggregated_monthly_listening_stats", [])
|
||||
has_activity = bool(
|
||||
monthly_stats
|
||||
and any(stat.get("aggregated_sum", 0) > 0 for stat in monthly_stats)
|
||||
)
|
||||
except Exception:
|
||||
has_activity = False
|
||||
|
||||
if has_activity:
|
||||
earliest_year = middle
|
||||
right = middle - 1
|
||||
else:
|
||||
left = middle + 1
|
||||
|
||||
return earliest_year
|
||||
|
||||
def get_current_month_listening_time(self) -> tuple[int, int, int]:
|
||||
"""Get total listening time for the current month as (hours, minutes, seconds)."""
|
||||
try:
|
||||
stats = self.client.get(
|
||||
"1.0/stats/aggregates",
|
||||
monthly_listening_interval_duration="1",
|
||||
monthly_listening_interval_start_date=date.today().strftime("%Y-%m"),
|
||||
store="Audible",
|
||||
)
|
||||
monthly_stats = stats.get("aggregated_monthly_listening_stats", [])
|
||||
if not monthly_stats:
|
||||
return (0, 0, 0)
|
||||
|
||||
total_milliseconds = sum(
|
||||
stat.get("aggregated_sum", 0) for stat in monthly_stats
|
||||
)
|
||||
total_seconds = int(total_milliseconds // 1000)
|
||||
hours, remainder = divmod(total_seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
return (hours, minutes, seconds)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get current month listening time: {e}")
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
worker = AudibleStats()
|
||||
worker.authenticate()
|
||||
print(worker.get_signup_year())
|
||||
hours, minutes, seconds = worker.get_current_month_listening_time()
|
||||
print(f"Total listening time this month: {hours}h {minutes}m {seconds}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
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
|
||||
64
tests/app/test_app_search_cache_logic.py
Normal file
64
tests/app/test_app_search_cache_logic.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
|
||||
from auditui.app import Auditui
|
||||
from auditui.library import build_search_text, filter_items
|
||||
|
||||
|
||||
class StubLibrary:
|
||||
"""Minimal library facade used by search-related app helpers."""
|
||||
|
||||
def extract_title(self, item: dict) -> str:
|
||||
"""Return title from a synthetic item."""
|
||||
return item.get("title", "")
|
||||
|
||||
def extract_authors(self, item: dict) -> str:
|
||||
"""Return authors from a synthetic item."""
|
||||
return item.get("authors", "")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DummyAuditui:
|
||||
"""Narrow object compatible with Auditui search-cache helper calls."""
|
||||
|
||||
_search_text_cache: dict[int, str] = field(default_factory=dict)
|
||||
library_client: StubLibrary = field(default_factory=StubLibrary)
|
||||
|
||||
|
||||
def test_get_search_text_is_cached() -> None:
|
||||
"""Ensure repeated text extraction for one item reuses cache entries."""
|
||||
item = {"title": "Title", "authors": "Author"}
|
||||
dummy = DummyAuditui()
|
||||
first = Auditui._get_search_text(cast(Auditui, dummy), item)
|
||||
second = Auditui._get_search_text(cast(Auditui, dummy), item)
|
||||
assert first == "title author"
|
||||
assert first == second
|
||||
assert len(dummy._search_text_cache) == 1
|
||||
|
||||
|
||||
def test_filter_items_uses_cached_callable() -> None:
|
||||
"""Ensure filter_items cooperates with a memoized search text callback."""
|
||||
library = StubLibrary()
|
||||
cache: dict[int, str] = {}
|
||||
items = [
|
||||
{"title": "Alpha", "authors": "Author One"},
|
||||
{"title": "Beta", "authors": "Author Two"},
|
||||
]
|
||||
|
||||
def cached(item: dict) -> str:
|
||||
"""Build and cache normalized search text per object identity."""
|
||||
cache_key = id(item)
|
||||
if cache_key not in cache:
|
||||
cache[cache_key] = build_search_text(item, cast(Any, library))
|
||||
return cache[cache_key]
|
||||
|
||||
result = filter_items(items, "beta", cached)
|
||||
assert result == [items[1]]
|
||||
|
||||
|
||||
def test_build_search_text_without_library_client() -> None:
|
||||
"""Ensure fallback search text path handles inline author dicts."""
|
||||
item = {"title": "Title", "authors": [{"name": "A"}, {"name": "B"}]}
|
||||
assert build_search_text(item, None) == "title a, b"
|
||||
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"
|
||||
41
tests/conftest.py
Normal file
41
tests/conftest.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, cast
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
try:
|
||||
import audible # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
audible_stub = ModuleType("audible")
|
||||
|
||||
class Authenticator:
|
||||
"""Minimal audible authenticator test stub."""
|
||||
|
||||
pass
|
||||
|
||||
class Client:
|
||||
"""Minimal audible client test stub."""
|
||||
|
||||
pass
|
||||
|
||||
setattr(cast(Any, audible_stub), "Authenticator", Authenticator)
|
||||
setattr(cast(Any, audible_stub), "Client", Client)
|
||||
|
||||
activation_bytes = ModuleType("audible.activation_bytes")
|
||||
|
||||
def get_activation_bytes(_auth: Authenticator | None = None) -> bytes:
|
||||
"""Return deterministic empty activation bytes for tests."""
|
||||
return b""
|
||||
|
||||
setattr(cast(Any, activation_bytes), "get_activation_bytes", get_activation_bytes)
|
||||
|
||||
sys.modules["audible"] = audible_stub
|
||||
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
|
||||
62
tests/ui/test_ui_filter_screen_behavior.py
Normal file
62
tests/ui/test_ui_filter_screen_behavior.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, cast
|
||||
|
||||
from auditui.ui import FilterScreen
|
||||
from textual.widgets import Input
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DummyEvent:
|
||||
"""Minimal event object carrying an input value for tests."""
|
||||
|
||||
value: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FakeTimer:
|
||||
"""Timer substitute recording whether stop() was called."""
|
||||
|
||||
callback: Callable[[], None]
|
||||
stopped: bool = False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Mark timer as stopped."""
|
||||
self.stopped = True
|
||||
|
||||
|
||||
def test_filter_debounce_uses_latest_value(monkeypatch) -> None:
|
||||
"""Ensure debounce cancels previous timer and emits latest input value."""
|
||||
seen: list[str] = []
|
||||
timers: list[FakeTimer] = []
|
||||
|
||||
def on_change(value: str) -> None:
|
||||
"""Capture emitted filter values."""
|
||||
seen.append(value)
|
||||
|
||||
screen = FilterScreen(on_change=on_change, debounce_seconds=0.2)
|
||||
|
||||
def fake_set_timer(_delay: float, callback: Callable[[], None]) -> FakeTimer:
|
||||
"""Record timer callbacks instead of scheduling real timers."""
|
||||
timer = FakeTimer(callback)
|
||||
timers.append(timer)
|
||||
return timer
|
||||
|
||||
monkeypatch.setattr(screen, "set_timer", fake_set_timer)
|
||||
screen.on_input_changed(cast(Input.Changed, DummyEvent("a")))
|
||||
screen.on_input_changed(cast(Input.Changed, DummyEvent("ab")))
|
||||
assert len(timers) == 2
|
||||
assert timers[0].stopped is True
|
||||
assert timers[1].stopped is False
|
||||
timers[1].callback()
|
||||
assert seen == ["ab"]
|
||||
|
||||
|
||||
def test_on_unmount_stops_pending_timer() -> None:
|
||||
"""Ensure screen unmount stops pending debounce timer when present."""
|
||||
screen = FilterScreen(on_change=lambda _value: None)
|
||||
timer = FakeTimer(lambda: None)
|
||||
screen._debounce_timer = timer
|
||||
screen.on_unmount()
|
||||
assert timer.stopped is True
|
||||
285
uv.lock
generated
285
uv.lock
generated
@@ -1,13 +1,15 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
requires-python = ">=3.10, <3.13"
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
|
||||
wheels = [
|
||||
@@ -33,19 +35,41 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "auditui"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
version = "0.2.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "audible" },
|
||||
{ name = "httpx" },
|
||||
{ name = "textual" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
dev = [
|
||||
{ name = "coverage", extra = ["toml"] },
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "audible", specifier = ">=0.10.0" },
|
||||
{ name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = ">=7.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "textual", specifier = ">=6.7.1" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" },
|
||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
|
||||
{ name = "textual", specifier = ">=8.0.0" },
|
||||
]
|
||||
provides-extras = ["dev"]
|
||||
|
||||
[[package]]
|
||||
name = "backports-asyncio-runner"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -70,6 +94,79 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
@@ -116,6 +213,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linkify-it-py"
|
||||
version = "2.0.3"
|
||||
@@ -166,6 +272,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pbkdf2"
|
||||
version = "1.3"
|
||||
@@ -178,56 +293,46 @@ version = "12.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f1621510327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f91510d3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/08/26e68b6b5da219c2a2cb7b563af008b53bb8e6b6fcb3fa40715fcdb2523a/pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", size = 5289809, upload-time = "2025-10-15T18:21:27.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/e9/4e58fb097fb74c7b4758a680aacd558810a417d1edaa7000142976ef9d2f/pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", size = 4650606, upload-time = "2025-10-15T18:21:29.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/e0/1fa492aa9f77b3bc6d471c468e62bfea1823056bf7e5e4f1914d7ab2565e/pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", size = 6221023, upload-time = "2025-10-15T18:21:31.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/09/4de7cd03e33734ccd0c876f0251401f1314e819cbfd89a0fcb6e77927cc6/pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", size = 8024937, upload-time = "2025-10-15T18:21:33.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/69/0688e7c1390666592876d9d474f5e135abb4acb39dcb583c4dc5490f1aff/pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", size = 6334139, upload-time = "2025-10-15T18:21:35.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1c/880921e98f525b9b44ce747ad1ea8f73fd7e992bafe3ca5e5644bf433dea/pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", size = 7026074, upload-time = "2025-10-15T18:21:37.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/03/96f718331b19b355610ef4ebdbbde3557c726513030665071fd025745671/pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", size = 6448852, upload-time = "2025-10-15T18:21:39.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/a0/6a193b3f0cc9437b122978d2c5cbce59510ccf9a5b48825096ed7472da2f/pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", size = 7117058, upload-time = "2025-10-15T18:21:40.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c4/043192375eaa4463254e8e61f0e2ec9a846b983929a8d0a7122e0a6d6fff/pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", size = 6295431, upload-time = "2025-10-15T18:21:42.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/c6/c2f2fc7e56301c21827e689bb8b0b465f1b52878b57471a070678c0c33cd/pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", size = 7000412, upload-time = "2025-10-15T18:21:44.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/d2/5f675067ba82da7a1c238a73b32e3fd78d67f9d9f80fbadd33a40b9c0481/pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", size = 2435903, upload-time = "2025-10-15T18:21:46.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -239,6 +344,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyaes"
|
||||
version = "1.6.1"
|
||||
@@ -263,6 +377,38 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-asyncio"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytest" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.2.0"
|
||||
@@ -299,7 +445,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "textual"
|
||||
version = "6.7.1"
|
||||
version = "8.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown-it-py", extra = ["linkify"] },
|
||||
@@ -309,9 +455,34 @@ dependencies = [
|
||||
{ name = "rich" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/00/9520327698acb6d8ae120b311ef1901840d55a6c41580e377f36261daf7a/textual-6.7.1.tar.gz", hash = "sha256:2a5acb0ab316a7ba9e74b0a291fab8933d681d7cf6f4e1eeb45c39a731b094cf", size = 1580916, upload-time = "2025-12-01T20:57:25.578Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f7/08/1e1f705825359590ddfaeda57653bd518c4ff7a96bb2c3239ba1b6fc4c51/textual-8.0.0.tar.gz", hash = "sha256:ce48f83a3d686c0fac0e80bf9136e1f8851c653aa6a4502e43293a151df18809", size = 1595895, upload-time = "2026-02-16T17:12:14.215Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/7a/7f3ea5e6f26d546ee4bd107df8fc9eef9f149dab0f6f15e1fc9f9413231f/textual-6.7.1-py3-none-any.whl", hash = "sha256:b92977ac5941dd37b6b7dc0ac021850ce8d9bf2e123c5bab7ff2016f215272e0", size = 713993, upload-time = "2025-12-01T20:57:23.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/be/e191c2a15da20530fde03564564e3e4b4220eb9d687d4014957e5c6a5e85/textual-8.0.0-py3-none-any.whl", hash = "sha256:8908f4ebe93a6b4f77ca7262197784a52162bc88b05f4ecf50ac93a92d49bb8f", size = 718904, upload-time = "2026-02-16T17:12:11.962Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user