Compare commits
247 Commits
b44ba70b6d
...
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 | |||
| e663401151 | |||
| 78dc8ed4a0 | |||
| 2d31c8d7a2 | |||
| b9f147c3b3 | |||
| 459970ebd5 | |||
| fa881a1ca8 | |||
| 7518d16501 | |||
| 620e1efa83 | |||
| a635c964da | |||
| dfe671409f | |||
| 52c67e20a6 | |||
| 553f5cb4f7 | |||
| 32b37a0834 | |||
| a2d2c7ce3a | |||
| 4741080284 | |||
| 737147b457 | |||
| 123d35068f | |||
| 258aabe10f | |||
| bc070c4162 | |||
| cbf6bff779 | |||
| 080c731fd7 | |||
| 1b6f1ff1f2 | |||
| aa5998c3e3 | |||
| c65e949731 | |||
| ab51e5506e | |||
| 3701b37f4c | |||
| 1474302d7e | |||
| eeecaaf42e | |||
| f359dee194 | |||
| 1e2655670d | |||
| cf6164c438 | |||
| 46fa15fcfe | |||
| 4b457452d4 | |||
| 0de0286992 | |||
| 391b0360bd | |||
| b0dc15a018 | |||
| a6d74265ed | |||
| 4f49a081c9 | |||
| 3a19db2cf0 | |||
| fcb1524806 | |||
| 18ffae7ac8 | |||
| d71c751bbc | |||
| 234b65c9d8 | |||
| 2d9970c198 | |||
| 5e3b33570d | |||
| 2ced756cc0 | |||
| 1c4017ae0c | |||
| 251a7a26d5 | |||
| 6462c83a21 | |||
| 0c590cfa82 | |||
| 16395981dc | |||
| 30f0612bb5 | |||
| 1aaff3b3b7 | |||
| 986541f0d3 | |||
| 151d565f36 | |||
| 7e2b657cfc | |||
| cef5e40347 | |||
| 839394343e | |||
| 84868c4afa | |||
| 03988f0988 | |||
| 9eba702a0a | |||
| f61f4ec55e | |||
| b45ff86061 | |||
| 6824d00088 | |||
| 46c66e0d5c | |||
| d4e73e6a13 | |||
| b2dd430ac9 | |||
| ce0d313187 | |||
| 7fee7e56cf | |||
| 58661641d1 | |||
| 95f30954b5 | |||
| d96a08935c | |||
| 0ce45c26b7 | |||
| 8b74c0f773 | |||
| 4a5e475f27 | |||
| 44d4f28ceb | |||
| 1d6033f057 | |||
| 5fe10a1636 | |||
| 1af3be37ce | |||
| c3dfa239fa | |||
| 42e6a1e029 | |||
| 41f5183653 | |||
| 1a1fee0984 | |||
| ddb7cab39e | |||
| 2d331288dd | |||
| d1a6fda863 | |||
| 2d10922a7c | |||
| 0ad4db95c5 | |||
| 0d9d65088b | |||
| 3b9d1ecf96 | |||
| 27f9a5396e | |||
| d3be27c70d | |||
| df2ae17721 | |||
| a0edab8e32 | |||
| ddb1704cb0 | |||
| 53284d7c0a | |||
| 7951373033 | |||
| cc3a1c6818 | |||
| 1088517cd5 | |||
| a62c3e9bf4 | |||
| fc15096918 | |||
| 37ac47698c | |||
| d6e2284db1 | |||
| 1cac45e6cf | |||
| 70e106208b | |||
| 73dc453c18 | |||
| 2d038fc811 | |||
| fbd987d353 | |||
| df0f0612ab | |||
| 8287b0ee16 | |||
| 4cbb13e371 | |||
| a45230c940 | |||
| ffaf998225 | |||
| ad6060395b | |||
| a7feeb9789 | |||
| c40444d587 | |||
| 46deb2baac | |||
| 79af9b2af6 | |||
| 2b21484309 | |||
| c691c49530 | |||
| 76a1c28510 | |||
| a67d0b4324 | |||
| 2e4ae1c1cb | |||
| d5f6510553 | |||
| 67fefeb679 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
venv
|
.venv
|
||||||
|
auditui.egg-info
|
||||||
__pycache__
|
__pycache__
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
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
|
||||||
175
README.md
175
README.md
@@ -1,56 +1,173 @@
|
|||||||
# auditui
|
# auditui
|
||||||
|
|
||||||
A terminal-based user interface (TUI) client for Audible, written in Python 3.
|
A terminal-based user interface (TUI) client for [Audible](https://www.audible.fr/), written in Python 3.
|
||||||
|
|
||||||
Listen to your audiobooks or podcasts, browse your library, and more.
|
The interface currently ships with a single built-in theme.
|
||||||
|
|
||||||
## What it does and where are we
|
## Requirements
|
||||||
|
|
||||||
`main.py` offers a TUI interface for browsing your Audible library, listing your books with progress information. You can sort by progress or title, show all books, or show only unfinished books which is the default.
|
- [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.
|
||||||
|
|
||||||
Now, I'm working on the "play" feature, which should allow you to play a book from the terminal by pressing `Enter` on a book in the list, and pause/unpause with `Space`.
|
## Features
|
||||||
|
|
||||||
Then, the next thing to add is a progress bar at the bottom of the interface, to show the progress of the book while it's playing.
|
- **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
|
||||||
|
|
||||||
It's still a work in progress, so expect bugs and missing features. By the way, the code is not yet organized as I'm currently experimenting.
|
## Installation
|
||||||
|
|
||||||
Also:
|
Use [`pipx`](https://pipx.pypa.io/latest/installation/) to install `auditui`:
|
||||||
|
|
||||||
- `tui-try.py` is where I test `textual` possibilities with fake data.
|
|
||||||
- `audible-api-test.py` is where I test `audible` python bindings possibilities.
|
|
||||||
|
|
||||||
## How to run
|
|
||||||
|
|
||||||
Install the dependencies with:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ virtualenv venv && source venv/bin/activate && pip install -r requirements.txt
|
pipx install git+https://git.kharec.info/Kharec/auditui.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the main application with:
|
Check the version to ensure installation was successful:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ python main.py
|
auditui --version
|
||||||
```
|
```
|
||||||
|
|
||||||
## Roadmap
|
All set, run `auditui configure` to set up authentication, and then `auditui` to start the TUI.
|
||||||
|
|
||||||
- [x] list your library
|
### Workaround for Python 3.13 Linux distributions
|
||||||
- [x] list your unfinished books with progress information
|
|
||||||
- [ ] build a decent TUI interface using [Textual](https://textual.textualize.io/)
|
|
||||||
- [ ] play/pause a book
|
|
||||||
- [ ] print progress at the bottom of the app while a book is playing
|
|
||||||
- [ ] add control to go to the previous/next chapter
|
|
||||||
- [ ] add a controle to jump 30s earlier/later
|
|
||||||
- [ ] mark a book as finished or unfinished
|
|
||||||
- [ ] code cleanup / organization
|
|
||||||
|
|
||||||
## Auth / credentials
|
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`.
|
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.
|
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
|
||||||
|
# modify the code...
|
||||||
|
# ...and run the TUI
|
||||||
|
$ uv run auditui
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't forget to run the tests.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
As usual, tests are located in `tests` directory and use `pytest`.
|
||||||
|
|
||||||
|
Get the dev dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv sync --extra dev
|
||||||
|
```
|
||||||
|
|
||||||
|
And run the tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under the GPLv3+ License. See the [LICENSE](LICENSE) file for details.
|
This project is licensed under the GPLv3+ License. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from getpass import getpass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
try:
|
|
||||||
import audible
|
|
||||||
except ImportError:
|
|
||||||
print("Error: audible library not found. Install it with: pip install audible")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
class Auditui:
|
|
||||||
AUTH_PATH = Path.home() / ".config" / "auditui" / "auth.json"
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.auth = None
|
|
||||||
self.client = None
|
|
||||||
|
|
||||||
def login_to_audible(self):
|
|
||||||
auth_file = self.AUTH_PATH
|
|
||||||
auth_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
if auth_file.exists():
|
|
||||||
try:
|
|
||||||
self.auth = audible.Authenticator.from_file(str(auth_file))
|
|
||||||
print("Loaded existing authentication.")
|
|
||||||
self.client = audible.Client(auth=self.auth)
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to load existing auth: {e}")
|
|
||||||
print("Please re-authenticate.")
|
|
||||||
|
|
||||||
print("Please authenticate with your Audible account.")
|
|
||||||
print("You will need to provide:")
|
|
||||||
print(" - Your Audible email/username")
|
|
||||||
print(" - Your password")
|
|
||||||
print(" - Your marketplace locale (e.g., 'US', 'UK', 'DE', 'FR')")
|
|
||||||
|
|
||||||
email = input("Email: ")
|
|
||||||
password = getpass("Password: ")
|
|
||||||
marketplace = input(
|
|
||||||
"Marketplace locale (default: US): ").strip().upper() or "US"
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.auth = audible.Authenticator.from_login(
|
|
||||||
username=email,
|
|
||||||
password=password,
|
|
||||||
locale=marketplace
|
|
||||||
)
|
|
||||||
|
|
||||||
auth_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
self.auth.to_file(str(auth_file))
|
|
||||||
print("Authentication successful! Credentials saved.")
|
|
||||||
self.client = audible.Client(auth=self.auth)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Authentication failed: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
def format_duration(self, value, unit='minutes', default_none=None):
|
|
||||||
if value is None or value <= 0:
|
|
||||||
return default_none
|
|
||||||
|
|
||||||
if unit == 'seconds':
|
|
||||||
total_minutes = int(value) // 60
|
|
||||||
else:
|
|
||||||
total_minutes = int(value)
|
|
||||||
|
|
||||||
if total_minutes < 60:
|
|
||||||
return f"{total_minutes} minute{'s' if total_minutes != 1 else ''}"
|
|
||||||
|
|
||||||
hours = total_minutes // 60
|
|
||||||
mins = total_minutes % 60
|
|
||||||
if mins == 0:
|
|
||||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
|
||||||
return f"{hours} hour{'s' if hours != 1 else ''} {mins} minute{'s' if mins != 1 else ''}"
|
|
||||||
|
|
||||||
def _extract_title(self, item):
|
|
||||||
product = item.get("product", {})
|
|
||||||
return (product.get("title") or
|
|
||||||
item.get("title") or
|
|
||||||
product.get("asin", "Unknown Title"))
|
|
||||||
|
|
||||||
def _extract_authors(self, 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", [])
|
|
||||||
return ", ".join([a.get("name", "") for a in authors if isinstance(a, dict)])
|
|
||||||
|
|
||||||
def _extract_runtime_minutes(self, 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)
|
|
||||||
if runtime is None:
|
|
||||||
runtime = item.get(field)
|
|
||||||
if runtime is not None:
|
|
||||||
break
|
|
||||||
|
|
||||||
if runtime is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(runtime, dict):
|
|
||||||
if "min" in runtime:
|
|
||||||
return int(runtime.get("min", 0))
|
|
||||||
elif isinstance(runtime, (int, float)):
|
|
||||||
return int(runtime)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _extract_progress_info(self, item):
|
|
||||||
percent_complete = item.get("percent_complete")
|
|
||||||
listening_status = item.get("listening_status", {})
|
|
||||||
|
|
||||||
if isinstance(listening_status, dict):
|
|
||||||
if percent_complete is None:
|
|
||||||
percent_complete = listening_status.get("percent_complete")
|
|
||||||
time_remaining_seconds = listening_status.get("time_remaining_seconds")
|
|
||||||
else:
|
|
||||||
time_remaining_seconds = None
|
|
||||||
|
|
||||||
return percent_complete, time_remaining_seconds
|
|
||||||
|
|
||||||
def _display_items(self, items):
|
|
||||||
if not items:
|
|
||||||
print("No books found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
print("-" * 80)
|
|
||||||
|
|
||||||
for idx, item in enumerate(items, 1):
|
|
||||||
title = self._extract_title(item)
|
|
||||||
author_names = self._extract_authors(item)
|
|
||||||
minutes = self._extract_runtime_minutes(item)
|
|
||||||
runtime_str = self.format_duration(
|
|
||||||
minutes, unit='minutes', default_none="Unknown length")
|
|
||||||
percent_complete, time_remaining_seconds = self._extract_progress_info(item)
|
|
||||||
|
|
||||||
print(f"{idx}. {title}")
|
|
||||||
if author_names:
|
|
||||||
print(f" Author: {author_names}")
|
|
||||||
print(f" Length: {runtime_str}")
|
|
||||||
|
|
||||||
if percent_complete is not None and percent_complete > 0:
|
|
||||||
percent_str = f"{percent_complete:.1f}%"
|
|
||||||
print(f" Progress: {percent_str} read")
|
|
||||||
|
|
||||||
if time_remaining_seconds:
|
|
||||||
time_remaining_str = self.format_duration(
|
|
||||||
time_remaining_seconds, unit='seconds')
|
|
||||||
if time_remaining_str:
|
|
||||||
print(f" Time remaining: {time_remaining_str}")
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("-" * 80)
|
|
||||||
print(f"Total: {len(items)} books")
|
|
||||||
|
|
||||||
def _fetch_all_pages(self, response_groups):
|
|
||||||
all_items = []
|
|
||||||
page = 1
|
|
||||||
page_size = 50
|
|
||||||
|
|
||||||
while True:
|
|
||||||
library = self.client.get(
|
|
||||||
path="library",
|
|
||||||
num_results=page_size,
|
|
||||||
page=page,
|
|
||||||
response_groups=response_groups
|
|
||||||
)
|
|
||||||
|
|
||||||
items = library.get("items", [])
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
break
|
|
||||||
|
|
||||||
all_items.extend(items)
|
|
||||||
print(f"Fetched page {page} ({len(items)} items)...", end="\r")
|
|
||||||
|
|
||||||
if len(items) < page_size:
|
|
||||||
break
|
|
||||||
|
|
||||||
page += 1
|
|
||||||
|
|
||||||
return all_items
|
|
||||||
|
|
||||||
def list_library(self):
|
|
||||||
try:
|
|
||||||
print("\nFetching your library...")
|
|
||||||
|
|
||||||
all_items = self._fetch_all_pages(
|
|
||||||
"contributors,media,product_attrs,product_desc,product_details,rating"
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"\nFetched {len(all_items)} books total.\n")
|
|
||||||
|
|
||||||
if not all_items:
|
|
||||||
print("Your library is empty.")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._display_items(all_items)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching library: {e}")
|
|
||||||
|
|
||||||
def list_unfinished(self):
|
|
||||||
try:
|
|
||||||
print("\nFetching your library...")
|
|
||||||
|
|
||||||
all_items = self._fetch_all_pages(
|
|
||||||
"contributors,media,product_attrs,product_desc,product_details,rating,is_finished,listening_status,percent_complete"
|
|
||||||
)
|
|
||||||
|
|
||||||
print(f"\nFetched {len(all_items)} books total.\n")
|
|
||||||
|
|
||||||
unfinished_items = []
|
|
||||||
finished_count = 0
|
|
||||||
for item in all_items:
|
|
||||||
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)
|
|
||||||
percent_complete = percent_complete if percent_complete is not None else listening_status.get(
|
|
||||||
"percent_complete", 0)
|
|
||||||
|
|
||||||
is_finished = False
|
|
||||||
if is_finished_flag is True:
|
|
||||||
is_finished = True
|
|
||||||
elif isinstance(percent_complete, (int, float)) and percent_complete >= 100:
|
|
||||||
is_finished = True
|
|
||||||
|
|
||||||
if is_finished:
|
|
||||||
finished_count += 1
|
|
||||||
else:
|
|
||||||
unfinished_items.append(item)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"Found {len(unfinished_items)} unfinished books (filtered out {finished_count} finished books).\n")
|
|
||||||
|
|
||||||
if not unfinished_items:
|
|
||||||
print("No unfinished books found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
self._display_items(unfinished_items)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching library: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
client = Auditui()
|
|
||||||
client.login_to_audible()
|
|
||||||
# client.list_library()
|
|
||||||
client.list_unfinished()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
3
auditui/__init__.py
Normal file
3
auditui/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Auditui: Audible TUI client"""
|
||||||
|
|
||||||
|
__version__ = "0.2.0"
|
||||||
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)
|
||||||
26
auditui/auth/__init__.py
Normal file
26
auditui/auth/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Load saved Audible credentials and build authenticator and API client."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import audible
|
||||||
|
|
||||||
|
from ..constants import AUTH_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate(
|
||||||
|
auth_path: Path = AUTH_PATH,
|
||||||
|
) -> tuple[audible.Authenticator, audible.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."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
authenticator = audible.Authenticator.from_file(str(auth_path))
|
||||||
|
audible_client = audible.Client(auth=authenticator)
|
||||||
|
return authenticator, audible_client
|
||||||
|
except (OSError, ValueError, KeyError) as exc:
|
||||||
|
raise ValueError(
|
||||||
|
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"]
|
||||||
52
auditui/cli/main.py
Normal file
52
auditui/cli/main.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""CLI entrypoint: configure subcommand or authenticate and run the TUI."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from auditui import __version__
|
||||||
|
from auditui.app import Auditui
|
||||||
|
from auditui.auth import authenticate
|
||||||
|
from auditui.configure import configure
|
||||||
|
from auditui.constants import AUTH_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(prog="auditui")
|
||||||
|
parser.add_argument(
|
||||||
|
"-v",
|
||||||
|
"--version",
|
||||||
|
action="version",
|
||||||
|
version=f"auditui {__version__}",
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
subparsers.add_parser("configure", help="Set up authentication")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "configure":
|
||||||
|
try:
|
||||||
|
configure()
|
||||||
|
print("Configuration completed successfully.")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Configuration error: {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
return
|
||||||
|
|
||||||
|
config_dir = AUTH_PATH.parent
|
||||||
|
|
||||||
|
if not config_dir.exists():
|
||||||
|
print("No configuration yet, please run 'auditui configure'.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth, client = authenticate()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Authentication error: {exc}")
|
||||||
|
if not AUTH_PATH.exists():
|
||||||
|
print("No configuration yet, please run 'auditui configure'.")
|
||||||
|
else:
|
||||||
|
print("Please re-authenticate by running 'auditui configure'.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
app = Auditui(auth=auth, client=client)
|
||||||
|
app.run()
|
||||||
45
auditui/configure/__init__.py
Normal file
45
auditui/configure/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""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, CONFIG_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def configure(
|
||||||
|
auth_path: Path = AUTH_PATH,
|
||||||
|
) -> tuple[audible.Authenticator, audible.Client]:
|
||||||
|
"""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): "
|
||||||
|
).strip().lower()
|
||||||
|
if response not in ("yes", "y"):
|
||||||
|
print("Configuration cancelled.")
|
||||||
|
raise SystemExit(0)
|
||||||
|
|
||||||
|
print("Please authenticate with your Audible account.")
|
||||||
|
|
||||||
|
email = input("\nEmail: ")
|
||||||
|
password = getpass("Password: ")
|
||||||
|
marketplace = input(
|
||||||
|
"Marketplace locale (default: US): "
|
||||||
|
).strip().upper() or "US"
|
||||||
|
|
||||||
|
authenticator = audible.Authenticator.from_login(
|
||||||
|
username=email, password=password, locale=marketplace
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
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()
|
||||||
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)]
|
||||||
84
auditui/library/table.py
Normal file
84
auditui/library/table.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""Formatting and sorting of library items for the main table."""
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
from typing import TYPE_CHECKING, Callable
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]:
|
||||||
|
"""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):
|
||||||
|
normalized = unicodedata.normalize('NFD', title_cell)
|
||||||
|
return normalized.encode('ascii', 'ignore').decode('ascii').lower()
|
||||||
|
return str(title_cell).lower()
|
||||||
|
|
||||||
|
return title_key, reverse
|
||||||
|
|
||||||
|
|
||||||
|
def create_progress_sort_key(progress_column_index: int = PROGRESS_COLUMN_INDEX, reverse: bool = False) -> tuple[Callable, bool]:
|
||||||
|
"""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):
|
||||||
|
try:
|
||||||
|
return float(progress_cell.rstrip("%"))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return 0.0
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return progress_key, reverse
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_author_name(author_names: str) -> str:
|
||||||
|
"""Truncate author 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: 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)
|
||||||
|
author_names = truncate_author_name(author_names)
|
||||||
|
author_display = author_names or "Unknown"
|
||||||
|
|
||||||
|
minutes = library_client.extract_runtime_minutes(item)
|
||||||
|
runtime_str = library_client.format_duration(
|
||||||
|
minutes, unit="minutes", default_none="Unknown length"
|
||||||
|
) or "Unknown"
|
||||||
|
|
||||||
|
percent_complete = library_client.extract_progress_info(item)
|
||||||
|
progress_str = (
|
||||||
|
f"{percent_complete:.1f}%"
|
||||||
|
if percent_complete and percent_complete > 0
|
||||||
|
else "0%"
|
||||||
|
)
|
||||||
|
|
||||||
|
downloaded_str = ""
|
||||||
|
if download_manager:
|
||||||
|
asin = library_client.extract_asin(item)
|
||||||
|
if asin and download_manager.is_cached(asin):
|
||||||
|
downloaded_str = "✓"
|
||||||
|
|
||||||
|
return (title, author_display, runtime_str, progress_str, downloaded_str)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_unfinished_items(items: list[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)
|
||||||
|
]
|
||||||
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,
|
||||||
|
)
|
||||||
42
auditui/playback/media_info.py
Normal file
42
auditui/playback/media_info.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Duration and chapter list for AAX files via ffprobe."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_media_info(path: Path, activation_hex: str | None = None) -> tuple[float | None, list[dict]]:
|
||||||
|
"""Return (total_duration_seconds, chapters) for the AAX file. Chapters have start_time, end_time, title."""
|
||||||
|
if not shutil.which("ffprobe"):
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = ["ffprobe", "-v", "quiet", "-print_format",
|
||||||
|
"json", "-show_format", "-show_chapters"]
|
||||||
|
if activation_hex:
|
||||||
|
cmd.extend(["-activation_bytes", activation_hex])
|
||||||
|
cmd.append(str(path))
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
format_info = data.get("format", {})
|
||||||
|
duration_str = format_info.get("duration")
|
||||||
|
duration = float(duration_str) if duration_str else None
|
||||||
|
|
||||||
|
chapters_data = data.get("chapters", [])
|
||||||
|
chapters = [
|
||||||
|
{
|
||||||
|
"start_time": float(ch.get("start_time", 0)),
|
||||||
|
"end_time": float(ch.get("end_time", 0)),
|
||||||
|
"title": ch.get("tags", {}).get("title", f"Chapter {idx + 1}"),
|
||||||
|
}
|
||||||
|
for idx, ch in enumerate(chapters_data)
|
||||||
|
]
|
||||||
|
return duration, chapters
|
||||||
|
except (json.JSONDecodeError, subprocess.TimeoutExpired, ValueError, KeyError):
|
||||||
|
return None, []
|
||||||
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]
|
||||||
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)
|
||||||
379
main.py
379
main.py
@@ -1,379 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""A terminal-based user interface (TUI) client for Audible"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from getpass import getpass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import audible
|
|
||||||
from textual.app import App, ComposeResult
|
|
||||||
from textual.widgets import Footer, Header, DataTable, Static
|
|
||||||
from textual.worker import get_current_worker
|
|
||||||
from textual import work
|
|
||||||
|
|
||||||
|
|
||||||
class AudituiApp(App):
|
|
||||||
"""Main application class for the Audible TUI app."""
|
|
||||||
BINDINGS = [
|
|
||||||
("d", "toggle_dark", "Toggle dark mode"),
|
|
||||||
("s", "sort", "Sort by title"),
|
|
||||||
("r", "reverse_sort", "Reverse sort"),
|
|
||||||
("p", "sort_by_progress", "Sort by progress"),
|
|
||||||
("a", "show_all", "Show all books"),
|
|
||||||
("u", "show_unfinished", "Show unfinished"),
|
|
||||||
("q", "quit", "Quit application"),
|
|
||||||
]
|
|
||||||
|
|
||||||
CSS = """
|
|
||||||
DataTable {
|
|
||||||
height: 1fr;
|
|
||||||
}
|
|
||||||
Static {
|
|
||||||
height: 1;
|
|
||||||
text-align: center;
|
|
||||||
background: $primary;
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
AUTH_PATH = Path.home() / ".config" / "auditui" / "auth.json"
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__()
|
|
||||||
self.auth = None
|
|
||||||
self.client = None
|
|
||||||
self.all_items = []
|
|
||||||
self.current_items = []
|
|
||||||
self.show_all_mode = False
|
|
||||||
self.progress_sort_reverse = False
|
|
||||||
self.title_column_key = None
|
|
||||||
self.progress_column_key = None
|
|
||||||
self.progress_column_index = 3
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
yield Header()
|
|
||||||
yield Static("Loading...", id="status")
|
|
||||||
table = DataTable()
|
|
||||||
table.zebra_stripes = True
|
|
||||||
table.cursor_type = "row"
|
|
||||||
yield table
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
"""Initialize the table and start fetching library data."""
|
|
||||||
table = self.query_one(DataTable)
|
|
||||||
table.add_columns("Title", "Author", "Length", "Progress")
|
|
||||||
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.")
|
|
||||||
|
|
||||||
@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:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
response_groups = (
|
|
||||||
"contributors,media,product_attrs,product_desc,product_details,"
|
|
||||||
"rating,is_finished,listening_status,percent_complete"
|
|
||||||
)
|
|
||||||
all_items = self._fetch_all_pages(response_groups)
|
|
||||||
self.all_items = all_items
|
|
||||||
self.call_from_thread(self.on_library_loaded, all_items)
|
|
||||||
except (OSError, ValueError, KeyError) as e:
|
|
||||||
self.call_from_thread(self.on_library_error, str(e))
|
|
||||||
|
|
||||||
def _fetch_all_pages(self, response_groups):
|
|
||||||
"""Fetch all pages of library items from the API."""
|
|
||||||
all_items = []
|
|
||||||
page = 1
|
|
||||||
page_size = 50
|
|
||||||
|
|
||||||
while True:
|
|
||||||
library = self.client.get(
|
|
||||||
path="library",
|
|
||||||
num_results=page_size,
|
|
||||||
page=page,
|
|
||||||
response_groups=response_groups
|
|
||||||
)
|
|
||||||
|
|
||||||
items = library.get("items", [])
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
break
|
|
||||||
|
|
||||||
all_items.extend(items)
|
|
||||||
self.call_from_thread(
|
|
||||||
self.update_status,
|
|
||||||
f"Fetched page {page} ({len(items)} items)..."
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(items) < page_size:
|
|
||||||
break
|
|
||||||
|
|
||||||
page += 1
|
|
||||||
|
|
||||||
return all_items
|
|
||||||
|
|
||||||
def on_library_loaded(self, items) -> 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 update_status(self, message: str) -> None:
|
|
||||||
"""Update the status message in the UI."""
|
|
||||||
status = self.query_one("#status", Static)
|
|
||||||
status.update(message)
|
|
||||||
|
|
||||||
def format_duration(self, value, unit='minutes', default_none=None):
|
|
||||||
"""Format duration value into human-readable string."""
|
|
||||||
if value is None or value <= 0:
|
|
||||||
return default_none
|
|
||||||
|
|
||||||
if unit == 'seconds':
|
|
||||||
total_minutes = int(value) // 60
|
|
||||||
else:
|
|
||||||
total_minutes = int(value)
|
|
||||||
|
|
||||||
if total_minutes < 60:
|
|
||||||
return f"{total_minutes} minute{'s' if total_minutes != 1 else ''}"
|
|
||||||
|
|
||||||
hours = total_minutes // 60
|
|
||||||
mins = total_minutes % 60
|
|
||||||
if mins == 0:
|
|
||||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
|
||||||
return f"{hours} hour{'s' if hours != 1 else ''} {mins} minute{'s' if mins != 1 else ''}"
|
|
||||||
|
|
||||||
def _extract_title(self, item):
|
|
||||||
"""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):
|
|
||||||
"""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", [])
|
|
||||||
return ", ".join([a.get("name", "") for a in authors if isinstance(a, dict)])
|
|
||||||
|
|
||||||
def _extract_runtime_minutes(self, item):
|
|
||||||
"""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)
|
|
||||||
if runtime is None:
|
|
||||||
runtime = item.get(field)
|
|
||||||
if runtime is not None:
|
|
||||||
break
|
|
||||||
|
|
||||||
if runtime is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(runtime, dict):
|
|
||||||
if "min" in runtime:
|
|
||||||
return int(runtime.get("min", 0))
|
|
||||||
elif isinstance(runtime, (int, float)):
|
|
||||||
return int(runtime)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _extract_progress_info(self, item):
|
|
||||||
"""Extract progress percentage from library item."""
|
|
||||||
percent_complete = item.get("percent_complete")
|
|
||||||
listening_status = item.get("listening_status", {})
|
|
||||||
|
|
||||||
if isinstance(listening_status, dict):
|
|
||||||
if percent_complete is None:
|
|
||||||
percent_complete = listening_status.get("percent_complete")
|
|
||||||
else:
|
|
||||||
percent_complete = None
|
|
||||||
|
|
||||||
return percent_complete
|
|
||||||
|
|
||||||
def _populate_table(self, items):
|
|
||||||
"""Populate the DataTable with library items."""
|
|
||||||
table = self.query_one(DataTable)
|
|
||||||
table.clear()
|
|
||||||
|
|
||||||
if not items:
|
|
||||||
self.update_status("No books found.")
|
|
||||||
return
|
|
||||||
|
|
||||||
for item in items:
|
|
||||||
title = self._extract_title(item)
|
|
||||||
author_names = self._extract_authors(item)
|
|
||||||
minutes = self._extract_runtime_minutes(item)
|
|
||||||
runtime_str = self.format_duration(
|
|
||||||
minutes, unit='minutes', default_none="Unknown length")
|
|
||||||
percent_complete = self._extract_progress_info(item)
|
|
||||||
|
|
||||||
progress_str = "0%"
|
|
||||||
if percent_complete is not None and percent_complete > 0:
|
|
||||||
progress_str = f"{percent_complete:.1f}%"
|
|
||||||
|
|
||||||
table.add_row(
|
|
||||||
title,
|
|
||||||
author_names or "Unknown",
|
|
||||||
runtime_str or "Unknown",
|
|
||||||
progress_str,
|
|
||||||
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 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:
|
|
||||||
return
|
|
||||||
self.show_all_mode = False
|
|
||||||
|
|
||||||
unfinished_items = []
|
|
||||||
for item in self.all_items:
|
|
||||||
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)
|
|
||||||
|
|
||||||
is_finished = False
|
|
||||||
if is_finished_flag is True:
|
|
||||||
is_finished = True
|
|
||||||
elif isinstance(percent_complete, (int, float)) and percent_complete >= 100:
|
|
||||||
is_finished = True
|
|
||||||
|
|
||||||
if not is_finished:
|
|
||||||
unfinished_items.append(item)
|
|
||||||
|
|
||||||
self._populate_table(unfinished_items)
|
|
||||||
|
|
||||||
def action_toggle_dark(self) -> None:
|
|
||||||
"""Toggle between dark and light theme."""
|
|
||||||
self.theme = (
|
|
||||||
"textual-dark" if self.theme == "textual-light" else "textual-light"
|
|
||||||
)
|
|
||||||
|
|
||||||
def action_sort(self) -> None:
|
|
||||||
"""Sort table by title in ascending order."""
|
|
||||||
table = self.query_one(DataTable)
|
|
||||||
if table.row_count > 0:
|
|
||||||
table.sort(self.title_column_key)
|
|
||||||
|
|
||||||
def action_reverse_sort(self) -> None:
|
|
||||||
"""Sort table by title in descending order."""
|
|
||||||
table = self.query_one(DataTable)
|
|
||||||
if table.row_count > 0:
|
|
||||||
table.sort(self.title_column_key, reverse=True)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
def progress_key(row_values):
|
|
||||||
progress_cell = row_values[self.progress_column_index]
|
|
||||||
if isinstance(progress_cell, str):
|
|
||||||
try:
|
|
||||||
return float(progress_cell.rstrip("%"))
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
return 0.0
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
table.sort(key=progress_key, reverse=self.progress_sort_reverse)
|
|
||||||
|
|
||||||
def action_show_all(self) -> None:
|
|
||||||
"""Action handler to show all books."""
|
|
||||||
self.show_all()
|
|
||||||
|
|
||||||
def action_show_unfinished(self) -> None:
|
|
||||||
"""Action handler to show unfinished books."""
|
|
||||||
self.show_unfinished()
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate():
|
|
||||||
"""Authenticate with Audible and return auth and client objects."""
|
|
||||||
auth_path = Path.home() / ".config" / "auditui" / "auth.json"
|
|
||||||
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
authenticator = None
|
|
||||||
if auth_path.exists():
|
|
||||||
try:
|
|
||||||
authenticator = audible.Authenticator.from_file(str(auth_path))
|
|
||||||
audible_client = audible.Client(auth=authenticator)
|
|
||||||
return authenticator, audible_client
|
|
||||||
except (OSError, ValueError, KeyError) as e:
|
|
||||||
print(f"Failed to load existing auth: {e}")
|
|
||||||
print("Please re-authenticate.")
|
|
||||||
|
|
||||||
print("Please authenticate with your Audible account.")
|
|
||||||
print("You will need to provide:")
|
|
||||||
print(" - Your Audible email/username")
|
|
||||||
print(" - Your password")
|
|
||||||
print(" - Your marketplace locale (e.g., 'US', 'UK', 'DE', 'FR')")
|
|
||||||
|
|
||||||
email = input("\nEmail: ")
|
|
||||||
password = getpass("Password: ")
|
|
||||||
marketplace = input(
|
|
||||||
"Marketplace locale (default: US): ").strip().upper() or "US"
|
|
||||||
|
|
||||||
try:
|
|
||||||
authenticator = audible.Authenticator.from_login(
|
|
||||||
username=email,
|
|
||||||
password=password,
|
|
||||||
locale=marketplace
|
|
||||||
)
|
|
||||||
|
|
||||||
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
authenticator.to_file(str(auth_path))
|
|
||||||
print("Authentication successful!")
|
|
||||||
audible_client = audible.Client(auth=authenticator)
|
|
||||||
return authenticator, audible_client
|
|
||||||
except (OSError, ValueError, KeyError) as e:
|
|
||||||
print(f"Authentication failed: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
auth, client = authenticate()
|
|
||||||
app = AudituiApp()
|
|
||||||
app.auth = auth
|
|
||||||
app.client = client
|
|
||||||
app.run()
|
|
||||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
[project]
|
||||||
|
name = "auditui"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "An Audible TUI client"
|
||||||
|
readme = "README.md"
|
||||||
|
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
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
anyio==4.11.0
|
|
||||||
audible==0.8.2
|
|
||||||
beautifulsoup4==4.14.2
|
|
||||||
certifi==2025.11.12
|
|
||||||
h11==0.14.0
|
|
||||||
httpcore==0.16.3
|
|
||||||
httpx==0.23.3
|
|
||||||
idna==3.11
|
|
||||||
linkify-it-py==2.0.3
|
|
||||||
markdown-it-py==4.0.0
|
|
||||||
mdit-py-plugins==0.5.0
|
|
||||||
mdurl==0.1.2
|
|
||||||
pbkdf2==1.3
|
|
||||||
pillow==12.0.0
|
|
||||||
platformdirs==4.5.0
|
|
||||||
pyaes==1.6.1
|
|
||||||
pyasn1==0.6.1
|
|
||||||
Pygments==2.19.2
|
|
||||||
rfc3986==1.5.0
|
|
||||||
rich==14.2.0
|
|
||||||
rsa==4.9.1
|
|
||||||
sniffio==1.3.1
|
|
||||||
soupsieve==2.8
|
|
||||||
textual==6.6.0
|
|
||||||
typing_extensions==4.15.0
|
|
||||||
uc-micro-py==1.0.3
|
|
||||||
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
|
||||||
56
tui-try.py
56
tui-try.py
@@ -1,56 +0,0 @@
|
|||||||
from textual.app import App, ComposeResult
|
|
||||||
from textual.widgets import Footer, Header, DataTable
|
|
||||||
|
|
||||||
|
|
||||||
class AudituiApp(App):
|
|
||||||
BINDINGS = [
|
|
||||||
("d", "toggle_dark", "Toggle dark mode"),
|
|
||||||
("s", "sort", "Sort by title"),
|
|
||||||
("r", "reverse_sort", "Reverse sort"),
|
|
||||||
("q", "quit", "Quit application")
|
|
||||||
]
|
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
|
||||||
yield Header()
|
|
||||||
table = DataTable()
|
|
||||||
table.zebra_stripes = True
|
|
||||||
table.cursor_type = "row"
|
|
||||||
yield table
|
|
||||||
yield Footer()
|
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
|
||||||
table = self.query_one(DataTable)
|
|
||||||
table.add_columns("Title", "Author", "Length", "Progress")
|
|
||||||
self.title_column_key = list(table.columns.keys())[0]
|
|
||||||
|
|
||||||
sample_books = [
|
|
||||||
("The Great Gatsby", "F. Scott Fitzgerald", "4h 30m", "100%"),
|
|
||||||
("1984", "George Orwell", "11h 25m", "75%"),
|
|
||||||
("To Kill a Mockingbird", "Harper Lee", "12h 17m", "50%"),
|
|
||||||
("Pride and Prejudice", "Jane Austen", "11h 35m", "0%"),
|
|
||||||
("The Catcher in the Rye", "J.D. Salinger", "7h 20m", "25%"),
|
|
||||||
("Lord of the Flies", "William Golding", "6h 35m", "100%"),
|
|
||||||
("Animal Farm", "George Orwell", "3h 15m", "90%"),
|
|
||||||
("Brave New World", "Aldous Huxley", "10h 45m", "60%"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for title, author, length, progress in sample_books:
|
|
||||||
table.add_row(title, author, length, progress, key=title)
|
|
||||||
|
|
||||||
def action_toggle_dark(self) -> None:
|
|
||||||
self.theme = (
|
|
||||||
"textual-dark" if self.theme == "textual-light" else "textual-light"
|
|
||||||
)
|
|
||||||
|
|
||||||
def action_sort(self) -> None:
|
|
||||||
table = self.query_one(DataTable)
|
|
||||||
table.sort(self.title_column_key)
|
|
||||||
|
|
||||||
def action_reverse_sort(self) -> None:
|
|
||||||
table = self.query_one(DataTable)
|
|
||||||
table.sort(self.title_column_key, reverse=True)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app = AudituiApp()
|
|
||||||
app.run()
|
|
||||||
504
uv.lock
generated
Normal file
504
uv.lock
generated
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
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 = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "audible"
|
||||||
|
version = "0.10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pbkdf2" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "pyaes" },
|
||||||
|
{ name = "rsa" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/31/3e/2dd2d81116b81d91fca4bdff86e2dfd41fc8668655e228ab3979beb0d03a/audible-0.10.0.tar.gz", hash = "sha256:125b3accc9ffbda020dd25818264cabe5d748a40559cb9b9c10611d87bb14ebb", size = 43286, upload-time = "2024-09-26T15:36:40.724Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/8e/b89637aeb78f5cc9914a136fe8602ec314b94ea441d92766b0b17d803810/audible-0.10.0-py3-none-any.whl", hash = "sha256:5f59082c0bb07f111a31b86358e07719d57c159bbc144c2724bec0d35a8e7e2c", size = 46636, upload-time = "2024-09-26T15:36:39.12Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "auditui"
|
||||||
|
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 = "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]]
|
||||||
|
name = "beautifulsoup4"
|
||||||
|
version = "4.14.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "soupsieve" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2025.11.12"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
|
||||||
|
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"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
|
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"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "uc-micro-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markdown-it-py"
|
||||||
|
version = "4.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "mdurl" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
linkify = [
|
||||||
|
{ name = "linkify-it-py" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdit-py-plugins"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mdurl"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
|
||||||
|
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"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/02/c0/6a2376ae81beb82eda645a091684c0b0becb86b972def7849ea9066e3d5e/pbkdf2-1.3.tar.gz", hash = "sha256:ac6397369f128212c43064a2b4878038dab78dab41875364554aaf2a684e6979", size = 6360, upload-time = "2011-06-14T05:18:10.981Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
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/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]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
|
||||||
|
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"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536, upload-time = "2017-09-20T21:17:54.23Z" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyasn1"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
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"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rsa"
|
||||||
|
version = "4.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyasn1" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soupsieve"
|
||||||
|
version = "2.8"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "textual"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "markdown-it-py", extra = ["linkify"] },
|
||||||
|
{ name = "mdit-py-plugins" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
{ name = "rich" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
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/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]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "uc-micro-py"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" },
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user