Compare commits
193 Commits
b44ba70b6d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
173
README.md
173
README.md
@@ -1,56 +1,171 @@
|
|||||||
# 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.
|
Currently, the only available theme is Catppuccin Mocha, following their [style guide](https://github.com/catppuccin/catppuccin/blob/main/docs/style-guide.md), as it's my preferred theme across most of my tools.
|
||||||
|
|
||||||
## 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 distribution
|
||||||
- [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. So you have to 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
|
||||||
|
```
|
||||||
|
|
||||||
|
As Python <3.14 is supported on `master` branch of the upstream [`audible`](https://github.com/mkb79/Audible), this should be temporary until the next version.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# install dependencies (creates .venv)
|
||||||
|
$ uv sync
|
||||||
|
# modify the code...
|
||||||
|
# ...and run the TUI
|
||||||
|
$ uv run python -m auditui.cli
|
||||||
|
```
|
||||||
|
|
||||||
|
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 package"""
|
||||||
|
|
||||||
|
__version__ = "0.1.4"
|
||||||
608
auditui/app.py
Normal file
608
auditui/app.py
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
"""Textual application for the Audible TUI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from textual import work
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Horizontal
|
||||||
|
from textual.events import Key, Resize
|
||||||
|
from textual.widgets import DataTable, ProgressBar, Static
|
||||||
|
from textual.worker import get_current_worker
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .constants import (
|
||||||
|
PROGRESS_COLUMN_INDEX,
|
||||||
|
SEEK_SECONDS,
|
||||||
|
TABLE_COLUMN_DEFS,
|
||||||
|
TABLE_CSS,
|
||||||
|
)
|
||||||
|
from .downloads import DownloadManager
|
||||||
|
from .library import LibraryClient
|
||||||
|
from .playback import PlaybackController
|
||||||
|
from .table_utils import (
|
||||||
|
create_progress_sort_key,
|
||||||
|
create_title_sort_key,
|
||||||
|
filter_unfinished_items,
|
||||||
|
format_item_as_row,
|
||||||
|
)
|
||||||
|
from .search_utils import build_search_text, filter_items
|
||||||
|
from .ui import FilterScreen, HelpScreen, StatsScreen
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from textual.widgets._data_table import ColumnKey
|
||||||
|
|
||||||
|
|
||||||
|
class Auditui(App):
|
||||||
|
"""Main application class for the Audible TUI app."""
|
||||||
|
|
||||||
|
theme = "textual-dark"
|
||||||
|
SHOW_PALETTE = False
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
("?", "show_help", "Help"),
|
||||||
|
("s", "show_stats", "Stats"),
|
||||||
|
("/", "filter", "Filter"),
|
||||||
|
("escape", "clear_filter", "Clear filter"),
|
||||||
|
("n", "sort", "Sort by name"),
|
||||||
|
("p", "sort_by_progress", "Sort by progress"),
|
||||||
|
("a", "show_all", "All/Unfinished"),
|
||||||
|
("r", "refresh", "Refresh"),
|
||||||
|
("enter", "play_selected", "Play"),
|
||||||
|
("space", "toggle_playback", "Pause/Resume"),
|
||||||
|
("left", "seek_backward", "-30s"),
|
||||||
|
("right", "seek_forward", "+30s"),
|
||||||
|
("ctrl+left", "previous_chapter", "Previous chapter"),
|
||||||
|
("ctrl+right", "next_chapter", "Next chapter"),
|
||||||
|
("up", "increase_speed", "Increase speed"),
|
||||||
|
("down", "decrease_speed", "Decrease speed"),
|
||||||
|
("f", "toggle_finished", "Mark finished"),
|
||||||
|
("d", "toggle_download", "Download/Delete"),
|
||||||
|
("q", "quit", "Quit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
CSS = TABLE_CSS
|
||||||
|
|
||||||
|
def __init__(self, auth=None, client=None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.auth = auth
|
||||||
|
self.client = client
|
||||||
|
self.library_client = LibraryClient(client) if client else None
|
||||||
|
self.download_manager = (
|
||||||
|
DownloadManager(auth, client) if auth and client else None
|
||||||
|
)
|
||||||
|
self.playback = PlaybackController(
|
||||||
|
self.update_status, self.library_client)
|
||||||
|
|
||||||
|
self.all_items: list[dict] = []
|
||||||
|
self.current_items: list[dict] = []
|
||||||
|
self._search_text_cache: dict[int, str] = {}
|
||||||
|
self.show_all_mode = False
|
||||||
|
self.filter_text = ""
|
||||||
|
self.title_sort_reverse = False
|
||||||
|
self.progress_sort_reverse = False
|
||||||
|
self.title_column_key: ColumnKey | None = None
|
||||||
|
self.progress_column_index = PROGRESS_COLUMN_INDEX
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Horizontal(
|
||||||
|
Static("? Help", id="top_left"),
|
||||||
|
Static(f"Auditui v{__version__}", id="top_center"),
|
||||||
|
Static("q Quit", id="top_right"),
|
||||||
|
id="top_bar",
|
||||||
|
)
|
||||||
|
yield Static("Loading...", id="status")
|
||||||
|
table: DataTable = DataTable()
|
||||||
|
table.zebra_stripes = True
|
||||||
|
table.cursor_type = "row"
|
||||||
|
yield table
|
||||||
|
yield Static("", id="progress_info")
|
||||||
|
with Horizontal(id="progress_bar_container"):
|
||||||
|
yield ProgressBar(id="progress_bar", show_eta=False, show_percentage=False, total=100)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
"""Initialize the table and start fetching library data."""
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
for column_name, _ratio in TABLE_COLUMN_DEFS:
|
||||||
|
table.add_column(column_name, width=1)
|
||||||
|
self.call_after_refresh(lambda: self._apply_column_widths(table))
|
||||||
|
column_keys = list(table.columns.keys())
|
||||||
|
self.title_column_key = column_keys[0]
|
||||||
|
|
||||||
|
if self.client:
|
||||||
|
self.update_status("Fetching library...")
|
||||||
|
self.fetch_library()
|
||||||
|
else:
|
||||||
|
self.update_status(
|
||||||
|
"Not authenticated. Please restart and authenticate.")
|
||||||
|
|
||||||
|
self.set_interval(1.0, self._check_playback_status)
|
||||||
|
self.set_interval(0.5, self._update_progress)
|
||||||
|
self.set_interval(30.0, self._save_position_periodically)
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
"""Clean up on app exit."""
|
||||||
|
self.playback.stop()
|
||||||
|
if self.download_manager:
|
||||||
|
self.download_manager.close()
|
||||||
|
|
||||||
|
def on_resize(self, event: Resize) -> None:
|
||||||
|
"""Keep table columns responsive to terminal width changes."""
|
||||||
|
del event
|
||||||
|
try:
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
self._apply_column_widths(table)
|
||||||
|
|
||||||
|
def on_key(self, event: Key) -> None:
|
||||||
|
"""Handle key presses."""
|
||||||
|
if self.playback.is_playing:
|
||||||
|
if event.key == "ctrl+left":
|
||||||
|
event.prevent_default()
|
||||||
|
self.action_previous_chapter()
|
||||||
|
return
|
||||||
|
elif event.key == "ctrl+right":
|
||||||
|
event.prevent_default()
|
||||||
|
self.action_next_chapter()
|
||||||
|
return
|
||||||
|
elif event.key == "left":
|
||||||
|
event.prevent_default()
|
||||||
|
self.action_seek_backward()
|
||||||
|
return
|
||||||
|
elif event.key == "right":
|
||||||
|
event.prevent_default()
|
||||||
|
self.action_seek_forward()
|
||||||
|
return
|
||||||
|
elif event.key == "up":
|
||||||
|
event.prevent_default()
|
||||||
|
self.action_increase_speed()
|
||||||
|
return
|
||||||
|
elif event.key == "down":
|
||||||
|
event.prevent_default()
|
||||||
|
self.action_decrease_speed()
|
||||||
|
return
|
||||||
|
|
||||||
|
if isinstance(self.focused, DataTable):
|
||||||
|
if event.key == "enter":
|
||||||
|
event.prevent_default()
|
||||||
|
self.action_play_selected()
|
||||||
|
elif event.key == "space":
|
||||||
|
event.prevent_default()
|
||||||
|
self.action_toggle_playback()
|
||||||
|
|
||||||
|
def update_status(self, message: str) -> None:
|
||||||
|
"""Update the status message in the UI."""
|
||||||
|
status = self.query_one("#status", Static)
|
||||||
|
status.display = True
|
||||||
|
status.update(message)
|
||||||
|
|
||||||
|
def _apply_column_widths(self, table: DataTable) -> None:
|
||||||
|
"""Assign proportional column widths based on available space."""
|
||||||
|
if not table.columns:
|
||||||
|
return
|
||||||
|
|
||||||
|
column_keys = list(table.columns.keys())
|
||||||
|
ratios = [ratio for _, ratio in TABLE_COLUMN_DEFS]
|
||||||
|
total_ratio = sum(ratios) or len(column_keys)
|
||||||
|
content_width = table.scrollable_content_region.width
|
||||||
|
available_width = content_width
|
||||||
|
if available_width <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
widths: list[int] = []
|
||||||
|
for ratio in ratios:
|
||||||
|
width = max(1, (available_width * ratio) // total_ratio)
|
||||||
|
widths.append(width)
|
||||||
|
|
||||||
|
remainder = available_width - sum(widths)
|
||||||
|
for i in range(remainder):
|
||||||
|
widths[i % len(widths)] += 1
|
||||||
|
|
||||||
|
for column_key, width in zip(column_keys, widths):
|
||||||
|
column = table.columns[column_key]
|
||||||
|
column.auto_width = False
|
||||||
|
column.width = width
|
||||||
|
table.refresh()
|
||||||
|
|
||||||
|
def _thread_status_update(self, message: str) -> None:
|
||||||
|
"""Safely update status from worker threads."""
|
||||||
|
self.call_from_thread(self.update_status, message)
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=True)
|
||||||
|
def fetch_library(self) -> None:
|
||||||
|
"""Fetch all library items from Audible API in background thread."""
|
||||||
|
worker = get_current_worker()
|
||||||
|
if worker.is_cancelled or not self.library_client:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
all_items = self.library_client.fetch_all_items(
|
||||||
|
self._thread_status_update)
|
||||||
|
self.call_from_thread(self.on_library_loaded, all_items)
|
||||||
|
except (OSError, ValueError, KeyError) as exc:
|
||||||
|
self.call_from_thread(self.on_library_error, str(exc))
|
||||||
|
|
||||||
|
def on_library_loaded(self, items: list[dict]) -> None:
|
||||||
|
"""Handle successful library load."""
|
||||||
|
self.all_items = items
|
||||||
|
self._search_text_cache.clear()
|
||||||
|
self._prime_search_cache(items)
|
||||||
|
self.update_status(f"Loaded {len(items)} books")
|
||||||
|
if self.show_all_mode:
|
||||||
|
self.show_all()
|
||||||
|
else:
|
||||||
|
self.show_unfinished()
|
||||||
|
|
||||||
|
def on_library_error(self, error: str) -> None:
|
||||||
|
"""Handle library fetch error."""
|
||||||
|
self.update_status(f"Error fetching library: {error}")
|
||||||
|
|
||||||
|
def _populate_table(self, items: list[dict]) -> None:
|
||||||
|
"""Populate the DataTable with library items."""
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
table.clear()
|
||||||
|
|
||||||
|
if not items or not self.library_client:
|
||||||
|
self.update_status("No books found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
title, author, runtime, progress, downloaded = format_item_as_row(
|
||||||
|
item, self.library_client, self.download_manager)
|
||||||
|
table.add_row(title, author, runtime,
|
||||||
|
progress, downloaded, key=title)
|
||||||
|
|
||||||
|
self.current_items = items
|
||||||
|
status = self.query_one("#status", Static)
|
||||||
|
status.display = False
|
||||||
|
|
||||||
|
def _refresh_table(self) -> None:
|
||||||
|
"""Refresh the table with current items."""
|
||||||
|
if self.current_items:
|
||||||
|
self._populate_table(self.current_items)
|
||||||
|
|
||||||
|
def show_all(self) -> None:
|
||||||
|
"""Display all books in the table."""
|
||||||
|
if not self.all_items:
|
||||||
|
return
|
||||||
|
self.show_all_mode = True
|
||||||
|
self._refresh_filtered_view()
|
||||||
|
|
||||||
|
def show_unfinished(self) -> None:
|
||||||
|
"""Display only unfinished books in the table."""
|
||||||
|
if not self.all_items or not self.library_client:
|
||||||
|
return
|
||||||
|
self.show_all_mode = False
|
||||||
|
self._refresh_filtered_view()
|
||||||
|
|
||||||
|
def action_sort(self) -> None:
|
||||||
|
"""Sort table by title, toggling direction on each press."""
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
if table.row_count > 0 and self.title_column_key:
|
||||||
|
title_key, reverse = create_title_sort_key(self.title_sort_reverse)
|
||||||
|
table.sort(key=title_key, reverse=reverse)
|
||||||
|
self.title_sort_reverse = not self.title_sort_reverse
|
||||||
|
|
||||||
|
def action_sort_by_progress(self) -> None:
|
||||||
|
"""Sort table by progress percentage, toggling direction on each press."""
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
if table.row_count > 0:
|
||||||
|
self.progress_sort_reverse = not self.progress_sort_reverse
|
||||||
|
progress_key, reverse = create_progress_sort_key(
|
||||||
|
self.progress_column_index, self.progress_sort_reverse)
|
||||||
|
table.sort(key=progress_key, reverse=reverse)
|
||||||
|
|
||||||
|
def action_show_all(self) -> None:
|
||||||
|
"""Toggle between showing all and unfinished books."""
|
||||||
|
if self.show_all_mode:
|
||||||
|
self.show_unfinished()
|
||||||
|
else:
|
||||||
|
self.show_all()
|
||||||
|
|
||||||
|
def action_refresh(self) -> None:
|
||||||
|
"""Refresh the library data from the API."""
|
||||||
|
if not self.client:
|
||||||
|
self.update_status("Not authenticated. Cannot refresh.")
|
||||||
|
return
|
||||||
|
self.update_status("Refreshing library...")
|
||||||
|
self.fetch_library()
|
||||||
|
|
||||||
|
def action_play_selected(self) -> None:
|
||||||
|
"""Start playing the selected book."""
|
||||||
|
if not self.download_manager:
|
||||||
|
self.update_status(
|
||||||
|
"Not authenticated. Please restart and authenticate.")
|
||||||
|
return
|
||||||
|
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
if table.row_count == 0:
|
||||||
|
self.update_status("No books available")
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor_row = table.cursor_row
|
||||||
|
if cursor_row >= len(self.current_items):
|
||||||
|
self.update_status("Invalid selection")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.library_client:
|
||||||
|
self.update_status("Library client not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_item = self.current_items[cursor_row]
|
||||||
|
asin = self.library_client.extract_asin(selected_item)
|
||||||
|
|
||||||
|
if not asin:
|
||||||
|
self.update_status("Could not get ASIN for selected book")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._start_playback_async(asin)
|
||||||
|
|
||||||
|
def action_toggle_playback(self) -> None:
|
||||||
|
"""Toggle pause/resume state."""
|
||||||
|
if not self.playback.toggle_playback():
|
||||||
|
self._no_playback_message()
|
||||||
|
|
||||||
|
def action_seek_forward(self) -> None:
|
||||||
|
"""Seek forward 30 seconds."""
|
||||||
|
if not self.playback.seek_forward(SEEK_SECONDS):
|
||||||
|
self._no_playback_message()
|
||||||
|
|
||||||
|
def action_seek_backward(self) -> None:
|
||||||
|
"""Seek backward 30 seconds."""
|
||||||
|
if not self.playback.seek_backward(SEEK_SECONDS):
|
||||||
|
self._no_playback_message()
|
||||||
|
|
||||||
|
def action_next_chapter(self) -> None:
|
||||||
|
"""Seek to the next chapter."""
|
||||||
|
if not self.playback.seek_to_next_chapter():
|
||||||
|
self._no_playback_message()
|
||||||
|
|
||||||
|
def action_previous_chapter(self) -> None:
|
||||||
|
"""Seek to the previous chapter."""
|
||||||
|
if not self.playback.seek_to_previous_chapter():
|
||||||
|
self._no_playback_message()
|
||||||
|
|
||||||
|
def action_increase_speed(self) -> None:
|
||||||
|
"""Increase playback speed."""
|
||||||
|
if not self.playback.increase_speed():
|
||||||
|
self._no_playback_message()
|
||||||
|
|
||||||
|
def action_decrease_speed(self) -> None:
|
||||||
|
"""Decrease playback speed."""
|
||||||
|
if not self.playback.decrease_speed():
|
||||||
|
self._no_playback_message()
|
||||||
|
|
||||||
|
def action_toggle_finished(self) -> None:
|
||||||
|
"""Toggle finished/unfinished status for the selected book."""
|
||||||
|
if not self.library_client:
|
||||||
|
self.update_status("Library client not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
if table.row_count == 0:
|
||||||
|
self.update_status("No books available")
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor_row = table.cursor_row
|
||||||
|
if cursor_row >= len(self.current_items):
|
||||||
|
self.update_status("Invalid selection")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_item = self.current_items[cursor_row]
|
||||||
|
asin = self.library_client.extract_asin(selected_item)
|
||||||
|
|
||||||
|
if not asin:
|
||||||
|
self.update_status("Could not get ASIN for selected book")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._toggle_finished_async(asin)
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=True)
|
||||||
|
def _toggle_finished_async(self, asin: str) -> None:
|
||||||
|
"""Toggle finished/unfinished status asynchronously."""
|
||||||
|
if not self.library_client:
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_item = None
|
||||||
|
for item in self.current_items:
|
||||||
|
if self.library_client.extract_asin(item) == asin:
|
||||||
|
selected_item = item
|
||||||
|
break
|
||||||
|
|
||||||
|
if not selected_item:
|
||||||
|
return
|
||||||
|
|
||||||
|
is_currently_finished = self.library_client.is_finished(selected_item)
|
||||||
|
|
||||||
|
if is_currently_finished:
|
||||||
|
self.call_from_thread(self.update_status,
|
||||||
|
"Already marked as finished")
|
||||||
|
return
|
||||||
|
|
||||||
|
success = self.library_client.mark_as_finished(asin, selected_item)
|
||||||
|
message = "Marked as finished" if success else "Failed to mark as finished"
|
||||||
|
|
||||||
|
self.call_from_thread(self.update_status, message)
|
||||||
|
if success:
|
||||||
|
if self.download_manager and self.download_manager.is_cached(asin):
|
||||||
|
self.download_manager.remove_cached(
|
||||||
|
asin, notify=self._thread_status_update
|
||||||
|
)
|
||||||
|
self.call_from_thread(self.fetch_library)
|
||||||
|
|
||||||
|
def _no_playback_message(self) -> None:
|
||||||
|
"""Show message when no playback is active."""
|
||||||
|
self.update_status("No playback active. Press Enter to play a book.")
|
||||||
|
|
||||||
|
def action_show_help(self) -> None:
|
||||||
|
"""Show the help screen with all keybindings."""
|
||||||
|
self.push_screen(HelpScreen())
|
||||||
|
|
||||||
|
def action_show_stats(self) -> None:
|
||||||
|
"""Show the stats screen with listening statistics."""
|
||||||
|
self.push_screen(StatsScreen())
|
||||||
|
|
||||||
|
def action_filter(self) -> None:
|
||||||
|
"""Show the filter screen to search the library."""
|
||||||
|
self.push_screen(
|
||||||
|
FilterScreen(
|
||||||
|
self.filter_text,
|
||||||
|
on_change=self._apply_filter,
|
||||||
|
),
|
||||||
|
self._apply_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_clear_filter(self) -> None:
|
||||||
|
"""Clear the current filter if active."""
|
||||||
|
if self.filter_text:
|
||||||
|
self.filter_text = ""
|
||||||
|
self._refresh_filtered_view()
|
||||||
|
self.update_status("Filter cleared")
|
||||||
|
|
||||||
|
def _apply_filter(self, filter_text: str) -> None:
|
||||||
|
"""Apply the filter to the library."""
|
||||||
|
self.filter_text = filter_text
|
||||||
|
self._refresh_filtered_view()
|
||||||
|
|
||||||
|
def _refresh_filtered_view(self) -> None:
|
||||||
|
"""Refresh the table with current filter and view mode."""
|
||||||
|
if not self.all_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
items = self.all_items
|
||||||
|
|
||||||
|
if self.filter_text:
|
||||||
|
items = filter_items(items, self.filter_text,
|
||||||
|
self._get_search_text)
|
||||||
|
self._populate_table(items)
|
||||||
|
self.update_status(
|
||||||
|
f"Filter: '{self.filter_text}' ({len(items)} books)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.show_all_mode and self.library_client:
|
||||||
|
items = filter_unfinished_items(items, self.library_client)
|
||||||
|
|
||||||
|
self._populate_table(items)
|
||||||
|
|
||||||
|
def _get_search_text(self, item: dict) -> str:
|
||||||
|
"""Return cached search text for filtering."""
|
||||||
|
cache_key = id(item)
|
||||||
|
cached = self._search_text_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
search_text = build_search_text(item, self.library_client)
|
||||||
|
self._search_text_cache[cache_key] = search_text
|
||||||
|
return search_text
|
||||||
|
|
||||||
|
def _prime_search_cache(self, items: list[dict]) -> None:
|
||||||
|
"""Precompute search text for a list of items."""
|
||||||
|
for item in items:
|
||||||
|
self._get_search_text(item)
|
||||||
|
|
||||||
|
def _check_playback_status(self) -> None:
|
||||||
|
"""Check if playback process has finished and update state accordingly."""
|
||||||
|
message = self.playback.check_status()
|
||||||
|
if message:
|
||||||
|
self.update_status(message)
|
||||||
|
self._hide_progress()
|
||||||
|
|
||||||
|
def _update_progress(self) -> None:
|
||||||
|
"""Update the progress bar and info during playback."""
|
||||||
|
if not self.playback.is_playing:
|
||||||
|
self._hide_progress()
|
||||||
|
return
|
||||||
|
|
||||||
|
progress_data = self.playback.get_current_progress()
|
||||||
|
if not progress_data:
|
||||||
|
self._hide_progress()
|
||||||
|
return
|
||||||
|
|
||||||
|
chapter_name, chapter_elapsed, chapter_total = progress_data
|
||||||
|
if chapter_total <= 0:
|
||||||
|
self._hide_progress()
|
||||||
|
return
|
||||||
|
|
||||||
|
progress_info = self.query_one("#progress_info", Static)
|
||||||
|
progress_bar = self.query_one("#progress_bar", ProgressBar)
|
||||||
|
progress_bar_container = self.query_one(
|
||||||
|
"#progress_bar_container", Horizontal)
|
||||||
|
|
||||||
|
progress_percent = min(100.0, max(
|
||||||
|
0.0, (chapter_elapsed / chapter_total) * 100.0))
|
||||||
|
progress_bar.update(progress=progress_percent)
|
||||||
|
chapter_elapsed_str = LibraryClient.format_time(chapter_elapsed)
|
||||||
|
chapter_total_str = LibraryClient.format_time(chapter_total)
|
||||||
|
progress_info.update(
|
||||||
|
f"{chapter_name} | {chapter_elapsed_str} / {chapter_total_str}")
|
||||||
|
progress_info.display = True
|
||||||
|
progress_bar_container.display = True
|
||||||
|
|
||||||
|
def _hide_progress(self) -> None:
|
||||||
|
"""Hide the progress widget."""
|
||||||
|
progress_info = self.query_one("#progress_info", Static)
|
||||||
|
progress_bar_container = self.query_one(
|
||||||
|
"#progress_bar_container", Horizontal)
|
||||||
|
progress_info.display = False
|
||||||
|
progress_bar_container.display = False
|
||||||
|
|
||||||
|
def _save_position_periodically(self) -> None:
|
||||||
|
"""Periodically save playback position."""
|
||||||
|
self.playback.update_position_if_needed()
|
||||||
|
|
||||||
|
def action_toggle_download(self) -> None:
|
||||||
|
"""Toggle download/remove for the selected book."""
|
||||||
|
if not self.download_manager:
|
||||||
|
self.update_status(
|
||||||
|
"Not authenticated. Please restart and authenticate.")
|
||||||
|
return
|
||||||
|
|
||||||
|
table = self.query_one(DataTable)
|
||||||
|
if table.row_count == 0:
|
||||||
|
self.update_status("No books available")
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor_row = table.cursor_row
|
||||||
|
if cursor_row >= len(self.current_items):
|
||||||
|
self.update_status("Invalid selection")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.library_client:
|
||||||
|
self.update_status("Library client not available")
|
||||||
|
return
|
||||||
|
|
||||||
|
selected_item = self.current_items[cursor_row]
|
||||||
|
asin = self.library_client.extract_asin(selected_item)
|
||||||
|
|
||||||
|
if not asin:
|
||||||
|
self.update_status("Could not get ASIN for selected book")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._toggle_download_async(asin)
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=True)
|
||||||
|
def _toggle_download_async(self, asin: str) -> None:
|
||||||
|
"""Toggle download/remove asynchronously."""
|
||||||
|
if not self.download_manager:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.download_manager.is_cached(asin):
|
||||||
|
self.download_manager.remove_cached(
|
||||||
|
asin, self._thread_status_update)
|
||||||
|
else:
|
||||||
|
self.download_manager.get_or_download(
|
||||||
|
asin, self._thread_status_update)
|
||||||
|
|
||||||
|
self.call_from_thread(self._refresh_table)
|
||||||
|
|
||||||
|
@work(exclusive=True, thread=True)
|
||||||
|
def _start_playback_async(self, asin: str) -> None:
|
||||||
|
"""Start playback asynchronously."""
|
||||||
|
if not self.download_manager:
|
||||||
|
return
|
||||||
|
self.playback.prepare_and_start(
|
||||||
|
self.download_manager,
|
||||||
|
asin,
|
||||||
|
self._thread_status_update,
|
||||||
|
)
|
||||||
24
auditui/auth.py
Normal file
24
auditui/auth.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""Authentication helpers for the Auditui app."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import audible
|
||||||
|
|
||||||
|
from .constants import AUTH_PATH
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate(
|
||||||
|
auth_path: Path = AUTH_PATH,
|
||||||
|
) -> tuple[audible.Authenticator, audible.Client]:
|
||||||
|
"""Authenticate with Audible and return authenticator and client."""
|
||||||
|
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
|
||||||
58
auditui/cli.py
Normal file
58
auditui/cli.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Auditui entrypoint."""
|
||||||
|
|
||||||
|
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:
|
||||||
|
"""Authenticate and launch the app."""
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
44
auditui/configure.py
Normal file
44
auditui/configure.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""Configuration helpers for the Auditui app."""
|
||||||
|
|
||||||
|
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]:
|
||||||
|
"""Force re-authentication and save credentials."""
|
||||||
|
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
|
||||||
275
auditui/constants.py
Normal file
275
auditui/constants.py
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
"""Shared constants for the Auditui application."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
AUTH_PATH = Path.home() / ".config" / "auditui" / "auth.json"
|
||||||
|
CONFIG_PATH = Path.home() / ".config" / "auditui" / "config.json"
|
||||||
|
CACHE_DIR = Path.home() / ".cache" / "auditui" / "books"
|
||||||
|
DOWNLOAD_URL = "https://cde-ta-g7g.amazon.com/FionaCDEServiceEngine/FSDownloadContent"
|
||||||
|
DEFAULT_CODEC = "LC_128_44100_stereo"
|
||||||
|
MIN_FILE_SIZE = 1024 * 1024
|
||||||
|
DEFAULT_CHUNK_SIZE = 8192
|
||||||
|
|
||||||
|
TABLE_COLUMN_DEFS = (
|
||||||
|
("Title", 2),
|
||||||
|
("Author", 2),
|
||||||
|
("Length", 1),
|
||||||
|
("Progress", 1),
|
||||||
|
("Downloaded", 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
AUTHOR_NAME_MAX_LENGTH = 40
|
||||||
|
AUTHOR_NAME_DISPLAY_LENGTH = 37
|
||||||
|
PROGRESS_COLUMN_INDEX = 3
|
||||||
|
SEEK_SECONDS = 30.0
|
||||||
|
|
||||||
|
TABLE_CSS = """
|
||||||
|
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 {
|
||||||
|
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: auto;
|
||||||
|
min-width: 40;
|
||||||
|
max-width: 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
236
auditui/downloads.py
Normal file
236
auditui/downloads.py
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
"""Download helpers for Audible content."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import audible
|
||||||
|
import httpx
|
||||||
|
from audible.activation_bytes import get_activation_bytes
|
||||||
|
|
||||||
|
from .constants import CACHE_DIR, DEFAULT_CHUNK_SIZE, DEFAULT_CODEC, DOWNLOAD_URL, MIN_FILE_SIZE
|
||||||
|
|
||||||
|
StatusCallback = Callable[[str], None]
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadManager:
|
||||||
|
"""Handle retrieval and download of Audible titles."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
auth: audible.Authenticator,
|
||||||
|
client: audible.Client,
|
||||||
|
cache_dir: Path = CACHE_DIR,
|
||||||
|
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
||||||
|
) -> None:
|
||||||
|
self.auth = auth
|
||||||
|
self.client = client
|
||||||
|
self.cache_dir = cache_dir
|
||||||
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.chunk_size = chunk_size
|
||||||
|
self._http_client = httpx.Client(
|
||||||
|
auth=auth, timeout=30.0, follow_redirects=True)
|
||||||
|
self._download_client = httpx.Client(
|
||||||
|
timeout=httpx.Timeout(connect=30.0, read=None,
|
||||||
|
write=30.0, pool=30.0),
|
||||||
|
follow_redirects=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_or_download(self, asin: str, notify: StatusCallback | None = None) -> Path | None:
|
||||||
|
"""Get local path of AAX file, downloading if missing."""
|
||||||
|
title = self._get_name_from_asin(asin) or asin
|
||||||
|
safe_title = self._sanitize_filename(title)
|
||||||
|
local_path = self.cache_dir / f"{safe_title}.aax"
|
||||||
|
|
||||||
|
if local_path.exists() and local_path.stat().st_size >= MIN_FILE_SIZE:
|
||||||
|
if notify:
|
||||||
|
notify(f"Using cached file: {local_path.name}")
|
||||||
|
return local_path
|
||||||
|
|
||||||
|
if notify:
|
||||||
|
notify(f"Downloading to {local_path.name}...")
|
||||||
|
|
||||||
|
dl_link = self._get_download_link(asin, notify=notify)
|
||||||
|
if not dl_link:
|
||||||
|
if notify:
|
||||||
|
notify("Failed to get download link")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not self._validate_download_url(dl_link):
|
||||||
|
if notify:
|
||||||
|
notify("Invalid download URL")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not self._download_file(dl_link, local_path, notify):
|
||||||
|
if notify:
|
||||||
|
notify("Download failed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not local_path.exists() or local_path.stat().st_size < MIN_FILE_SIZE:
|
||||||
|
if notify:
|
||||||
|
notify("Download failed or file too small")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return local_path
|
||||||
|
|
||||||
|
def get_activation_bytes(self) -> str | None:
|
||||||
|
"""Get activation bytes as hex string."""
|
||||||
|
try:
|
||||||
|
activation_bytes = get_activation_bytes(self.auth)
|
||||||
|
if isinstance(activation_bytes, bytes):
|
||||||
|
return activation_bytes.hex()
|
||||||
|
return str(activation_bytes)
|
||||||
|
except (OSError, ValueError, KeyError, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_cached_path(self, asin: str) -> Path | None:
|
||||||
|
"""Get the cached file path for a book if it exists."""
|
||||||
|
title = self._get_name_from_asin(asin) or asin
|
||||||
|
safe_title = self._sanitize_filename(title)
|
||||||
|
local_path = self.cache_dir / f"{safe_title}.aax"
|
||||||
|
if local_path.exists() and local_path.stat().st_size >= MIN_FILE_SIZE:
|
||||||
|
return local_path
|
||||||
|
return None
|
||||||
|
|
||||||
|
def is_cached(self, asin: str) -> bool:
|
||||||
|
"""Check if a book is already cached."""
|
||||||
|
return self.get_cached_path(asin) is not None
|
||||||
|
|
||||||
|
def remove_cached(self, asin: str, notify: StatusCallback | None = None) -> bool:
|
||||||
|
"""Remove a cached book file."""
|
||||||
|
cached_path = self.get_cached_path(asin)
|
||||||
|
if not cached_path:
|
||||||
|
if notify:
|
||||||
|
notify("Book is not cached")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
cached_path.unlink()
|
||||||
|
if notify:
|
||||||
|
notify(f"Removed from cache: {cached_path.name}")
|
||||||
|
return True
|
||||||
|
except OSError as exc:
|
||||||
|
if notify:
|
||||||
|
notify(f"Failed to remove cache: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _validate_download_url(self, url: str) -> bool:
|
||||||
|
"""Validate that the URL is a valid HTTP/HTTPS URL."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _sanitize_filename(self, filename: str) -> str:
|
||||||
|
"""Remove invalid characters from filename."""
|
||||||
|
return re.sub(r'[<>:"/\\|?*]', "_", filename)
|
||||||
|
|
||||||
|
def _get_name_from_asin(self, asin: str) -> str | None:
|
||||||
|
"""Get the title/name of a book from its ASIN."""
|
||||||
|
try:
|
||||||
|
product_info = self.client.get(
|
||||||
|
path=f"1.0/catalog/products/{asin}",
|
||||||
|
response_groups="product_desc,product_attrs",
|
||||||
|
)
|
||||||
|
product = product_info.get("product", {})
|
||||||
|
return product.get("title") or "Unknown Title"
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_download_link(
|
||||||
|
self, asin: str, codec: str = DEFAULT_CODEC, notify: StatusCallback | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Get download link for book."""
|
||||||
|
if self.auth.adp_token is None:
|
||||||
|
if notify:
|
||||||
|
notify("Missing ADP token (not authenticated?)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"type": "AUDI",
|
||||||
|
"currentTransportMethod": "WIFI",
|
||||||
|
"key": asin,
|
||||||
|
"codec": codec,
|
||||||
|
}
|
||||||
|
response = self._http_client.get(
|
||||||
|
url=DOWNLOAD_URL,
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
link = response.headers.get("Location")
|
||||||
|
if not link:
|
||||||
|
link = str(response.url)
|
||||||
|
|
||||||
|
tld = self.auth.locale.domain
|
||||||
|
return link.replace("cds.audible.com", f"cds.audible.{tld}")
|
||||||
|
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
if notify:
|
||||||
|
notify(f"Download-link request failed: {exc!s}")
|
||||||
|
return None
|
||||||
|
except (OSError, ValueError, KeyError, AttributeError) as exc:
|
||||||
|
if notify:
|
||||||
|
notify(f"Download-link error: {exc!s}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _download_file(
|
||||||
|
self, url: str, dest_path: Path, notify: StatusCallback | None = None
|
||||||
|
) -> Path | None:
|
||||||
|
"""Download file from URL to destination."""
|
||||||
|
try:
|
||||||
|
with self._download_client.stream("GET", url) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
total_size = int(response.headers.get("content-length", 0))
|
||||||
|
downloaded = 0
|
||||||
|
|
||||||
|
with open(dest_path, "wb") as file_handle:
|
||||||
|
for chunk in response.iter_bytes(chunk_size=self.chunk_size):
|
||||||
|
file_handle.write(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
if total_size > 0 and notify:
|
||||||
|
percent = (downloaded / total_size) * 100
|
||||||
|
notify(
|
||||||
|
f"Downloading: {percent:.1f}% ({downloaded}/{total_size} bytes)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return dest_path
|
||||||
|
except httpx.HTTPStatusError as exc:
|
||||||
|
if notify:
|
||||||
|
notify(
|
||||||
|
f"Download HTTP error: {exc.response.status_code} {exc.response.reason_phrase}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
if dest_path.exists() and dest_path.stat().st_size < MIN_FILE_SIZE:
|
||||||
|
dest_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
if notify:
|
||||||
|
notify(f"Download network error: {exc!s}")
|
||||||
|
try:
|
||||||
|
if dest_path.exists() and dest_path.stat().st_size < MIN_FILE_SIZE:
|
||||||
|
dest_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
except (OSError, ValueError, KeyError) as exc:
|
||||||
|
if notify:
|
||||||
|
notify(f"Download error: {exc!s}")
|
||||||
|
try:
|
||||||
|
if dest_path.exists() and dest_path.stat().st_size < MIN_FILE_SIZE:
|
||||||
|
dest_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Close the HTTP clients and release resources."""
|
||||||
|
if hasattr(self, "_http_client"):
|
||||||
|
self._http_client.close()
|
||||||
|
if hasattr(self, "_download_client"):
|
||||||
|
self._download_client.close()
|
||||||
367
auditui/library.py
Normal file
367
auditui/library.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
"""Library helpers for fetching and formatting Audible data."""
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import audible
|
||||||
|
|
||||||
|
|
||||||
|
ProgressCallback = Callable[[str], None]
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryClient:
|
||||||
|
"""Helper for interacting with the Audible library."""
|
||||||
|
|
||||||
|
def __init__(self, client: audible.Client) -> None:
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def fetch_all_items(self, on_progress: ProgressCallback | None = None) -> list:
|
||||||
|
"""Fetch all library items from the API."""
|
||||||
|
response_groups = (
|
||||||
|
"contributors,media,product_attrs,product_desc,product_details,"
|
||||||
|
"is_finished,listening_status,percent_complete"
|
||||||
|
)
|
||||||
|
return self._fetch_all_pages(response_groups, on_progress)
|
||||||
|
|
||||||
|
def _fetch_page(
|
||||||
|
self, page: int, page_size: int, response_groups: str
|
||||||
|
) -> tuple[int, list[dict]]:
|
||||||
|
"""Fetch a single page of library items."""
|
||||||
|
library = self.client.get(
|
||||||
|
path="library",
|
||||||
|
num_results=page_size,
|
||||||
|
page=page,
|
||||||
|
response_groups=response_groups,
|
||||||
|
)
|
||||||
|
items = library.get("items", [])
|
||||||
|
return page, list(items)
|
||||||
|
|
||||||
|
def _fetch_all_pages(
|
||||||
|
self, response_groups: str, on_progress: ProgressCallback | None = None
|
||||||
|
) -> list:
|
||||||
|
"""Fetch all pages of library items from the API using maximum parallel fetching."""
|
||||||
|
library_response = None
|
||||||
|
page_size = 200
|
||||||
|
|
||||||
|
for attempt_size in [200, 100, 50]:
|
||||||
|
try:
|
||||||
|
library_response = self.client.get(
|
||||||
|
path="library",
|
||||||
|
num_results=attempt_size,
|
||||||
|
page=1,
|
||||||
|
response_groups=response_groups,
|
||||||
|
)
|
||||||
|
page_size = attempt_size
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not library_response:
|
||||||
|
return []
|
||||||
|
|
||||||
|
first_page_items = library_response.get("items", [])
|
||||||
|
if not first_page_items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_items: list[dict] = list(first_page_items)
|
||||||
|
if on_progress:
|
||||||
|
on_progress(f"Fetched page 1 ({len(first_page_items)} items)...")
|
||||||
|
|
||||||
|
if len(first_page_items) < page_size:
|
||||||
|
return all_items
|
||||||
|
|
||||||
|
total_items_estimate = library_response.get(
|
||||||
|
"total_results") or library_response.get("total")
|
||||||
|
if total_items_estimate:
|
||||||
|
estimated_pages = (total_items_estimate +
|
||||||
|
page_size - 1) // page_size
|
||||||
|
estimated_pages = min(estimated_pages, 1000)
|
||||||
|
else:
|
||||||
|
estimated_pages = 500
|
||||||
|
|
||||||
|
max_workers = 50
|
||||||
|
page_results: dict[int, list[dict]] = {}
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
|
future_to_page: dict = {}
|
||||||
|
|
||||||
|
for page in range(2, estimated_pages + 1):
|
||||||
|
future = executor.submit(
|
||||||
|
self._fetch_page, page, page_size, response_groups
|
||||||
|
)
|
||||||
|
future_to_page[future] = page
|
||||||
|
|
||||||
|
completed_count = 0
|
||||||
|
total_items = len(first_page_items)
|
||||||
|
|
||||||
|
for future in as_completed(future_to_page):
|
||||||
|
page_num = future_to_page.pop(future)
|
||||||
|
try:
|
||||||
|
fetched_page, items = future.result()
|
||||||
|
if not items or len(items) < page_size:
|
||||||
|
for remaining_future in list(future_to_page.keys()):
|
||||||
|
remaining_future.cancel()
|
||||||
|
break
|
||||||
|
|
||||||
|
page_results[fetched_page] = items
|
||||||
|
total_items += len(items)
|
||||||
|
completed_count += 1
|
||||||
|
if on_progress and completed_count % 20 == 0:
|
||||||
|
on_progress(
|
||||||
|
f"Fetched {completed_count} pages ({total_items} items)..."
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for page_num in sorted(page_results.keys()):
|
||||||
|
all_items.extend(page_results[page_num])
|
||||||
|
|
||||||
|
return all_items
|
||||||
|
|
||||||
|
def extract_title(self, item: dict) -> str:
|
||||||
|
"""Extract title from library item."""
|
||||||
|
product = item.get("product", {})
|
||||||
|
return (
|
||||||
|
product.get("title")
|
||||||
|
or item.get("title")
|
||||||
|
or product.get("asin", "Unknown Title")
|
||||||
|
)
|
||||||
|
|
||||||
|
def extract_authors(self, item: dict) -> str:
|
||||||
|
"""Extract author names from library item."""
|
||||||
|
product = item.get("product", {})
|
||||||
|
authors = product.get("authors") or product.get("contributors") or []
|
||||||
|
if not authors and "authors" in item:
|
||||||
|
authors = item.get("authors", [])
|
||||||
|
|
||||||
|
author_names = [a.get("name", "")
|
||||||
|
for a in authors if isinstance(a, dict)]
|
||||||
|
return ", ".join(author_names) or "Unknown"
|
||||||
|
|
||||||
|
def extract_runtime_minutes(self, item: dict) -> int | None:
|
||||||
|
"""Extract runtime in minutes from library item."""
|
||||||
|
product = item.get("product", {})
|
||||||
|
runtime_fields = [
|
||||||
|
"runtime_length_min",
|
||||||
|
"runtime_length",
|
||||||
|
"vLength",
|
||||||
|
"length",
|
||||||
|
"duration",
|
||||||
|
]
|
||||||
|
|
||||||
|
runtime = None
|
||||||
|
for field in runtime_fields:
|
||||||
|
runtime = product.get(field) or item.get(field)
|
||||||
|
if runtime is not None:
|
||||||
|
break
|
||||||
|
|
||||||
|
if runtime is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(runtime, dict):
|
||||||
|
return int(runtime.get("min", 0))
|
||||||
|
if isinstance(runtime, (int, float)):
|
||||||
|
return int(runtime)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_progress_info(self, item: dict) -> float | None:
|
||||||
|
"""Extract progress percentage from library item."""
|
||||||
|
percent_complete = item.get("percent_complete")
|
||||||
|
listening_status = item.get("listening_status", {})
|
||||||
|
|
||||||
|
if isinstance(listening_status, dict) and percent_complete is None:
|
||||||
|
percent_complete = listening_status.get("percent_complete")
|
||||||
|
|
||||||
|
return float(percent_complete) if percent_complete is not None else None
|
||||||
|
|
||||||
|
def extract_asin(self, item: dict) -> str | None:
|
||||||
|
"""Extract ASIN from library item."""
|
||||||
|
product = item.get("product", {})
|
||||||
|
return item.get("asin") or product.get("asin")
|
||||||
|
|
||||||
|
def is_finished(self, item: dict) -> bool:
|
||||||
|
"""Check if a library item is finished."""
|
||||||
|
is_finished_flag = item.get("is_finished")
|
||||||
|
percent_complete = item.get("percent_complete")
|
||||||
|
listening_status = item.get("listening_status")
|
||||||
|
|
||||||
|
if isinstance(listening_status, dict):
|
||||||
|
is_finished_flag = is_finished_flag or listening_status.get(
|
||||||
|
"is_finished", False
|
||||||
|
)
|
||||||
|
if percent_complete is None:
|
||||||
|
percent_complete = listening_status.get("percent_complete", 0)
|
||||||
|
|
||||||
|
return bool(is_finished_flag) or (
|
||||||
|
isinstance(percent_complete, (int, float)
|
||||||
|
) and percent_complete >= 100
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_last_position(self, asin: str) -> float | None:
|
||||||
|
"""Get the last playback position for a book in seconds."""
|
||||||
|
try:
|
||||||
|
response = self.client.get(
|
||||||
|
path="1.0/annotations/lastpositions",
|
||||||
|
asins=asin,
|
||||||
|
)
|
||||||
|
annotations = response.get("asin_last_position_heard_annots", [])
|
||||||
|
|
||||||
|
for annot in annotations:
|
||||||
|
if annot.get("asin") != asin:
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_position_heard = annot.get("last_position_heard", {})
|
||||||
|
if not isinstance(last_position_heard, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if last_position_heard.get("status") == "DoesNotExist":
|
||||||
|
return None
|
||||||
|
|
||||||
|
position_ms = last_position_heard.get("position_ms")
|
||||||
|
if position_ms is not None:
|
||||||
|
return float(position_ms) / 1000.0
|
||||||
|
|
||||||
|
return None
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_content_reference(self, asin: str) -> dict | None:
|
||||||
|
"""Get content reference data including ACR and version."""
|
||||||
|
try:
|
||||||
|
response = self.client.get(
|
||||||
|
path=f"1.0/content/{asin}/metadata",
|
||||||
|
response_groups="content_reference",
|
||||||
|
)
|
||||||
|
content_metadata = response.get("content_metadata", {})
|
||||||
|
content_reference = content_metadata.get("content_reference", {})
|
||||||
|
if isinstance(content_reference, dict):
|
||||||
|
return content_reference
|
||||||
|
return None
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _update_position(self, asin: str, position_seconds: float) -> bool:
|
||||||
|
"""Update the playback position for a book."""
|
||||||
|
if position_seconds < 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
content_ref = self._get_content_reference(asin)
|
||||||
|
if not content_ref:
|
||||||
|
return False
|
||||||
|
|
||||||
|
acr = content_ref.get("acr")
|
||||||
|
if not acr:
|
||||||
|
return False
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"acr": acr,
|
||||||
|
"asin": asin,
|
||||||
|
"position_ms": int(position_seconds * 1000),
|
||||||
|
}
|
||||||
|
|
||||||
|
if version := content_ref.get("version"):
|
||||||
|
body["version"] = version
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.put(
|
||||||
|
path=f"1.0/lastpositions/{asin}",
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def save_last_position(self, asin: str, position_seconds: float) -> bool:
|
||||||
|
"""Save the last playback position for a book."""
|
||||||
|
if position_seconds <= 0:
|
||||||
|
return False
|
||||||
|
return self._update_position(asin, position_seconds)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_duration(
|
||||||
|
value: int | None, unit: str = "minutes", default_none: str | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""Format duration value into a compact string."""
|
||||||
|
if value is None or value <= 0:
|
||||||
|
return default_none
|
||||||
|
|
||||||
|
total_minutes = int(value)
|
||||||
|
if unit == "seconds":
|
||||||
|
total_minutes //= 60
|
||||||
|
|
||||||
|
hours, minutes = divmod(total_minutes, 60)
|
||||||
|
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}h{minutes:02d}" if minutes else f"{hours}h"
|
||||||
|
return f"{minutes}m"
|
||||||
|
|
||||||
|
def mark_as_finished(self, asin: str, item: dict | None = None) -> bool:
|
||||||
|
"""Mark a book as finished by setting position to the end."""
|
||||||
|
total_ms = self._get_runtime_ms(asin, item)
|
||||||
|
if not total_ms:
|
||||||
|
return False
|
||||||
|
|
||||||
|
position_ms = total_ms
|
||||||
|
acr = self._get_acr(asin)
|
||||||
|
if not acr:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.put(
|
||||||
|
path=f"1.0/lastpositions/{asin}",
|
||||||
|
body={"asin": asin, "acr": acr, "position_ms": position_ms},
|
||||||
|
)
|
||||||
|
if item:
|
||||||
|
item["is_finished"] = True
|
||||||
|
listening_status = item.get("listening_status", {})
|
||||||
|
if isinstance(listening_status, dict):
|
||||||
|
listening_status["is_finished"] = True
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_runtime_ms(self, asin: str, item: dict | None = None) -> int | None:
|
||||||
|
"""Get total runtime in milliseconds."""
|
||||||
|
if item:
|
||||||
|
runtime_min = self.extract_runtime_minutes(item)
|
||||||
|
if runtime_min:
|
||||||
|
return runtime_min * 60 * 1000
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self.client.get(
|
||||||
|
path=f"1.0/content/{asin}/metadata",
|
||||||
|
response_groups="chapter_info",
|
||||||
|
)
|
||||||
|
chapter_info = response.get(
|
||||||
|
"content_metadata", {}).get("chapter_info", {})
|
||||||
|
return chapter_info.get("runtime_length_ms")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_acr(self, asin: str) -> str | None:
|
||||||
|
"""Get ACR token needed for position updates."""
|
||||||
|
try:
|
||||||
|
response = self.client.post(
|
||||||
|
path=f"1.0/content/{asin}/licenserequest",
|
||||||
|
body={
|
||||||
|
"response_groups": "content_reference",
|
||||||
|
"consumption_type": "Download",
|
||||||
|
"drm_type": "Adrm",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return response.get("content_license", {}).get("acr")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def format_time(seconds: float) -> str:
|
||||||
|
"""Format seconds as HH:MM:SS or MM:SS."""
|
||||||
|
total_seconds = int(seconds)
|
||||||
|
hours = total_seconds // 3600
|
||||||
|
minutes = (total_seconds % 3600) // 60
|
||||||
|
secs = total_seconds % 60
|
||||||
|
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
|
||||||
|
return f"{minutes:02d}:{secs:02d}"
|
||||||
42
auditui/media_info.py
Normal file
42
auditui/media_info.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Media information loading for Audible content."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_media_info(path: Path, activation_hex: str | None = None) -> tuple[float | None, list[dict]]:
|
||||||
|
"""Load media information including duration and chapters using ffprobe."""
|
||||||
|
if not shutil.which("ffprobe"):
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = ["ffprobe", "-v", "quiet", "-print_format",
|
||||||
|
"json", "-show_format", "-show_chapters"]
|
||||||
|
if activation_hex:
|
||||||
|
cmd.extend(["-activation_bytes", activation_hex])
|
||||||
|
cmd.append(str(path))
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
data = json.loads(result.stdout)
|
||||||
|
format_info = data.get("format", {})
|
||||||
|
duration_str = format_info.get("duration")
|
||||||
|
duration = float(duration_str) if duration_str else None
|
||||||
|
|
||||||
|
chapters_data = data.get("chapters", [])
|
||||||
|
chapters = [
|
||||||
|
{
|
||||||
|
"start_time": float(ch.get("start_time", 0)),
|
||||||
|
"end_time": float(ch.get("end_time", 0)),
|
||||||
|
"title": ch.get("tags", {}).get("title", f"Chapter {idx + 1}"),
|
||||||
|
}
|
||||||
|
for idx, ch in enumerate(chapters_data)
|
||||||
|
]
|
||||||
|
return duration, chapters
|
||||||
|
except (json.JSONDecodeError, subprocess.TimeoutExpired, ValueError, KeyError):
|
||||||
|
return None, []
|
||||||
513
auditui/playback.py
Normal file
513
auditui/playback.py
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
"""Playback control for Auditui."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .downloads import DownloadManager
|
||||||
|
from .library import LibraryClient
|
||||||
|
from .media_info import load_media_info
|
||||||
|
|
||||||
|
StatusCallback = Callable[[str], None]
|
||||||
|
|
||||||
|
MIN_SPEED = 0.5
|
||||||
|
MAX_SPEED = 2.0
|
||||||
|
SPEED_INCREMENT = 0.5
|
||||||
|
|
||||||
|
|
||||||
|
class PlaybackController:
|
||||||
|
"""Manage playback through ffplay."""
|
||||||
|
|
||||||
|
def __init__(self, notify: StatusCallback, library_client: LibraryClient | None = None) -> None:
|
||||||
|
self.notify = notify
|
||||||
|
self.library_client = library_client
|
||||||
|
self.playback_process: subprocess.Popen | None = None
|
||||||
|
self.is_playing = False
|
||||||
|
self.is_paused = False
|
||||||
|
self.current_file_path: Path | None = None
|
||||||
|
self.current_asin: str | None = None
|
||||||
|
self.playback_start_time: float | None = None
|
||||||
|
self.paused_duration: float = 0.0
|
||||||
|
self.pause_start_time: float | None = None
|
||||||
|
self.total_duration: float | None = None
|
||||||
|
self.chapters: list[dict] = []
|
||||||
|
self.seek_offset: float = 0.0
|
||||||
|
self.activation_hex: str | None = None
|
||||||
|
self.last_save_time: float = 0.0
|
||||||
|
self.position_save_interval: float = 30.0
|
||||||
|
self.playback_speed: float = 1.0
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
activation_hex: str | None = None,
|
||||||
|
status_callback: StatusCallback | None = None,
|
||||||
|
start_position: float = 0.0,
|
||||||
|
speed: float | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Start playing a local file using ffplay."""
|
||||||
|
notify = status_callback or self.notify
|
||||||
|
|
||||||
|
if not shutil.which("ffplay"):
|
||||||
|
notify("ffplay not found. Please install ffmpeg")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.playback_process is not None:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
self.activation_hex = activation_hex
|
||||||
|
self.seek_offset = start_position
|
||||||
|
if speed is not None:
|
||||||
|
self.playback_speed = speed
|
||||||
|
|
||||||
|
cmd = ["ffplay", "-nodisp", "-autoexit"]
|
||||||
|
if activation_hex:
|
||||||
|
cmd.extend(["-activation_bytes", activation_hex])
|
||||||
|
if start_position > 0:
|
||||||
|
cmd.extend(["-ss", str(start_position)])
|
||||||
|
if self.playback_speed != 1.0:
|
||||||
|
cmd.extend(["-af", f"atempo={self.playback_speed:.2f}"])
|
||||||
|
cmd.append(str(path))
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.playback_process = subprocess.Popen(
|
||||||
|
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
|
||||||
|
time.sleep(0.2)
|
||||||
|
if self.playback_process.poll() is not None:
|
||||||
|
return_code = self.playback_process.returncode
|
||||||
|
if return_code == 0 and start_position > 0 and self.total_duration:
|
||||||
|
if start_position >= self.total_duration - 5:
|
||||||
|
notify("Reached end of file")
|
||||||
|
self._reset_state()
|
||||||
|
return False
|
||||||
|
notify(
|
||||||
|
f"Playback process exited immediately (code: {return_code})")
|
||||||
|
self.playback_process = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.is_playing = True
|
||||||
|
self.is_paused = False
|
||||||
|
self.current_file_path = path
|
||||||
|
self.playback_start_time = time.time()
|
||||||
|
self.paused_duration = 0.0
|
||||||
|
self.pause_start_time = None
|
||||||
|
duration, chapters = load_media_info(path, activation_hex)
|
||||||
|
self.total_duration = duration
|
||||||
|
self.chapters = chapters
|
||||||
|
notify(f"Playing: {path.name}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
||||||
|
notify(f"Error starting playback: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the current playback."""
|
||||||
|
if self.playback_process is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._save_current_position()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.playback_process.poll() is None:
|
||||||
|
self.playback_process.terminate()
|
||||||
|
try:
|
||||||
|
self.playback_process.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.playback_process.kill()
|
||||||
|
self.playback_process.wait()
|
||||||
|
except (ProcessLookupError, ValueError):
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._reset_state()
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
"""Pause the current playback."""
|
||||||
|
if not self._validate_playback_state(require_paused=False):
|
||||||
|
return
|
||||||
|
|
||||||
|
self.pause_start_time = time.time()
|
||||||
|
self._send_signal(signal.SIGSTOP, "Paused", "pause")
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
"""Resume the current playback."""
|
||||||
|
if not self._validate_playback_state(require_paused=True):
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.pause_start_time is not None:
|
||||||
|
self.paused_duration += time.time() - self.pause_start_time
|
||||||
|
self.pause_start_time = None
|
||||||
|
self._send_signal(signal.SIGCONT, "Playing", "resume")
|
||||||
|
|
||||||
|
def check_status(self) -> str | None:
|
||||||
|
"""Check if playback process has finished and return status message."""
|
||||||
|
if self.playback_process is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return_code = self.playback_process.poll()
|
||||||
|
if return_code is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
finished_file = self.current_file_path
|
||||||
|
self._reset_state()
|
||||||
|
|
||||||
|
if finished_file:
|
||||||
|
if return_code == 0:
|
||||||
|
return f"Finished: {finished_file.name}"
|
||||||
|
return f"Playback ended unexpectedly (code: {return_code}): {finished_file.name}"
|
||||||
|
return "Playback finished"
|
||||||
|
|
||||||
|
def _reset_state(self) -> None:
|
||||||
|
"""Reset all playback state."""
|
||||||
|
self.playback_process = None
|
||||||
|
self.is_playing = False
|
||||||
|
self.is_paused = False
|
||||||
|
self.current_file_path = None
|
||||||
|
self.current_asin = None
|
||||||
|
self.playback_start_time = None
|
||||||
|
self.paused_duration = 0.0
|
||||||
|
self.pause_start_time = None
|
||||||
|
self.total_duration = None
|
||||||
|
self.chapters = []
|
||||||
|
self.seek_offset = 0.0
|
||||||
|
self.activation_hex = None
|
||||||
|
self.last_save_time = 0.0
|
||||||
|
self.playback_speed = 1.0
|
||||||
|
|
||||||
|
def _validate_playback_state(self, require_paused: bool) -> bool:
|
||||||
|
"""Validate playback state before pause/resume operations."""
|
||||||
|
if not (self.playback_process and self.is_playing):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if require_paused and not self.is_paused:
|
||||||
|
return False
|
||||||
|
if not require_paused and self.is_paused:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.is_alive():
|
||||||
|
self.stop()
|
||||||
|
self.notify("Playback process has ended")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _send_signal(self, sig: signal.Signals, status_prefix: str, action: str) -> None:
|
||||||
|
"""Send signal to playback process and update state."""
|
||||||
|
if self.playback_process is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.kill(self.playback_process.pid, sig)
|
||||||
|
self.is_paused = sig == signal.SIGSTOP
|
||||||
|
filename = self.current_file_path.name if self.current_file_path else None
|
||||||
|
message = f"{status_prefix}: {filename}" if filename else status_prefix
|
||||||
|
self.notify(message)
|
||||||
|
except ProcessLookupError:
|
||||||
|
self.stop()
|
||||||
|
self.notify("Process no longer exists")
|
||||||
|
except PermissionError:
|
||||||
|
self.notify(f"Permission denied: cannot {action} playback")
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
self.notify(f"Error {action}ing playback: {exc}")
|
||||||
|
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
"""Check if playback process is still running."""
|
||||||
|
if self.playback_process is None:
|
||||||
|
return False
|
||||||
|
return self.playback_process.poll() is None
|
||||||
|
|
||||||
|
def prepare_and_start(
|
||||||
|
self,
|
||||||
|
download_manager: DownloadManager,
|
||||||
|
asin: str,
|
||||||
|
status_callback: StatusCallback | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Download file, get activation bytes, and start playback."""
|
||||||
|
notify = status_callback or self.notify
|
||||||
|
|
||||||
|
if not download_manager:
|
||||||
|
notify("Could not download file")
|
||||||
|
return False
|
||||||
|
|
||||||
|
notify("Preparing playback...")
|
||||||
|
|
||||||
|
local_path = download_manager.get_or_download(asin, notify)
|
||||||
|
if not local_path:
|
||||||
|
notify("Could not download file")
|
||||||
|
return False
|
||||||
|
|
||||||
|
notify("Getting activation bytes...")
|
||||||
|
activation_hex = download_manager.get_activation_bytes()
|
||||||
|
if not activation_hex:
|
||||||
|
notify("Failed to get activation bytes")
|
||||||
|
return False
|
||||||
|
|
||||||
|
start_position = 0.0
|
||||||
|
if self.library_client:
|
||||||
|
try:
|
||||||
|
last_position = self.library_client.get_last_position(asin)
|
||||||
|
if last_position is not None and last_position > 0:
|
||||||
|
start_position = last_position
|
||||||
|
notify(
|
||||||
|
f"Resuming from {LibraryClient.format_time(start_position)}")
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
notify(f"Starting playback of {local_path.name}...")
|
||||||
|
self.current_asin = asin
|
||||||
|
self.last_save_time = time.time()
|
||||||
|
return self.start(local_path, activation_hex, notify, start_position, self.playback_speed)
|
||||||
|
|
||||||
|
def toggle_playback(self) -> bool:
|
||||||
|
"""Toggle pause/resume state. Returns True if action was taken."""
|
||||||
|
if not self.is_playing:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.is_alive():
|
||||||
|
self.stop()
|
||||||
|
self.notify("Playback has ended")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.is_paused:
|
||||||
|
self.resume()
|
||||||
|
else:
|
||||||
|
self.pause()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get_current_elapsed(self) -> float:
|
||||||
|
"""Calculate current elapsed playback time."""
|
||||||
|
if self.playback_start_time is None:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
if self.is_paused and self.pause_start_time is not None:
|
||||||
|
return (self.pause_start_time - self.playback_start_time) - self.paused_duration
|
||||||
|
|
||||||
|
if self.pause_start_time is not None:
|
||||||
|
self.paused_duration += current_time - self.pause_start_time
|
||||||
|
self.pause_start_time = None
|
||||||
|
|
||||||
|
return max(0.0, (current_time - self.playback_start_time) - self.paused_duration)
|
||||||
|
|
||||||
|
def _stop_process(self) -> None:
|
||||||
|
"""Stop the playback process without resetting state."""
|
||||||
|
if not self.playback_process:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.playback_process.poll() is None:
|
||||||
|
self.playback_process.terminate()
|
||||||
|
try:
|
||||||
|
self.playback_process.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.playback_process.kill()
|
||||||
|
self.playback_process.wait()
|
||||||
|
except (ProcessLookupError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.playback_process = None
|
||||||
|
self.is_playing = False
|
||||||
|
self.is_paused = False
|
||||||
|
self.playback_start_time = None
|
||||||
|
self.paused_duration = 0.0
|
||||||
|
self.pause_start_time = None
|
||||||
|
|
||||||
|
def _get_saved_state(self) -> dict:
|
||||||
|
"""Get current playback state for saving."""
|
||||||
|
return {
|
||||||
|
"file_path": self.current_file_path,
|
||||||
|
"asin": self.current_asin,
|
||||||
|
"activation": self.activation_hex,
|
||||||
|
"duration": self.total_duration,
|
||||||
|
"chapters": self.chapters.copy(),
|
||||||
|
"speed": self.playback_speed,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _restart_at_position(
|
||||||
|
self, new_position: float, new_speed: float | None = None, message: str | None = None
|
||||||
|
) -> bool:
|
||||||
|
"""Restart playback at a new position, optionally with new speed."""
|
||||||
|
if not self.is_playing or not self.current_file_path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
was_paused = self.is_paused
|
||||||
|
saved_state = self._get_saved_state()
|
||||||
|
speed = new_speed if new_speed is not None else saved_state["speed"]
|
||||||
|
|
||||||
|
self._stop_process()
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
if self.start(saved_state["file_path"], saved_state["activation"], self.notify, new_position, speed):
|
||||||
|
self.current_asin = saved_state["asin"]
|
||||||
|
self.total_duration = saved_state["duration"]
|
||||||
|
self.chapters = saved_state["chapters"]
|
||||||
|
if was_paused:
|
||||||
|
time.sleep(0.3)
|
||||||
|
self.pause()
|
||||||
|
if message:
|
||||||
|
self.notify(message)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _seek(self, seconds: float, direction: str) -> bool:
|
||||||
|
"""Seek forward or backward by specified seconds."""
|
||||||
|
elapsed = self._get_current_elapsed()
|
||||||
|
current_total_position = self.seek_offset + elapsed
|
||||||
|
|
||||||
|
if direction == "forward":
|
||||||
|
new_position = current_total_position + seconds
|
||||||
|
if self.total_duration:
|
||||||
|
if new_position >= self.total_duration - 2:
|
||||||
|
self.notify("Already at end of file")
|
||||||
|
return False
|
||||||
|
new_position = min(new_position, self.total_duration - 2)
|
||||||
|
message = f"Skipped forward {int(seconds)}s"
|
||||||
|
else:
|
||||||
|
new_position = max(0.0, current_total_position - seconds)
|
||||||
|
message = f"Skipped backward {int(seconds)}s"
|
||||||
|
|
||||||
|
return self._restart_at_position(new_position, message=message)
|
||||||
|
|
||||||
|
def seek_forward(self, seconds: float = 30.0) -> bool:
|
||||||
|
"""Seek forward by specified seconds. Returns True if action was taken."""
|
||||||
|
return self._seek(seconds, "forward")
|
||||||
|
|
||||||
|
def seek_backward(self, seconds: float = 30.0) -> bool:
|
||||||
|
"""Seek backward by specified seconds. Returns True if action was taken."""
|
||||||
|
return self._seek(seconds, "backward")
|
||||||
|
|
||||||
|
def get_current_progress(self) -> tuple[str, float, float] | None:
|
||||||
|
"""Get current playback progress."""
|
||||||
|
if not self.is_playing or self.playback_start_time is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
elapsed = self._get_current_elapsed()
|
||||||
|
total_elapsed = self.seek_offset + elapsed
|
||||||
|
chapter_name, chapter_elapsed, chapter_total = self._get_current_chapter(
|
||||||
|
total_elapsed)
|
||||||
|
return (chapter_name, chapter_elapsed, chapter_total)
|
||||||
|
|
||||||
|
def _get_current_chapter(self, elapsed: float) -> tuple[str, float, float]:
|
||||||
|
"""Get current chapter info."""
|
||||||
|
if not self.chapters:
|
||||||
|
return ("Unknown Chapter", elapsed, self.total_duration or 0.0)
|
||||||
|
|
||||||
|
for chapter in self.chapters:
|
||||||
|
if chapter["start_time"] <= elapsed < chapter["end_time"]:
|
||||||
|
chapter_elapsed = elapsed - chapter["start_time"]
|
||||||
|
chapter_total = chapter["end_time"] - chapter["start_time"]
|
||||||
|
return (chapter["title"], chapter_elapsed, chapter_total)
|
||||||
|
|
||||||
|
last_chapter = self.chapters[-1]
|
||||||
|
chapter_elapsed = max(0.0, elapsed - last_chapter["start_time"])
|
||||||
|
chapter_total = last_chapter["end_time"] - last_chapter["start_time"]
|
||||||
|
return (last_chapter["title"], chapter_elapsed, chapter_total)
|
||||||
|
|
||||||
|
def _get_current_chapter_index(self, elapsed: float) -> int | None:
|
||||||
|
"""Get the index of the current chapter based on elapsed time."""
|
||||||
|
if not self.chapters:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for idx, chapter in enumerate(self.chapters):
|
||||||
|
if chapter["start_time"] <= elapsed < chapter["end_time"]:
|
||||||
|
return idx
|
||||||
|
|
||||||
|
return len(self.chapters) - 1
|
||||||
|
|
||||||
|
def seek_to_chapter(self, direction: str) -> bool:
|
||||||
|
"""Seek to next or previous chapter."""
|
||||||
|
if not self.is_playing or not self.current_file_path:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.chapters:
|
||||||
|
self.notify("No chapter information available")
|
||||||
|
return False
|
||||||
|
|
||||||
|
elapsed = self._get_current_elapsed()
|
||||||
|
current_total_position = self.seek_offset + elapsed
|
||||||
|
current_chapter_idx = self._get_current_chapter_index(
|
||||||
|
current_total_position)
|
||||||
|
|
||||||
|
if current_chapter_idx is None:
|
||||||
|
self.notify("Could not determine current chapter")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if direction == "next":
|
||||||
|
if current_chapter_idx >= len(self.chapters) - 1:
|
||||||
|
self.notify("Already at last chapter")
|
||||||
|
return False
|
||||||
|
target_chapter = self.chapters[current_chapter_idx + 1]
|
||||||
|
new_position = target_chapter["start_time"]
|
||||||
|
message = f"Next chapter: {target_chapter['title']}"
|
||||||
|
else:
|
||||||
|
if current_chapter_idx <= 0:
|
||||||
|
self.notify("Already at first chapter")
|
||||||
|
return False
|
||||||
|
target_chapter = self.chapters[current_chapter_idx - 1]
|
||||||
|
new_position = target_chapter["start_time"]
|
||||||
|
message = f"Previous chapter: {target_chapter['title']}"
|
||||||
|
|
||||||
|
return self._restart_at_position(new_position, message=message)
|
||||||
|
|
||||||
|
def seek_to_next_chapter(self) -> bool:
|
||||||
|
"""Seek to the next chapter. Returns True if action was taken."""
|
||||||
|
return self.seek_to_chapter("next")
|
||||||
|
|
||||||
|
def seek_to_previous_chapter(self) -> bool:
|
||||||
|
"""Seek to the previous chapter. Returns True if action was taken."""
|
||||||
|
return self.seek_to_chapter("previous")
|
||||||
|
|
||||||
|
def _save_current_position(self) -> None:
|
||||||
|
"""Save the current playback position to Audible."""
|
||||||
|
if not (self.library_client and self.current_asin and self.is_playing):
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.playback_start_time is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_position = self.seek_offset + self._get_current_elapsed()
|
||||||
|
if current_position <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.library_client.save_last_position(
|
||||||
|
self.current_asin, current_position)
|
||||||
|
except (OSError, ValueError, KeyError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update_position_if_needed(self) -> None:
|
||||||
|
"""Periodically save position if enough time has passed."""
|
||||||
|
if not (self.is_playing and self.library_client and self.current_asin):
|
||||||
|
return
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - self.last_save_time >= self.position_save_interval:
|
||||||
|
self._save_current_position()
|
||||||
|
self.last_save_time = current_time
|
||||||
|
|
||||||
|
def _change_speed(self, delta: float) -> bool:
|
||||||
|
"""Change playback speed by delta amount. Returns True if action was taken."""
|
||||||
|
new_speed = max(MIN_SPEED, min(MAX_SPEED, self.playback_speed + delta))
|
||||||
|
if new_speed == self.playback_speed:
|
||||||
|
return False
|
||||||
|
|
||||||
|
elapsed = self._get_current_elapsed()
|
||||||
|
current_total_position = self.seek_offset + elapsed
|
||||||
|
|
||||||
|
return self._restart_at_position(current_total_position, new_speed, f"Speed: {new_speed:.2f}x")
|
||||||
|
|
||||||
|
def increase_speed(self) -> bool:
|
||||||
|
"""Increase playback speed. Returns True if action was taken."""
|
||||||
|
return self._change_speed(SPEED_INCREMENT)
|
||||||
|
|
||||||
|
def decrease_speed(self) -> bool:
|
||||||
|
"""Decrease playback speed. Returns True if action was taken."""
|
||||||
|
return self._change_speed(-SPEED_INCREMENT)
|
||||||
34
auditui/search_utils.py
Normal file
34
auditui/search_utils.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""Search helpers for filtering library items."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .library import LibraryClient
|
||||||
|
|
||||||
|
|
||||||
|
def build_search_text(item: dict, library_client: LibraryClient | None) -> str:
|
||||||
|
"""Build a lowercase search string for an item."""
|
||||||
|
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[dict],
|
||||||
|
filter_text: str,
|
||||||
|
get_search_text: Callable[[dict], str],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Filter items by a search string."""
|
||||||
|
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)]
|
||||||
87
auditui/table_utils.py
Normal file
87
auditui/table_utils.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Utils for table operations."""
|
||||||
|
|
||||||
|
import unicodedata
|
||||||
|
from typing import TYPE_CHECKING, Callable
|
||||||
|
|
||||||
|
from .constants import (
|
||||||
|
AUTHOR_NAME_DISPLAY_LENGTH,
|
||||||
|
AUTHOR_NAME_MAX_LENGTH,
|
||||||
|
PROGRESS_COLUMN_INDEX,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .downloads import DownloadManager
|
||||||
|
|
||||||
|
|
||||||
|
def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]:
|
||||||
|
"""Create a sort key function for sorting by title."""
|
||||||
|
def title_key(row_values):
|
||||||
|
title_cell = row_values[0]
|
||||||
|
if isinstance(title_cell, str):
|
||||||
|
normalized = unicodedata.normalize('NFD', title_cell)
|
||||||
|
return normalized.encode('ascii', 'ignore').decode('ascii').lower()
|
||||||
|
return str(title_cell).lower()
|
||||||
|
|
||||||
|
return title_key, reverse
|
||||||
|
|
||||||
|
|
||||||
|
def create_progress_sort_key(progress_column_index: int = PROGRESS_COLUMN_INDEX, reverse: bool = False) -> tuple[Callable, bool]:
|
||||||
|
"""Create a sort key function for sorting by progress percentage."""
|
||||||
|
def progress_key(row_values):
|
||||||
|
progress_cell = row_values[progress_column_index]
|
||||||
|
if isinstance(progress_cell, str):
|
||||||
|
try:
|
||||||
|
return float(progress_cell.rstrip("%"))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
return 0.0
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
return progress_key, reverse
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_author_name(author_names: str) -> str:
|
||||||
|
"""Truncate author name if it exceeds maximum length."""
|
||||||
|
if author_names and len(author_names) > AUTHOR_NAME_MAX_LENGTH:
|
||||||
|
return f"{author_names[:AUTHOR_NAME_DISPLAY_LENGTH]}..."
|
||||||
|
return author_names
|
||||||
|
|
||||||
|
|
||||||
|
def format_item_as_row(item: dict, library_client, download_manager: "DownloadManager | None" = None) -> tuple[str, str, str, str, str]:
|
||||||
|
"""Format a library item into table row data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (title, author, runtime, progress, downloaded) strings
|
||||||
|
"""
|
||||||
|
title = library_client.extract_title(item)
|
||||||
|
|
||||||
|
author_names = library_client.extract_authors(item)
|
||||||
|
author_names = truncate_author_name(author_names)
|
||||||
|
author_display = author_names or "Unknown"
|
||||||
|
|
||||||
|
minutes = library_client.extract_runtime_minutes(item)
|
||||||
|
runtime_str = library_client.format_duration(
|
||||||
|
minutes, unit="minutes", default_none="Unknown length"
|
||||||
|
) or "Unknown"
|
||||||
|
|
||||||
|
percent_complete = library_client.extract_progress_info(item)
|
||||||
|
progress_str = (
|
||||||
|
f"{percent_complete:.1f}%"
|
||||||
|
if percent_complete and percent_complete > 0
|
||||||
|
else "0%"
|
||||||
|
)
|
||||||
|
|
||||||
|
downloaded_str = ""
|
||||||
|
if download_manager:
|
||||||
|
asin = library_client.extract_asin(item)
|
||||||
|
if asin and download_manager.is_cached(asin):
|
||||||
|
downloaded_str = "✓"
|
||||||
|
|
||||||
|
return (title, author_display, runtime_str, progress_str, downloaded_str)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_unfinished_items(items: list[dict], library_client) -> list[dict]:
|
||||||
|
"""Filter out finished items from the list."""
|
||||||
|
return [
|
||||||
|
item for item in items
|
||||||
|
if not library_client.is_finished(item)
|
||||||
|
]
|
||||||
567
auditui/ui.py
Normal file
567
auditui/ui.py
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
"""UI components for the Auditui application."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Any, Callable, Protocol, TYPE_CHECKING, cast
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Container, Vertical
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.timer import Timer
|
||||||
|
from textual.widgets import Input, Label, ListItem, ListView, Static
|
||||||
|
|
||||||
|
from .constants import AUTH_PATH, CONFIG_PATH
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from textual.binding import Binding
|
||||||
|
|
||||||
|
|
||||||
|
class _AppContext(Protocol):
|
||||||
|
BINDINGS: list[tuple[str, str, str]]
|
||||||
|
client: Any
|
||||||
|
auth: Any
|
||||||
|
library_client: Any
|
||||||
|
all_items: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
KEY_DISPLAY_MAP = {
|
||||||
|
"ctrl+": "^",
|
||||||
|
"left": "←",
|
||||||
|
"right": "→",
|
||||||
|
"up": "↑",
|
||||||
|
"down": "↓",
|
||||||
|
"space": "Space",
|
||||||
|
"enter": "Enter",
|
||||||
|
}
|
||||||
|
|
||||||
|
KEY_COLOR = "#f9e2af"
|
||||||
|
DESC_COLOR = "#cdd6f4"
|
||||||
|
|
||||||
|
|
||||||
|
class AppContextMixin(ModalScreen):
|
||||||
|
"""Mixin to provide a typed app accessor."""
|
||||||
|
|
||||||
|
def _app(self) -> _AppContext:
|
||||||
|
return cast(_AppContext, self.app)
|
||||||
|
|
||||||
|
|
||||||
|
class HelpScreen(AppContextMixin, ModalScreen):
|
||||||
|
"""Help screen displaying all available keybindings."""
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "dismiss", "Close"), ("?", "dismiss", "Close")]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_key_display(key: str) -> str:
|
||||||
|
"""Format a key string for display with symbols."""
|
||||||
|
result = key
|
||||||
|
for old, new in KEY_DISPLAY_MAP.items():
|
||||||
|
result = result.replace(old, new)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_binding(binding: "Binding | tuple[str, str, str]") -> tuple[str, str]:
|
||||||
|
"""Extract key and description from a binding."""
|
||||||
|
if isinstance(binding, tuple):
|
||||||
|
return binding[0], binding[2]
|
||||||
|
return binding.key, binding.description
|
||||||
|
|
||||||
|
def _make_item(self, binding: "Binding | tuple[str, str, str]") -> ListItem:
|
||||||
|
"""Create a ListItem for a single binding."""
|
||||||
|
key, description = self._parse_binding(binding)
|
||||||
|
key_display = self._format_key_display(key)
|
||||||
|
text = f"[bold {KEY_COLOR}]{key_display:>16}[/] [{DESC_COLOR}]{description:<25}[/]"
|
||||||
|
return ListItem(Label(text))
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
app = self._app()
|
||||||
|
bindings = list(app.BINDINGS)
|
||||||
|
|
||||||
|
with Container(id="help_container"):
|
||||||
|
yield Static("Keybindings", id="help_title")
|
||||||
|
with Vertical(id="help_content"):
|
||||||
|
yield ListView(
|
||||||
|
*[self._make_item(b) for b in bindings],
|
||||||
|
classes="help_list",
|
||||||
|
)
|
||||||
|
yield Static(
|
||||||
|
f"Press [bold {KEY_COLOR}]?[/] or [bold {KEY_COLOR}]Escape[/] to close",
|
||||||
|
id="help_footer",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def action_dismiss(self, result: Any | None = None) -> None:
|
||||||
|
await self.dismiss(result)
|
||||||
|
|
||||||
|
|
||||||
|
class StatsScreen(AppContextMixin, ModalScreen):
|
||||||
|
"""Stats screen displaying listening statistics."""
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "dismiss", "Close"), ("s", "dismiss", "Close")]
|
||||||
|
|
||||||
|
def _format_time(self, milliseconds: int) -> str:
|
||||||
|
"""Format milliseconds as hours and minutes."""
|
||||||
|
total_seconds = int(milliseconds) // 1000
|
||||||
|
hours, remainder = divmod(total_seconds, 3600)
|
||||||
|
minutes, _ = divmod(remainder, 60)
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}h{minutes:02d}"
|
||||||
|
return f"{minutes}m"
|
||||||
|
|
||||||
|
def _format_date(self, date_str: str | None) -> str:
|
||||||
|
"""Format ISO date string for display."""
|
||||||
|
if not date_str:
|
||||||
|
return "Unknown"
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||||
|
return dt.strftime("%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
return date_str
|
||||||
|
|
||||||
|
def _get_signup_year(self) -> int:
|
||||||
|
"""Get signup year using binary search on listening activity."""
|
||||||
|
app = self._app()
|
||||||
|
if not app.client:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
current_year = date.today().year
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = app.client.get(
|
||||||
|
"1.0/stats/aggregates",
|
||||||
|
monthly_listening_interval_duration="12",
|
||||||
|
monthly_listening_interval_start_date=f"{current_year}-01",
|
||||||
|
store="Audible",
|
||||||
|
)
|
||||||
|
if not self._has_activity(stats):
|
||||||
|
return 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
left, right = 1995, current_year
|
||||||
|
earliest_year = current_year
|
||||||
|
|
||||||
|
while left <= right:
|
||||||
|
middle = (left + right) // 2
|
||||||
|
try:
|
||||||
|
stats = app.client.get(
|
||||||
|
"1.0/stats/aggregates",
|
||||||
|
monthly_listening_interval_duration="12",
|
||||||
|
monthly_listening_interval_start_date=f"{middle}-01",
|
||||||
|
store="Audible",
|
||||||
|
)
|
||||||
|
has_activity = self._has_activity(stats)
|
||||||
|
except Exception:
|
||||||
|
has_activity = False
|
||||||
|
|
||||||
|
if has_activity:
|
||||||
|
earliest_year = middle
|
||||||
|
right = middle - 1
|
||||||
|
else:
|
||||||
|
left = middle + 1
|
||||||
|
|
||||||
|
return earliest_year
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_activity(stats: dict) -> bool:
|
||||||
|
"""Check if stats contain any listening activity."""
|
||||||
|
monthly_stats = stats.get("aggregated_monthly_listening_stats", [])
|
||||||
|
return bool(
|
||||||
|
monthly_stats and any(s.get("aggregated_sum", 0)
|
||||||
|
> 0 for s in monthly_stats)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_listening_time(self, duration: int, start_date: str) -> int:
|
||||||
|
"""Get listening time in milliseconds for a given period."""
|
||||||
|
app = self._app()
|
||||||
|
if not app.client:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
stats = app.client.get(
|
||||||
|
"1.0/stats/aggregates",
|
||||||
|
monthly_listening_interval_duration=str(duration),
|
||||||
|
monthly_listening_interval_start_date=start_date,
|
||||||
|
store="Audible",
|
||||||
|
)
|
||||||
|
monthly_stats = stats.get("aggregated_monthly_listening_stats", [])
|
||||||
|
return sum(s.get("aggregated_sum", 0) for s in monthly_stats)
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _get_finished_books_count(self) -> int:
|
||||||
|
"""Get count of finished books from library."""
|
||||||
|
app = self._app()
|
||||||
|
if not app.library_client or not app.all_items:
|
||||||
|
return 0
|
||||||
|
return sum(
|
||||||
|
1 for item in app.all_items if app.library_client.is_finished(item)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _get_account_info(self) -> dict:
|
||||||
|
"""Get account information including subscription details."""
|
||||||
|
app = self._app()
|
||||||
|
if not app.client:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
account_info = {}
|
||||||
|
endpoints = [
|
||||||
|
(
|
||||||
|
"1.0/account/information",
|
||||||
|
"subscription_details,plan_summary,subscription_details_payment_instrument,delinquency_status,customer_benefits,customer_segments,directed_ids",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"1.0/customer/information",
|
||||||
|
"subscription_details_premium,subscription_details_rodizio,customer_segment,subscription_details_channels,migration_details",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"1.0/customer/status",
|
||||||
|
"benefits_status,member_giving_status,prime_benefits_status,prospect_benefits_status",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for endpoint, response_groups in endpoints:
|
||||||
|
try:
|
||||||
|
response = app.client.get(
|
||||||
|
endpoint, response_groups=response_groups)
|
||||||
|
account_info.update(response)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return account_info
|
||||||
|
|
||||||
|
def _get_email(self) -> str:
|
||||||
|
"""Get email from auth, config, or API."""
|
||||||
|
app = self._app()
|
||||||
|
for getter in (
|
||||||
|
self._get_email_from_auth,
|
||||||
|
self._get_email_from_config,
|
||||||
|
self._get_email_from_auth_file,
|
||||||
|
self._get_email_from_account_info,
|
||||||
|
):
|
||||||
|
email = getter(app)
|
||||||
|
if email:
|
||||||
|
return email
|
||||||
|
|
||||||
|
auth_data: dict[str, Any] | None = None
|
||||||
|
if app.auth:
|
||||||
|
try:
|
||||||
|
auth_data = getattr(app.auth, "data", None)
|
||||||
|
except Exception:
|
||||||
|
auth_data = None
|
||||||
|
|
||||||
|
account_info = self._get_account_info() if app.client else None
|
||||||
|
for candidate in (auth_data, account_info):
|
||||||
|
email = self._find_email_in_data(candidate)
|
||||||
|
if email:
|
||||||
|
return email
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def _get_email_from_auth(self, app: _AppContext) -> str | None:
|
||||||
|
"""Extract email from the authenticator if available."""
|
||||||
|
if not app.auth:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
email = self._first_email(
|
||||||
|
getattr(app.auth, "username", None),
|
||||||
|
getattr(app.auth, "login", None),
|
||||||
|
getattr(app.auth, "email", None),
|
||||||
|
)
|
||||||
|
if email:
|
||||||
|
return email
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
customer_info = getattr(app.auth, "customer_info", None)
|
||||||
|
if isinstance(customer_info, dict):
|
||||||
|
email = self._first_email(
|
||||||
|
customer_info.get("email"),
|
||||||
|
customer_info.get("email_address"),
|
||||||
|
customer_info.get("primary_email"),
|
||||||
|
)
|
||||||
|
if email:
|
||||||
|
return email
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = getattr(app.auth, "data", None)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
return self._first_email(
|
||||||
|
data.get("username"),
|
||||||
|
data.get("email"),
|
||||||
|
data.get("login"),
|
||||||
|
data.get("user_email"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_email_from_config(self, app: _AppContext) -> str | None:
|
||||||
|
"""Extract email from the config file."""
|
||||||
|
try:
|
||||||
|
if CONFIG_PATH.exists():
|
||||||
|
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
||||||
|
config = json.load(f)
|
||||||
|
return self._first_email(
|
||||||
|
config.get("email"),
|
||||||
|
config.get("username"),
|
||||||
|
config.get("login"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_email_from_auth_file(self, app: _AppContext) -> str | None:
|
||||||
|
"""Extract email from the auth file."""
|
||||||
|
try:
|
||||||
|
if AUTH_PATH.exists():
|
||||||
|
with open(AUTH_PATH, "r", encoding="utf-8") as f:
|
||||||
|
auth_file_data = json.load(f)
|
||||||
|
return self._first_email(
|
||||||
|
auth_file_data.get("username"),
|
||||||
|
auth_file_data.get("email"),
|
||||||
|
auth_file_data.get("login"),
|
||||||
|
auth_file_data.get("user_email"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_email_from_account_info(self, app: _AppContext) -> str | None:
|
||||||
|
"""Extract email from the account info API."""
|
||||||
|
if not app.client:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
account_info = self._get_account_info()
|
||||||
|
if account_info:
|
||||||
|
email = self._first_email(
|
||||||
|
account_info.get("email"),
|
||||||
|
account_info.get("customer_email"),
|
||||||
|
account_info.get("username"),
|
||||||
|
)
|
||||||
|
if email:
|
||||||
|
return email
|
||||||
|
customer_info = account_info.get("customer_info", {})
|
||||||
|
if isinstance(customer_info, dict):
|
||||||
|
return self._first_email(
|
||||||
|
customer_info.get("email"),
|
||||||
|
customer_info.get("email_address"),
|
||||||
|
customer_info.get("primary_email"),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _first_email(self, *values: str | None) -> str | None:
|
||||||
|
"""Return the first non-empty, non-Unknown email value."""
|
||||||
|
for value in values:
|
||||||
|
if value and value != "Unknown":
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _find_email_in_data(self, data: Any) -> str | None:
|
||||||
|
"""Search nested data for an email-like value."""
|
||||||
|
if data is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
stack: list[Any] = [data]
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
if isinstance(current, dict):
|
||||||
|
stack.extend(current.values())
|
||||||
|
elif isinstance(current, list):
|
||||||
|
stack.extend(current)
|
||||||
|
elif isinstance(current, str):
|
||||||
|
if "@" in current:
|
||||||
|
local, _, domain = current.partition("@")
|
||||||
|
if local and "." in domain:
|
||||||
|
return current
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _get_subscription_details(self, account_info: dict) -> dict:
|
||||||
|
"""Extract subscription details from nested API response."""
|
||||||
|
paths = [
|
||||||
|
["customer_details", "subscription", "subscription_details"],
|
||||||
|
["customer", "customer_details", "subscription", "subscription_details"],
|
||||||
|
["subscription_details"],
|
||||||
|
["subscription", "subscription_details"],
|
||||||
|
]
|
||||||
|
for path in paths:
|
||||||
|
data: Any = account_info
|
||||||
|
for key in path:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data = data.get(key)
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if isinstance(data, list) and data:
|
||||||
|
return data[0]
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _get_country(self) -> str:
|
||||||
|
"""Get country from authenticator locale."""
|
||||||
|
app = self._app()
|
||||||
|
if not app.auth:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
try:
|
||||||
|
locale_obj = getattr(app.auth, "locale", None)
|
||||||
|
if not locale_obj:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
if hasattr(locale_obj, "country_code"):
|
||||||
|
return locale_obj.country_code.upper()
|
||||||
|
if hasattr(locale_obj, "domain"):
|
||||||
|
return locale_obj.domain.upper()
|
||||||
|
if isinstance(locale_obj, str):
|
||||||
|
return locale_obj.split("_")[-1].upper() if "_" in locale_obj else locale_obj.upper()
|
||||||
|
return str(locale_obj)
|
||||||
|
except Exception:
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def _make_stat_item(self, label: str, value: str) -> ListItem:
|
||||||
|
"""Create a ListItem for a stat."""
|
||||||
|
text = f"[bold {KEY_COLOR}]{label:>16}[/] [{DESC_COLOR}]{value:<25}[/]"
|
||||||
|
return ListItem(Label(text))
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
app = self._app()
|
||||||
|
if not app.client:
|
||||||
|
with Container(id="help_container"):
|
||||||
|
yield Static("Statistics", id="help_title")
|
||||||
|
yield Static(
|
||||||
|
"Not authenticated. Please restart and authenticate.",
|
||||||
|
classes="help_row",
|
||||||
|
)
|
||||||
|
yield Static(
|
||||||
|
f"Press [bold {KEY_COLOR}]s[/] or [bold {KEY_COLOR}]Escape[/] to close",
|
||||||
|
id="help_footer",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
stats_items = self._build_stats_items(today)
|
||||||
|
|
||||||
|
with Container(id="help_container"):
|
||||||
|
yield Static("Statistics", id="help_title")
|
||||||
|
with Vertical(id="help_content"):
|
||||||
|
yield ListView(
|
||||||
|
*[self._make_stat_item(label, value)
|
||||||
|
for label, value in stats_items],
|
||||||
|
classes="help_list",
|
||||||
|
)
|
||||||
|
yield Static(
|
||||||
|
f"Press [bold {KEY_COLOR}]s[/] or [bold {KEY_COLOR}]Escape[/] to close",
|
||||||
|
id="help_footer",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_stats_items(self, today: date) -> list[tuple[str, str]]:
|
||||||
|
"""Build the list of stats items to display."""
|
||||||
|
signup_year = self._get_signup_year()
|
||||||
|
month_time = self._get_listening_time(1, today.strftime("%Y-%m"))
|
||||||
|
year_time = self._get_listening_time(12, today.strftime("%Y-01"))
|
||||||
|
finished_count = self._get_finished_books_count()
|
||||||
|
app = self._app()
|
||||||
|
total_books = len(app.all_items) if app.all_items else 0
|
||||||
|
|
||||||
|
email = self._get_email()
|
||||||
|
country = self._get_country()
|
||||||
|
|
||||||
|
subscription_name = "Unknown"
|
||||||
|
subscription_price = "Unknown"
|
||||||
|
next_bill_date = "Unknown"
|
||||||
|
|
||||||
|
account_info = self._get_account_info()
|
||||||
|
if account_info:
|
||||||
|
subscription_data = self._get_subscription_details(account_info)
|
||||||
|
if subscription_data:
|
||||||
|
if name := subscription_data.get("name"):
|
||||||
|
subscription_name = name
|
||||||
|
|
||||||
|
if bill_date := subscription_data.get("next_bill_date"):
|
||||||
|
next_bill_date = self._format_date(bill_date)
|
||||||
|
|
||||||
|
if bill_amount := subscription_data.get("next_bill_amount", {}):
|
||||||
|
amount = bill_amount.get("currency_value")
|
||||||
|
currency = bill_amount.get("currency_code", "EUR")
|
||||||
|
if amount is not None:
|
||||||
|
subscription_price = f"{amount} {currency}"
|
||||||
|
|
||||||
|
stats_items = []
|
||||||
|
if email != "Unknown":
|
||||||
|
stats_items.append(("Email", email))
|
||||||
|
stats_items.append(("Country Store", country))
|
||||||
|
stats_items.append(("Signup Year", str(signup_year)
|
||||||
|
if signup_year > 0 else "Unknown"))
|
||||||
|
if next_bill_date != "Unknown":
|
||||||
|
stats_items.append(("Next Credit", next_bill_date))
|
||||||
|
stats_items.append(("Next Bill", next_bill_date))
|
||||||
|
if subscription_name != "Unknown":
|
||||||
|
stats_items.append(("Subscription", subscription_name))
|
||||||
|
if subscription_price != "Unknown":
|
||||||
|
stats_items.append(("Price", subscription_price))
|
||||||
|
stats_items.append(("This Month", self._format_time(month_time)))
|
||||||
|
stats_items.append(("This Year", self._format_time(year_time)))
|
||||||
|
stats_items.append(
|
||||||
|
("Books Finished", f"{finished_count} / {total_books}"))
|
||||||
|
|
||||||
|
return stats_items
|
||||||
|
|
||||||
|
async def action_dismiss(self, result: Any | None = None) -> None:
|
||||||
|
await self.dismiss(result)
|
||||||
|
|
||||||
|
|
||||||
|
class FilterScreen(ModalScreen[str]):
|
||||||
|
"""Filter screen for searching the library."""
|
||||||
|
|
||||||
|
BINDINGS = [("escape", "cancel", "Cancel")]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
initial_filter: str = "",
|
||||||
|
on_change: Callable[[str], None] | None = None,
|
||||||
|
debounce_seconds: float = 0.2,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._initial_filter = initial_filter
|
||||||
|
self._on_change = on_change
|
||||||
|
self._debounce_seconds = debounce_seconds
|
||||||
|
self._debounce_timer: Timer | None = None
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Container(id="filter_container"):
|
||||||
|
yield Static("Filter Library", id="filter_title")
|
||||||
|
yield Input(
|
||||||
|
value=self._initial_filter,
|
||||||
|
placeholder="Type to filter by title or author...",
|
||||||
|
id="filter_input",
|
||||||
|
)
|
||||||
|
yield Static(
|
||||||
|
f"Press [bold {KEY_COLOR}]Enter[/] to apply, "
|
||||||
|
f"[bold {KEY_COLOR}]Escape[/] to clear",
|
||||||
|
id="filter_footer",
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.query_one("#filter_input", Input).focus()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
self.dismiss(event.value)
|
||||||
|
|
||||||
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
|
if not self._on_change:
|
||||||
|
return
|
||||||
|
if self._debounce_timer:
|
||||||
|
self._debounce_timer.stop()
|
||||||
|
value = event.value
|
||||||
|
self._debounce_timer = self.set_timer(
|
||||||
|
self._debounce_seconds,
|
||||||
|
lambda: self._on_change(value),
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_cancel(self) -> None:
|
||||||
|
self.dismiss("")
|
||||||
|
|
||||||
|
def on_unmount(self) -> None:
|
||||||
|
if self._debounce_timer:
|
||||||
|
self._debounce_timer.stop()
|
||||||
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.1.4"
|
||||||
|
description = "An Audible TUI client"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10,<3.13"
|
||||||
|
dependencies = ["audible>=0.10.0", "httpx>=0.28.1", "textual>=6.7.1"]
|
||||||
|
|
||||||
|
[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
|
|
||||||
35
tests/conftest.py
Normal file
35
tests/conftest.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
|
||||||
|
|
||||||
|
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 stub for type usage
|
||||||
|
pass
|
||||||
|
|
||||||
|
class Client: # minimal stub for type usage
|
||||||
|
pass
|
||||||
|
|
||||||
|
audible_stub.Authenticator = Authenticator
|
||||||
|
audible_stub.Client = Client
|
||||||
|
|
||||||
|
activation_bytes = ModuleType("audible.activation_bytes")
|
||||||
|
|
||||||
|
def get_activation_bytes(_auth: Authenticator | None = None) -> bytes:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
activation_bytes.get_activation_bytes = get_activation_bytes
|
||||||
|
|
||||||
|
sys.modules["audible"] = audible_stub
|
||||||
|
sys.modules["audible.activation_bytes"] = activation_bytes
|
||||||
50
tests/test_app_filter.py
Normal file
50
tests/test_app_filter.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from auditui.app import Auditui
|
||||||
|
from auditui.search_utils import build_search_text, filter_items
|
||||||
|
|
||||||
|
|
||||||
|
class StubLibrary:
|
||||||
|
def extract_title(self, item: dict) -> str:
|
||||||
|
return item.get("title", "")
|
||||||
|
|
||||||
|
def extract_authors(self, item: dict) -> str:
|
||||||
|
return item.get("authors", "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_search_text_is_cached() -> None:
|
||||||
|
class Dummy:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._search_text_cache: dict[int, str] = {}
|
||||||
|
self.library_client = StubLibrary()
|
||||||
|
|
||||||
|
item = {"title": "Title", "authors": "Author"}
|
||||||
|
dummy = Dummy()
|
||||||
|
first = Auditui._get_search_text(dummy, item)
|
||||||
|
second = Auditui._get_search_text(dummy, item)
|
||||||
|
assert first == "title author"
|
||||||
|
assert first == second
|
||||||
|
assert len(dummy._search_text_cache) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_items_uses_cache() -> None:
|
||||||
|
library = StubLibrary()
|
||||||
|
cache: dict[int, str] = {}
|
||||||
|
items = [
|
||||||
|
{"title": "Alpha", "authors": "Author One"},
|
||||||
|
{"title": "Beta", "authors": "Author Two"},
|
||||||
|
]
|
||||||
|
|
||||||
|
def cached(item: dict) -> str:
|
||||||
|
cache_key = id(item)
|
||||||
|
if cache_key not in cache:
|
||||||
|
cache[cache_key] = build_search_text(item, library)
|
||||||
|
return cache[cache_key]
|
||||||
|
|
||||||
|
result = filter_items(items, "beta", cached)
|
||||||
|
assert result == [items[1]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_search_text_without_library() -> None:
|
||||||
|
item = {"title": "Title", "authors": [{"name": "A"}, {"name": "B"}]}
|
||||||
|
assert build_search_text(item, None) == "title a, b"
|
||||||
48
tests/test_downloads.py
Normal file
48
tests/test_downloads.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from auditui import downloads
|
||||||
|
from auditui.constants import MIN_FILE_SIZE
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_filename() -> None:
|
||||||
|
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
|
||||||
|
assert dm._sanitize_filename('a<>:"/\\|?*b') == "a_________b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_download_url() -> None:
|
||||||
|
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
|
||||||
|
assert dm._validate_download_url("https://example.com/file") is True
|
||||||
|
assert dm._validate_download_url("http://example.com/file") is True
|
||||||
|
assert dm._validate_download_url("ftp://example.com/file") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cached_path_and_remove(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
|
||||||
|
dm.cache_dir = tmp_path
|
||||||
|
|
||||||
|
monkeypatch.setattr(dm, "_get_name_from_asin", lambda asin: "My Book")
|
||||||
|
safe_name = dm._sanitize_filename("My Book")
|
||||||
|
cached_path = tmp_path / f"{safe_name}.aax"
|
||||||
|
cached_path.write_bytes(b"0" * MIN_FILE_SIZE)
|
||||||
|
|
||||||
|
assert dm.get_cached_path("ASIN123") == cached_path
|
||||||
|
assert dm.is_cached("ASIN123") is True
|
||||||
|
|
||||||
|
messages: list[str] = []
|
||||||
|
assert dm.remove_cached("ASIN123", notify=messages.append) is True
|
||||||
|
assert not cached_path.exists()
|
||||||
|
assert messages and "Removed from cache" in messages[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cached_path_ignores_small_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
dm = downloads.DownloadManager.__new__(downloads.DownloadManager)
|
||||||
|
dm.cache_dir = tmp_path
|
||||||
|
|
||||||
|
monkeypatch.setattr(dm, "_get_name_from_asin", lambda asin: "My Book")
|
||||||
|
safe_name = dm._sanitize_filename("My Book")
|
||||||
|
cached_path = tmp_path / f"{safe_name}.aax"
|
||||||
|
cached_path.write_bytes(b"0" * (MIN_FILE_SIZE - 1))
|
||||||
|
|
||||||
|
assert dm.get_cached_path("ASIN123") is None
|
||||||
129
tests/test_library.py
Normal file
129
tests/test_library.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
from auditui.library import LibraryClient
|
||||||
|
|
||||||
|
|
||||||
|
class MockClient:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.put_calls: list[tuple[str, dict]] = []
|
||||||
|
self.post_calls: list[tuple[str, dict]] = []
|
||||||
|
self._post_response: dict = {}
|
||||||
|
self.raise_on_put = False
|
||||||
|
|
||||||
|
def put(self, path: str, body: dict) -> dict:
|
||||||
|
if self.raise_on_put:
|
||||||
|
raise RuntimeError("put failed")
|
||||||
|
self.put_calls.append((path, body))
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def post(self, path: str, body: dict) -> dict:
|
||||||
|
self.post_calls.append((path, body))
|
||||||
|
return self._post_response
|
||||||
|
|
||||||
|
def get(self, path: str, **kwargs: dict) -> dict:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_title_prefers_product() -> None:
|
||||||
|
client = MockClient()
|
||||||
|
library = LibraryClient(client) # type: ignore[arg-type]
|
||||||
|
item = build_item(title="Outer", product_title="Inner")
|
||||||
|
assert library.extract_title(item) == "Inner"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_authors_joins_names() -> None:
|
||||||
|
client = MockClient()
|
||||||
|
library = LibraryClient(client) # type: ignore[arg-type]
|
||||||
|
item = build_item(authors=[{"name": "A"}, {"name": "B"}])
|
||||||
|
assert library.extract_authors(item) == "A, B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_runtime_minutes_from_dict() -> None:
|
||||||
|
client = MockClient()
|
||||||
|
library = LibraryClient(client) # type: ignore[arg-type]
|
||||||
|
item = build_item(runtime_min=12)
|
||||||
|
assert library.extract_runtime_minutes(item) == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_progress_info_from_listening_status() -> None:
|
||||||
|
client = MockClient()
|
||||||
|
library = LibraryClient(client) # type: ignore[arg-type]
|
||||||
|
item = build_item(listening_status={"percent_complete": 25.0})
|
||||||
|
assert library.extract_progress_info(item) == 25.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_finished_with_percent_complete() -> None:
|
||||||
|
client = MockClient()
|
||||||
|
library = LibraryClient(client) # type: ignore[arg-type]
|
||||||
|
item = build_item(percent_complete=100)
|
||||||
|
assert library.is_finished(item)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_duration_and_time() -> None:
|
||||||
|
client = MockClient()
|
||||||
|
library = LibraryClient(client) # type: ignore[arg-type]
|
||||||
|
assert library.format_duration(61) == "1h01"
|
||||||
|
assert library.format_time(3661) == "01:01:01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_as_finished_success_updates_item() -> None:
|
||||||
|
client = MockClient()
|
||||||
|
client._post_response = {"content_license": {"acr": "token"}}
|
||||||
|
library = LibraryClient(client) # type: ignore[arg-type]
|
||||||
|
item = build_item(runtime_min=1, listening_status={})
|
||||||
|
ok = library.mark_as_finished("ASIN", item)
|
||||||
|
assert ok
|
||||||
|
assert client.put_calls
|
||||||
|
path, body = client.put_calls[0]
|
||||||
|
assert path == "1.0/lastpositions/ASIN"
|
||||||
|
assert body["acr"] == "token"
|
||||||
|
assert body["position_ms"] == 60_000
|
||||||
|
assert item["is_finished"] is True
|
||||||
|
assert item["listening_status"]["is_finished"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_as_finished_fails_without_acr() -> None:
|
||||||
|
client = MockClient()
|
||||||
|
client._post_response = {}
|
||||||
|
library = LibraryClient(client) # type: ignore[arg-type]
|
||||||
|
item = build_item(runtime_min=1)
|
||||||
|
ok = library.mark_as_finished("ASIN", item)
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_as_finished_handles_put_error() -> None:
|
||||||
|
client = MockClient()
|
||||||
|
client._post_response = {"content_license": {"acr": "token"}}
|
||||||
|
client.raise_on_put = True
|
||||||
|
library = LibraryClient(client) # type: ignore[arg-type]
|
||||||
|
item = build_item(runtime_min=1)
|
||||||
|
ok = library.mark_as_finished("ASIN", item)
|
||||||
|
assert ok is False
|
||||||
|
|
||||||
|
|
||||||
|
def build_item(
|
||||||
|
*,
|
||||||
|
title: str | None = None,
|
||||||
|
product_title: str | None = None,
|
||||||
|
authors: list[dict] | None = None,
|
||||||
|
runtime_min: int | None = None,
|
||||||
|
listening_status: dict | None = None,
|
||||||
|
percent_complete: int | float | None = None,
|
||||||
|
) -> dict:
|
||||||
|
item: dict = {}
|
||||||
|
if title is not None:
|
||||||
|
item["title"] = title
|
||||||
|
if percent_complete is not None:
|
||||||
|
item["percent_complete"] = percent_complete
|
||||||
|
if listening_status is not None:
|
||||||
|
item["listening_status"] = listening_status
|
||||||
|
product: dict = {}
|
||||||
|
if product_title is not None:
|
||||||
|
product["title"] = product_title
|
||||||
|
if runtime_min is not None:
|
||||||
|
product["runtime_length"] = {"min": runtime_min}
|
||||||
|
if authors is not None:
|
||||||
|
product["authors"] = authors
|
||||||
|
if product:
|
||||||
|
item["product"] = product
|
||||||
|
if runtime_min is not None and "runtime_length_min" not in item:
|
||||||
|
item["runtime_length_min"] = runtime_min
|
||||||
|
return item
|
||||||
80
tests/test_table_utils.py
Normal file
80
tests/test_table_utils.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from auditui import table_utils
|
||||||
|
|
||||||
|
|
||||||
|
class StubLibrary:
|
||||||
|
def extract_title(self, item: dict) -> str:
|
||||||
|
return item.get("title", "")
|
||||||
|
|
||||||
|
def extract_authors(self, item: dict) -> str:
|
||||||
|
return item.get("authors", "")
|
||||||
|
|
||||||
|
def extract_runtime_minutes(self, item: dict) -> int | None:
|
||||||
|
return item.get("minutes")
|
||||||
|
|
||||||
|
def format_duration(
|
||||||
|
self, value: int | None, unit: str = "minutes", default_none: str | None = None
|
||||||
|
) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return default_none
|
||||||
|
return f"{value}m"
|
||||||
|
|
||||||
|
def extract_progress_info(self, item: dict) -> float | None:
|
||||||
|
return item.get("percent")
|
||||||
|
|
||||||
|
def extract_asin(self, item: dict) -> str | None:
|
||||||
|
return item.get("asin")
|
||||||
|
|
||||||
|
|
||||||
|
class StubDownloads:
|
||||||
|
def __init__(self, cached: set[str]) -> None:
|
||||||
|
self._cached = cached
|
||||||
|
|
||||||
|
def is_cached(self, asin: str) -> bool:
|
||||||
|
return asin in self._cached
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_title_sort_key_normalizes_accents() -> None:
|
||||||
|
key_fn, _ = table_utils.create_title_sort_key()
|
||||||
|
assert key_fn(["École"]) == "ecole"
|
||||||
|
assert key_fn(["Zoo"]) == "zoo"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_progress_sort_key_parses_percent() -> None:
|
||||||
|
key_fn, _ = table_utils.create_progress_sort_key()
|
||||||
|
assert key_fn(["0", "0", "0", "42.5%"]) == 42.5
|
||||||
|
assert key_fn(["0", "0", "0", "bad"]) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_truncate_author_name() -> None:
|
||||||
|
long_name = "A" * (table_utils.AUTHOR_NAME_MAX_LENGTH + 5)
|
||||||
|
truncated = table_utils.truncate_author_name(long_name)
|
||||||
|
assert truncated.endswith("...")
|
||||||
|
assert len(truncated) <= table_utils.AUTHOR_NAME_MAX_LENGTH
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_item_as_row_with_downloaded() -> None:
|
||||||
|
library = StubLibrary()
|
||||||
|
downloads = StubDownloads({"ASIN123"})
|
||||||
|
item = {
|
||||||
|
"title": "Title",
|
||||||
|
"authors": "Author One",
|
||||||
|
"minutes": 90,
|
||||||
|
"percent": 12.34,
|
||||||
|
"asin": "ASIN123",
|
||||||
|
}
|
||||||
|
title, author, runtime, progress, downloaded = table_utils.format_item_as_row(
|
||||||
|
item, library, downloads
|
||||||
|
)
|
||||||
|
assert title == "Title"
|
||||||
|
assert author == "Author One"
|
||||||
|
assert runtime == "90m"
|
||||||
|
assert progress == "12.3%"
|
||||||
|
assert downloaded == "✓"
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_item_as_row_zero_progress() -> None:
|
||||||
|
library = StubLibrary()
|
||||||
|
item = {"title": "Title", "authors": "Author",
|
||||||
|
"minutes": 30, "percent": 0.0}
|
||||||
|
_, _, _, progress, _ = table_utils.format_item_as_row(item, library, None)
|
||||||
|
assert progress == "0%"
|
||||||
62
tests/test_ui_email.py
Normal file
62
tests/test_ui_email.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from auditui import ui
|
||||||
|
|
||||||
|
|
||||||
|
class DummyApp:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.client = None
|
||||||
|
self.auth = None
|
||||||
|
self.library_client = None
|
||||||
|
self.all_items = []
|
||||||
|
self.BINDINGS = []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dummy_app() -> DummyApp:
|
||||||
|
return DummyApp()
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_email_in_data() -> None:
|
||||||
|
screen = ui.StatsScreen()
|
||||||
|
data = {"a": {"b": ["nope", "user@example.com"]}}
|
||||||
|
assert screen._find_email_in_data(data) == "user@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_email_from_config(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dummy_app: DummyApp
|
||||||
|
) -> None:
|
||||||
|
screen = ui.StatsScreen()
|
||||||
|
config_path = tmp_path / "config.json"
|
||||||
|
config_path.write_text(json.dumps({"email": "config@example.com"}))
|
||||||
|
monkeypatch.setattr(ui, "CONFIG_PATH", config_path)
|
||||||
|
|
||||||
|
email = screen._get_email_from_config(dummy_app)
|
||||||
|
assert email == "config@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_email_from_auth_file(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, dummy_app: DummyApp
|
||||||
|
) -> None:
|
||||||
|
screen = ui.StatsScreen()
|
||||||
|
auth_path = tmp_path / "auth.json"
|
||||||
|
auth_path.write_text(json.dumps({"email": "auth@example.com"}))
|
||||||
|
monkeypatch.setattr(ui, "AUTH_PATH", auth_path)
|
||||||
|
|
||||||
|
email = screen._get_email_from_auth_file(dummy_app)
|
||||||
|
assert email == "auth@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_email_from_auth(dummy_app: DummyApp) -> None:
|
||||||
|
screen = ui.StatsScreen()
|
||||||
|
|
||||||
|
class Auth:
|
||||||
|
username = "user@example.com"
|
||||||
|
login = None
|
||||||
|
email = None
|
||||||
|
|
||||||
|
dummy_app.auth = Auth()
|
||||||
|
assert screen._get_email_from_auth(dummy_app) == "user@example.com"
|
||||||
44
tests/test_ui_filter.py
Normal file
44
tests/test_ui_filter.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from auditui.ui import FilterScreen
|
||||||
|
|
||||||
|
|
||||||
|
class DummyEvent:
|
||||||
|
def __init__(self, value: str) -> None:
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTimer:
|
||||||
|
def __init__(self, callback) -> None:
|
||||||
|
self.callback = callback
|
||||||
|
self.stopped = False
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.stopped = True
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_debounce_uses_latest_value(monkeypatch) -> None:
|
||||||
|
seen: list[str] = []
|
||||||
|
timers: list[FakeTimer] = []
|
||||||
|
|
||||||
|
def on_change(value: str) -> None:
|
||||||
|
seen.append(value)
|
||||||
|
|
||||||
|
screen = FilterScreen(on_change=on_change, debounce_seconds=0.2)
|
||||||
|
|
||||||
|
def fake_set_timer(_delay: float, callback):
|
||||||
|
timer = FakeTimer(callback)
|
||||||
|
timers.append(timer)
|
||||||
|
return timer
|
||||||
|
|
||||||
|
monkeypatch.setattr(screen, "set_timer", fake_set_timer)
|
||||||
|
|
||||||
|
screen.on_input_changed(DummyEvent("a"))
|
||||||
|
screen.on_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"]
|
||||||
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.1.4"
|
||||||
|
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 = ">=6.7.1" },
|
||||||
|
]
|
||||||
|
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 = "6.7.1"
|
||||||
|
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/ab/00/9520327698acb6d8ae120b311ef1901840d55a6c41580e377f36261daf7a/textual-6.7.1.tar.gz", hash = "sha256:2a5acb0ab316a7ba9e74b0a291fab8933d681d7cf6f4e1eeb45c39a731b094cf", size = 1580916, upload-time = "2025-12-01T20:57:25.578Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/7a/7f3ea5e6f26d546ee4bd107df8fc9eef9f149dab0f6f15e1fc9f9413231f/textual-6.7.1-py3-none-any.whl", hash = "sha256:b92977ac5941dd37b6b7dc0ac021850ce8d9bf2e123c5bab7ff2016f215272e0", size = 713993, upload-time = "2025-12-01T20:57:23.698Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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