Compare commits
198 Commits
727148b3ca
...
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 | |||
| b44ba70b6d | |||
| c3c3b083f9 | |||
| 1ead1d3e74 | |||
| 43e41c2f9a | |||
| 2482103162 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
venv
|
||||
.venv
|
||||
auditui.egg-info
|
||||
__pycache__
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12
|
||||
169
README.md
169
README.md
@@ -1,32 +1,171 @@
|
||||
# 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, search for new titles, add them to your wishlist, 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
|
||||
## Requirements
|
||||
|
||||
For now, it can:
|
||||
- [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.
|
||||
|
||||
- [x] list your entire library
|
||||
- [x] list your unfinished books with progress information
|
||||
- [ ] play a book (start when it was last paused, mark the position when it's paused)
|
||||
- [ ] open a pdf if one's attached to the book
|
||||
- [ ] mark a book as finished
|
||||
- [ ] mark a book as unfinished
|
||||
- [ ] search for new titles
|
||||
- [ ] add a book to your wishlist
|
||||
## Features
|
||||
|
||||
Once it'll do all of this (more or less), I'll think of a better code structure and the TUI interface.
|
||||
- **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
|
||||
|
||||
I'm still experimenting the `audible` library and its API.
|
||||
## Installation
|
||||
|
||||
## Credentials
|
||||
Use [`pipx`](https://pipx.pypa.io/latest/installation/) to install `auditui`:
|
||||
|
||||
```bash
|
||||
pipx install git+https://git.kharec.info/Kharec/auditui.git
|
||||
```
|
||||
|
||||
Check the version to ensure installation was successful:
|
||||
|
||||
```bash
|
||||
auditui --version
|
||||
```
|
||||
|
||||
All set, run `auditui configure` to set up authentication, and then `auditui` to start the TUI.
|
||||
|
||||
### Workaround for Python 3.13 linux distribution
|
||||
|
||||
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`.
|
||||
|
||||
When running `auditui configure`, you will be prompted for:
|
||||
|
||||
- **Email**: Your Audible account email address
|
||||
- **Password**: Your Audible account password (input is hidden)
|
||||
- **Marketplace locale**: The regional marketplace you want to connect to (defaults to `US` if left empty)
|
||||
|
||||
The marketplace locale determines which Audible region you access, affecting available audiobooks in your library. Common marketplace codes include:
|
||||
|
||||
- `US` - United States (default)
|
||||
- `UK` - United Kingdom
|
||||
- `DE` - Germany
|
||||
- `FR` - France
|
||||
- `CA` - Canada
|
||||
- `AU` - Australia
|
||||
- `IT` - Italy
|
||||
- `ES` - Spain
|
||||
- `JP` - Japan
|
||||
|
||||
To change your marketplace after initial configuration, simply run `auditui configure` again and select a different locale when prompted. But you should probably just stick with the marketplace you used when you first created your Audible account.
|
||||
|
||||
OTP is supported if you use a two-factor authentication device.
|
||||
|
||||
## Hacking
|
||||
|
||||
This project uses [uv](https://github.com/astral-sh/uv) for dependency management.
|
||||
|
||||
```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
|
||||
|
||||
This project is licensed under the GPLv3+ License. See the [LICENSE](LICENSE) file for details.
|
||||
|
||||
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()
|
||||
271
main.py
271
main.py
@@ -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()
|
||||
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,2 +0,0 @@
|
||||
audible==0.8.2
|
||||
textual==6.6.0
|
||||
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