Compare commits

...

254 Commits

Author SHA1 Message Date
26cba97cbd chore(changelog): document load-time tuning and duplicate row key fix 2026-02-18 04:28:35 +01:00
175bb7cbdc test(app): add coverage for unique table row key generation 2026-02-18 04:28:28 +01:00
bf0e70e9d9 fix(table): use unique row keys to avoid duplicate title crashes 2026-02-18 04:28:20 +01:00
cb4104e59a perf(app): remove eager search cache priming during library load 2026-02-18 04:28:13 +01:00
570639e988 perf(library): tune first-page probe order for faster medium-library loads 2026-02-18 04:28:06 +01:00
5ba0fafbc1 chore: update changelog 2026-02-18 04:20:56 +01:00
bed0ac4fea test(downloads): add regression coverage for too-small download retry behavior 2026-02-18 04:19:39 +01:00
0a909484e3 fix(downloads): retry undersized downloads and surface precise size failure messages 2026-02-18 04:19:33 +01:00
ecdd953ff4 refactor(downloads): split download streaming into focused helpers and reduce complexity 2026-02-18 04:12:54 +01:00
4ba2c43c93 clean: remove unused import 2026-02-18 04:09:04 +01:00
4b1924edd8 chore: update changelog 2026-02-18 04:08:41 +01:00
da20e84513 docs: update readme 2026-02-18 04:02:20 +01:00
dcb43f65dd chore: fix wording 2026-02-18 03:57:51 +01:00
beca8ee085 perf(library): optimize paginated fetch with bounded concurrent scheduling 2026-02-18 03:56:44 +01:00
e813267d5e refactor(library): decompose monolithic LibraryClient into fetch/extract/positions/finished/format mixins while preserving public behavior 2026-02-18 03:54:16 +01:00
eca58423dc refactor(constants): split constants into domain modules with compatibility exports 2026-02-18 03:44:26 +01:00
307368480a chore: update changelog 2026-02-18 03:39:53 +01:00
a8add30928 chore(changelog): document download filename metadata fallback fix 2026-02-18 03:39:18 +01:00
3e6e31c2db test(playback): verify prepare_and_start passes naming hints to downloads 2026-02-18 03:39:13 +01:00
6335f8bbac test(downloads): cover preferred naming hint propagation in get_or_download 2026-02-18 03:39:09 +01:00
0cf2644f55 test(downloads): validate author_title stem generation and cache fallbacks 2026-02-18 03:39:04 +01:00
597e82dc20 test(app): verify playback start receives selected item metadata 2026-02-18 03:38:58 +01:00
25d56cf407 test(app): cover selected item hint forwarding for downloads 2026-02-18 03:38:54 +01:00
76c991600c fix(playback): forward preferred title and author to download manager 2026-02-18 03:38:50 +01:00
95e641a527 fix(app): pass selected item title and author as download naming hints 2026-02-18 03:38:46 +01:00
8f8cdf7bfa fix(downloads): prefer library metadata for author_title filenames with fallback stems 2026-02-18 03:38:41 +01:00
9c19891443 chore: update changelog 2026-02-18 03:21:19 +01:00
01de75871a feat: versionning 2026-02-18 03:21:15 +01:00
e88dcee155 test: cover app and playback controller mixin behavior 2026-02-18 03:17:48 +01:00
4bc9b3fd3f test: add focused playback helper unit coverage 2026-02-18 03:17:42 +01:00
cd99960f2f test: reorganize core suite into explicit domain files 2026-02-18 03:17:33 +01:00
bd2bd43e7f test: add regression coverage for app key bindings and space priority 2026-02-18 03:01:51 +01:00
7f5e3266be fix: ensure Space always reaches toggle_playback action in TUI 2026-02-18 02:59:04 +01:00
184585bed0 fix(interface): app compose, table query, and Space pause 2026-02-18 02:51:35 +01:00
8e73e45e2d refactor: restructure into package layout and split large modules 2026-02-18 02:43:45 +01:00
bc24439da8 test: replace cached search dummy setup with dataclass fixture object 2026-02-17 14:26:02 +01:00
c9d6be6847 test: model StubDownloads state with a dataclass 2026-02-17 14:26:00 +01:00
f85a2d3cda test: convert DummyApp test context to typed dataclass 2026-02-17 14:25:58 +01:00
7e4a57d18e test: refactor MockClient state fields into a dataclass 2026-02-17 14:25:56 +01:00
4a7fa69c2e test: clean up tests/test_ui_filter.py using dataclass and slots 2026-02-17 12:43:06 +01:00
78f15b4622 chore: add a changelog 2026-02-16 21:01:16 +01:00
b63525060a docs: mention textual dependency in hacking section 2026-02-16 20:14:49 +01:00
79355f3bdf chore: bump __version__ to 0.1.6 2026-02-16 20:14:49 +01:00
7602638ffe chore: refresh lockfile for textual 8 2026-02-16 20:14:49 +01:00
dd8e513063 chore: bump package metadata for v0.1.6 2026-02-16 20:14:49 +01:00
bdccc3a2eb fix: update ui typing for textual 8 2026-02-16 20:14:49 +01:00
3ab73de2aa fix: align app with textual 8 api 2026-02-16 20:14:49 +01:00
f2c9d683b6 chore: update lock 2026-02-12 19:19:54 +01:00
b530238494 feat: now display download status in MB 2026-02-12 19:19:48 +01:00
89073aaf95 fix: progress bar centered 2026-02-12 19:19:36 +01:00
4c27d1864c feat: make the main view truely responsive 2026-02-12 19:03:47 +01:00
81814246d0 fix: redistribute proportions 2026-02-12 19:03:37 +01:00
b71e15d54c chore: update pyproject 2026-02-12 19:03:26 +01:00
181b8314e6 feat: update version 2026-02-12 19:03:21 +01:00
c6edfa5572 style: align help list rows 2026-01-19 15:25:13 +01:00
ac99643dbc style: densify help modal layout 2026-01-19 15:25:06 +01:00
889ac62a9a refactor: tidy modal styles 2026-01-19 15:15:26 +01:00
0bf6db7980 chore: bump version to 0.1.4 2026-01-19 14:41:23 +01:00
6aa4ebb33f chore: update lockfile version 2026-01-19 14:41:15 +01:00
ca43ea8858 chore: bump version to 0.1.4 2026-01-19 14:41:09 +01:00
733e35b0d2 fix: constrain progress bar width 2026-01-19 14:41:01 +01:00
f3573dfffc fix: center progress bar container 2026-01-19 14:40:50 +01:00
d17cb6f4d2 chore: bump version to 0.1.3 2026-01-09 19:01:29 +01:00
6e3eb87f76 chore: update pyproject 2026-01-09 19:01:20 +01:00
b5f82d6e33 feat: speed up library fetching with concurrent page requests 2026-01-09 19:01:14 +01:00
8bddca2f75 docs: testing is now implemented 2026-01-06 13:09:15 +01:00
bb8571df8a chore: bump version to 0.1.2 2026-01-06 12:58:50 +01:00
f528df49a9 update lockfile for dev extras 2026-01-06 12:58:31 +01:00
d40ad4534a chore: add dev deps and test config 2026-01-06 12:58:21 +01:00
c9a8764286 feat: add test config 2026-01-06 12:58:11 +01:00
1976b5d88c test: cover filter debounce 2026-01-06 12:58:00 +01:00
a8e3972f34 test: cover email extraction 2026-01-06 12:57:55 +01:00
eea6f26bcf test: cover table utils 2026-01-06 12:57:46 +01:00
ca70661bf6 test: cover library parsing and mark as finish 2026-01-06 12:57:36 +01:00
7930bf6941 test: cover cache and url helpers 2026-01-06 12:57:16 +01:00
6d3e818b01 test: cover filter/search helpers 2026-01-06 12:57:07 +01:00
02c6e4cb88 refactor: use shared search helpers 2026-01-06 12:56:35 +01:00
b63956c08f feat: add search helper module 2026-01-06 12:56:26 +01:00
f024128f85 docs: update readme 2026-01-06 10:30:29 +01:00
6d246944a3 ci: ignore package dir 2026-01-06 10:26:43 +01:00
e975654d87 build: package to true 2026-01-06 10:26:37 +01:00
fbad34cc24 chore: update uv.lock 2026-01-06 10:26:29 +01:00
c6a1374e21 docs: phrasing 2026-01-06 09:48:18 +01:00
db92450c7e docs: update readme 2026-01-06 09:46:59 +01:00
c0004c554f chore: update uv.lock 2026-01-06 09:37:57 +01:00
f565ee9dc9 chore: update pyproject.toml 2026-01-06 09:37:52 +01:00
67c44b2cb7 feat: update version 2026-01-06 09:37:08 +01:00
7128e3e7d4 docs: update readme 2026-01-06 09:34:29 +01:00
290e76d289 fix: include 3.12.x 2026-01-06 07:44:51 +01:00
678f3dac77 fix: pipx install needs the full url 2026-01-06 07:44:44 +01:00
24146c8db6 fix: python version 2026-01-06 07:43:03 +01:00
d996b1d523 docs: add a workaround for python 3.13 based linux 2026-01-06 07:40:26 +01:00
cf3dc315d7 fix: required python version for audible module 2026-01-06 07:40:06 +01:00
3806c35140 docs: production readme 2026-01-06 07:29:54 +01:00
974c671012 docs: add r toggle 2026-01-05 22:46:10 +01:00
0cf9884c6c feat: add a refresh toggle 2026-01-05 22:46:05 +01:00
124a962d72 feat: when a book is mark as finished, remove it from cache 2026-01-05 22:39:20 +01:00
bcad61d78a docs: update readme 2026-01-05 22:33:56 +01:00
f9c4771ee4 refactor: update finish logic to use runtime+acr 2026-01-05 22:33:52 +01:00
964b888e4c fix: finish-only mark action 2026-01-05 22:33:43 +01:00
e620ea8369 docs: add filter shortcut and update roadmap 2026-01-05 21:52:39 +01:00
c1dd38fbe6 feat: add debounced filter screen 2026-01-05 21:52:29 +01:00
fca7329ba1 feat: style filter modal 2026-01-05 21:52:24 +01:00
8fdd517933 feat: add filter view with cached search 2026-01-05 21:52:18 +01:00
bec7ba5ec0 docs: get rid of marketplace features 2026-01-05 17:29:30 +01:00
0505086e11 refactor: type-narrow app access and email lookup 2026-01-04 17:52:05 +01:00
b6c483623d docs: update readme 2026-01-04 16:39:03 +01:00
8ee3ccfc1c clean: remove stats playground 2026-01-04 16:38:59 +01:00
837bb12a89 docs: add stats keybinding and mark feature complete 2026-01-04 16:38:16 +01:00
009111e57d feat: add StatsScreen with listening and account statistics 2026-01-04 16:38:07 +01:00
b65047d9f7 feat: add CONFIG_PATH and StatsScreen CSS 2026-01-04 16:37:58 +01:00
b3ebd56151 feat: persist email to config file 2026-01-04 16:37:51 +01:00
2d765bbf04 feat: add stats screen keybinding 2026-01-04 16:37:44 +01:00
8e41d0b002 feat: redesign help as two-column cheat sheet 2026-01-04 11:21:52 +01:00
74691f3322 feat: compact duration formatting 2026-01-04 11:21:35 +01:00
ff1030f4bd feat: massive UI revamp 2026-01-04 11:21:12 +01:00
1bbd28888b feat: replace footer with custom top bar 2026-01-04 11:21:00 +01:00
20ef60b1e4 feat: polish responsive help screen CSS 2026-01-03 11:49:26 +01:00
d2cfebddf7 docs: update readme 2026-01-03 11:42:11 +01:00
43c0215a6f feat: define ratio-based table columns 2026-01-03 11:41:29 +01:00
7741c8adba feat: make ui columns responsive 2026-01-03 11:41:19 +01:00
eaa1628fcc refactor: extract key display mapping and helper methods 2026-01-03 11:14:43 +01:00
e663401151 docs: update readme 2026-01-02 18:58:07 +01:00
78dc8ed4a0 feat: add methods to mark books as finished/unfinished 2026-01-02 18:58:02 +01:00
2d31c8d7a2 feat: add 'f' key binding to toggle finished/unfinished status 2026-01-02 18:57:53 +01:00
b9f147c3b3 feat: add up/down arrow key bindings for playback speed control 2026-01-02 17:20:13 +01:00
459970ebd5 docs: add speed control 2026-01-02 17:20:05 +01:00
fa881a1ca8 feat: add arrow key symbols to help screen display 2026-01-02 17:19:53 +01:00
7518d16501 feat: add playback speed control with increase/decrease methods 2026-01-02 17:19:40 +01:00
620e1efa83 fix: replace typing List/Tuple with built-in generics 2025-12-23 05:17:24 +01:00
a635c964da fix: get version from package not toml 2025-12-21 10:57:40 +01:00
dfe671409f feat: add version 2025-12-21 10:57:32 +01:00
52c67e20a6 feat: add a -v|--version flag 2025-12-21 10:52:44 +01:00
553f5cb4f7 build: add script entrypoint 2025-12-20 22:53:00 +01:00
32b37a0834 docs: update readme 2025-12-20 22:52:30 +01:00
a2d2c7ce3a refactor: move main cli into package 2025-12-20 22:52:27 +01:00
4741080284 clean: shorter messages 2025-12-16 06:24:36 +01:00
737147b457 clean: remove unused import 2025-12-16 06:21:27 +01:00
123d35068f refactor: use ui.py and remove unused imports 2025-12-16 06:21:20 +01:00
258aabe10f refactor: future-proof ui components in ui.py 2025-12-16 06:21:09 +01:00
bc070c4162 feat: relooking of help screen 2025-12-16 06:02:33 +01:00
cbf6bff779 feat: help screen now is scrollable and looks better 2025-12-16 06:02:22 +01:00
080c731fd7 feat: add css for new help screen 2025-12-16 03:35:46 +01:00
1b6f1ff1f2 feat: add a help screen with all keybindings 2025-12-16 03:35:33 +01:00
aa5998c3e3 docs: update roadmap and main description 2025-12-16 03:35:16 +01:00
c65e949731 feat: improve margin 2025-12-16 03:25:12 +01:00
ab51e5506e feat: hide useless palette 2025-12-16 03:25:02 +01:00
3701b37f4c docs: update roadmap 2025-12-16 03:10:32 +01:00
1474302d7e feat: add downloaded status indicator to table rows 2025-12-16 03:10:13 +01:00
eeecaaf42e feat: add cache-related method to get, remove or check 2025-12-16 03:09:26 +01:00
f359dee194 feat: add a "downloaded" column in the UI 2025-12-16 03:09:06 +01:00
1e2655670d feat: add a toggle to download/remove a book from cache 2025-12-16 03:08:56 +01:00
cf6164c438 docs: update keybindings 2025-12-16 02:59:02 +01:00
46fa15fcfe clean: remove dark/light toggle 2025-12-16 02:58:57 +01:00
4b457452d4 refactor: move table-related utilities to table_utils.py 2025-12-16 02:55:15 +01:00
0de0286992 fix: docstring 2025-12-16 02:50:42 +01:00
391b0360bd docs: update definition of roadmap item 2025-12-16 02:26:31 +01:00
b0dc15a018 refactor: table_helpers to table_utils 2025-12-16 01:55:18 +01:00
a6d74265ed docs: update roadmap 2025-12-16 01:47:03 +01:00
4f49a081c9 clean: keep one-line docstring for consistency 2025-12-15 21:06:55 +01:00
3a19db2cf0 refactor: extract table sorting logic and media info loading to new modules 2025-12-15 21:03:52 +01:00
fcb1524806 feat: standardize error handling patterns 2025-12-15 21:01:09 +01:00
18ffae7ac8 refactor: use unified time formatting method 2025-12-15 20:59:36 +01:00
d71c751bbc clean: remove obsolete private method 2025-12-15 20:59:26 +01:00
234b65c9d8 refactor: consolidate time formatting 2025-12-15 20:59:15 +01:00
2d9970c198 refactor: move constants to constants.py 2025-12-15 20:57:30 +01:00
5e3b33570d feat: add last position retrieval and save functionality 2025-12-15 13:25:05 +01:00
2ced756cc0 docs: update roadmap 2025-12-15 13:24:12 +01:00
1c4017ae0c feat: resume from last position and auto-save playback position 2025-12-15 13:23:53 +01:00
251a7a26d5 format: pep8 2025-12-15 13:23:13 +01:00
6462c83a21 feat: save position 2025-12-15 13:22:43 +01:00
0c590cfa82 feat: get current month listening time using API 2025-12-15 12:25:21 +01:00
16395981dc fix: handle accented characters correctly in title sorting 2025-12-15 12:14:33 +01:00
30f0612bb5 docs: update readme with bindings 2025-12-15 07:52:41 +01:00
1aaff3b3b7 fix: correct binding according to name 2025-12-15 07:50:11 +01:00
986541f0d3 fix: binding description 2025-12-15 07:48:07 +01:00
151d565f36 feat: remove a redundant toggle, pressing p twice sort/reverse sort by progress 2025-12-15 07:46:54 +01:00
7e2b657cfc feat: remove a redundant toggle, pressing s twice sort/reverse sort 2025-12-15 07:46:03 +01:00
cef5e40347 fix: split clients, add surface error and follow redirects on downloads 2025-12-14 09:49:03 +01:00
839394343e feat: make chunk size configurable 2025-12-14 09:37:47 +01:00
84868c4afa feat: add default chunk size 2025-12-14 09:37:28 +01:00
03988f0988 fix:l close download_manager flux on app exit 2025-12-14 09:35:25 +01:00
9eba702a0a feat: reuse connections for better performance 2025-12-14 09:35:05 +01:00
f61f4ec55e build: update uv.lock 2025-12-14 09:34:53 +01:00
b45ff86061 build: add httpx dependency 2025-12-14 09:34:47 +01:00
6824d00088 feat: add url validation before trying to download 2025-12-14 09:32:29 +01:00
46c66e0d5c docs: update readme 2025-12-14 09:28:44 +01:00
d4e73e6a13 feat: implement goto previous/next chapter 2025-12-14 09:28:05 +01:00
b2dd430ac9 feat: methods to goto next/previous chapter 2025-12-14 09:27:56 +01:00
ce0d313187 clean: just handle auth as configure handles the rest of the flow 2025-12-14 09:11:55 +01:00
7fee7e56cf feat: use configuration flow if not existing/not correct 2025-12-14 09:11:12 +01:00
58661641d1 feat: add a configuration flow 2025-12-14 09:10:56 +01:00
95f30954b5 docs: update readme 2025-12-10 10:16:38 +01:00
d96a08935c docs: two more done ! \o/ 2025-12-09 19:51:31 +01:00
0ce45c26b7 feat: add the possibility to move forward/backward 30s with left/right 2025-12-09 19:50:43 +01:00
8b74c0f773 feat: progress bar + move (for|back)ward 30s 2025-12-09 19:50:24 +01:00
4a5e475f27 feat: add a progress bar 2025-12-09 19:50:08 +01:00
44d4f28ceb docs: one more done 2025-12-09 10:48:00 +01:00
1d6033f057 docs: update readme 2025-12-09 10:47:48 +01:00
5fe10a1636 feat: print chapter and progress in the footer of the app while a book is playing 2025-12-09 10:47:38 +01:00
1af3be37ce fix: unused import 2025-12-08 07:35:43 +01:00
c3dfa239fa fix: solve some mypy errors 2025-12-08 07:35:09 +01:00
42e6a1e029 fix: truncate author names if too long to not break UI 2025-12-07 21:37:43 +01:00
41f5183653 feat: optimize format_duration 2025-12-07 21:34:16 +01:00
1a1fee0984 refactor: extract playback orchestration and optimize code structure 2025-12-07 20:31:37 +01:00
ddb7cab39e refactor: delegate playback orchestration to PlaybackController 2025-12-07 20:30:59 +01:00
2d331288dd feat: make authentication less verbose 2025-12-07 14:01:37 +01:00
d1a6fda863 docs: update readme 2025-12-07 11:45:12 +01:00
2d10922a7c feat: create a catppuccin mocha theme 2025-12-07 11:45:03 +01:00
0ad4db95c5 docs: update readme after massive refactor 2025-12-07 00:12:17 +01:00
0d9d65088b feat: add __init__ 2025-12-07 00:09:16 +01:00
3b9d1ecf96 feat: add app submodule 2025-12-07 00:09:07 +01:00
27f9a5396e feat: add auth submodule 2025-12-07 00:08:52 +01:00
d3be27c70d feat: add constants 2025-12-07 00:08:46 +01:00
df2ae17721 feat: download module 2025-12-07 00:08:41 +01:00
a0edab8e32 feat: add library module 2025-12-07 00:08:38 +01:00
ddb1704cb0 feat: add playback module 2025-12-07 00:08:33 +01:00
53284d7c0a refactor: do a bit a code architecture 2025-12-07 00:08:28 +01:00
7951373033 fix: use constant now that authenticate is in Auditui class 2025-12-06 16:22:46 +01:00
cc3a1c6818 feat: move authentication inside Auditui class 2025-12-06 16:04:09 +01:00
1088517cd5 refactor: reduce code 2025-12-06 15:57:05 +01:00
a62c3e9bf4 clean: remove player.py, now useless 2025-12-06 15:48:21 +01:00
fc15096918 refactor: rename AudituiApp to Auditui to have the right name in TUI interface 2025-12-06 15:46:16 +01:00
37ac47698c docs: update readme with play/pause implementation 2025-12-06 15:44:57 +01:00
d6e2284db1 feat: play/pause implementation based on local download 2025-12-06 15:44:45 +01:00
1cac45e6cf feat: get signup year in the least shitty way possible 2025-12-06 15:11:08 +01:00
70e106208b refactor: get_stat() to be done yet 2025-12-05 17:14:21 +01:00
73dc453c18 feat: prepare code 2025-12-05 17:13:13 +01:00
2d038fc811 feat: add base class and authentication for now 2025-12-05 17:05:54 +01:00
fbd987d353 docs: add stats.py 2025-12-05 17:05:44 +01:00
df0f0612ab docs: update readme 2025-12-05 16:07:59 +01:00
8287b0ee16 docs: update roadmap 2025-12-05 16:06:49 +01:00
4cbb13e371 docs: rephrase roadmap 2025-12-05 15:57:46 +01:00
a45230c940 clean: duplicate code 2025-12-05 15:55:03 +01:00
ffaf998225 docs: update dependencies for ffmpeg 2025-12-05 15:36:43 +01:00
ad6060395b docs: update roadmap with new concerns 2025-12-05 15:34:07 +01:00
a7feeb9789 feat: output possible track in json and quit 2025-12-05 15:32:03 +01:00
c40444d587 feat: add a first try of playing a song 2025-12-05 15:29:41 +01:00
46deb2baac docs: update readme 2025-12-05 15:29:12 +01:00
79af9b2af6 docs: update readme 2025-12-04 16:14:55 +01:00
2b21484309 clean: remove old tries 2025-12-04 16:14:51 +01:00
c691c49530 git: include .venv to .gitignore 2025-12-04 09:41:17 +01:00
76a1c28510 docs: update readme 2025-12-04 09:41:04 +01:00
a67d0b4324 feat: migrate dependencies management to uv 2025-12-04 09:40:14 +01:00
2e4ae1c1cb clean: remove requirements.txt as we're migrating to uv 2025-12-04 09:39:55 +01:00
d5f6510553 refactor: different formatting 2025-12-02 10:48:28 +01:00
67fefeb679 typo: controle -> control 2025-11-26 11:01:52 +01:00
b44ba70b6d build: update requirements with pip freeze 2025-11-25 22:05:15 +01:00
c3c3b083f9 feat: add first successful and consistent attempt textualize+audible 2025-11-25 22:05:08 +01:00
1ead1d3e74 docs: update readme 2025-11-25 22:04:38 +01:00
43e41c2f9a oups: audible, not audit 2025-11-25 19:45:24 +01:00
2482103162 clean: rename my test of audible api 2025-11-25 19:44:38 +01:00
727148b3ca build: add versions in requirements 2025-11-25 12:28:17 +01:00
5c64ee3e37 fix: get the column key from the columns attribute 2025-11-25 12:24:25 +01:00
92 changed files with 6241 additions and 344 deletions

3
.gitignore vendored
View File

@@ -1,2 +1,3 @@
venv .venv
auditui.egg-info
__pycache__ __pycache__

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.12

94
CHANGELOG.md Normal file
View File

@@ -0,0 +1,94 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.2.0] - 2026-02-18
### Changed
- massive code refactoring
- complete test suite revamp
- updated download cache naming to use `Author_Title` format with normalized separators
- optimized library pagination fetch with bounded concurrent scheduling
- adjusted library first-page probe order to prefer larger page sizes for medium libraries
- removed eager search cache priming during library load to reduce startup work
### Fixed
- reused library metadata for download filename generation to avoid `Unknown-Author_Unknown-Title` when title/author are already known in the UI
- fixed Audible last-position request parameter handling after library client refactor
- added retry behavior and explicit size diagnostics when downloaded files are too small
- prevented table rendering crashes by generating unique row keys instead of using title-only keys
## [0.1.6] - 2026-02-16
### Changed
- Updated compatibility for Textual 8 APIs and typing.
## [0.1.5] - 2026-02-12
### Added
- Display download progress in megabytes in the main view.
### Fixed
- Re-centered the progress bar in the updated responsive layout.
### Changed
- Improved responsive behavior of the main layout and column proportions.
- Polished modal styling and help screen density/alignment for better readability.
## [0.1.4] - 2026-01-19
### Fixed
- Centered the progress bar container.
- Constrained progress bar width to prevent layout overflow.
## [0.1.3] - 2026-01-09
### Changed
- Improved library fetching performance by requesting pages concurrently.
## [0.1.2] - 2026-01-06
### Added
- Added a shared search helper module.
- Added test configuration and development dependencies.
- Added test coverage for filter/search helpers, cache and URL helpers, library parsing, table utilities, email extraction, and filter debounce behavior.
### Changed
- Refactored existing search/filter logic to use shared helpers.
- Improved packaging and CI setup for project distribution.
## [0.1.1] - 2026-01-06
### Added
- Added playback speed controls with up/down key bindings.
- Added an `f` key binding to toggle finished/unfinished status.
- Added responsive table columns and a redesigned top bar/help experience.
- Added a stats screen with listening/account statistics and persisted email config.
- Added a debounced filter view with cached search and refresh toggle.
### Changed
- Refined UI layout and styling with a broad responsive redesign.
- Updated behavior so finished books are removed from cache.
### Fixed
- Corrected Python version and installation compatibility details for Audible/pipx environments.
## [0.1.0] - 2025-12-25
FIRST VERSION

171
README.md
View File

@@ -1,32 +1,173 @@
# auditui # auditui
A terminal-based user interface (TUI) client for Audible, written in Python 3. A terminal-based user interface (TUI) client for [Audible](https://www.audible.fr/), written in Python 3.
Listen to your audiobooks or podcasts, browse your library, search for new titles, add them to your wishlist, and more. The interface currently ships with a single built-in theme.
## 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 ## Features
- [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
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 distributions
On some Linux distributions, Python 3.13 is already the default. In that case, install Python 3.12 manually before using `pipx`.
For Arch Linux:
```bash
yay -S python312
```
Once you have Python 3.12, run:
```bash
pipx install git+https://git.kharec.info/Kharec/auditui.git --python python3.12
```
This workaround is temporary and depends on upstream `audible` compatibility updates.
## Upgrade
Assuming it's already installed, use `pipx` to upgrade auditui:
```bash
pipx upgrade auditui
```
## Keybindings
| Key | Action |
| ------------ | -------------------------- |
| `/` | Filter library |
| `?` | Show help screen |
| `enter` | Play the selected book |
| `space` | Pause/resume the playback |
| `escape` | Clear filter |
| `ctrl+left` | Go to the previous chapter |
| `ctrl+right` | Go to the next chapter |
| `up` | Increase playback speed |
| `down` | Decrease playback speed |
| `left` | Seek backward 30 seconds |
| `right` | Seek forward 30 seconds |
| `a` | Show all/unfinished |
| `d` | Toggle download/delete |
| `f` | Mark as finished |
| `n` | Sort by name |
| `p` | Sort by progress |
| `q` | Quit the application |
| `r` | Refresh view |
| `s` | Show stats screen |
## Cache
Books are downloaded to `~/.cache/auditui/books`.
Downloaded files use a normalized `Author_Title.aax` naming format. For example, `Stephen King` and `11/22/63` become `Stephen-King_11-22-63.aax`.
The `d` key toggles the download state for the selected book: if the book is not cached, pressing `d` will download it; if it's already cached, pressing `d` will delete it from the cache.
To check the total size of your cache:
```bash
du -sh ~/.cache/auditui/books
```
Or the size of individual books:
```bash
du -h ~/.cache/auditui/books/*
```
Clean all the cache (if necessary) with:
```bash
rm -rf ~/.cache/auditui/books/*
```
## Authentication / credentials
Login is handled and credentials are stored in `~/.config/auditui/auth.json`. Login is handled and credentials are stored in `~/.config/auditui/auth.json`.
When running `auditui configure`, you will be prompted for:
- **Email**: Your Audible account email address
- **Password**: Your Audible account password (input is hidden)
- **Marketplace locale**: The regional marketplace you want to connect to (defaults to `US` if left empty)
The marketplace locale determines which Audible region you access, affecting available audiobooks in your library. Common marketplace codes include:
- `US` - United States (default)
- `UK` - United Kingdom
- `DE` - Germany
- `FR` - France
- `CA` - Canada
- `AU` - Australia
- `IT` - Italy
- `ES` - Spain
- `JP` - Japan
To change your marketplace after initial configuration, simply run `auditui configure` again and select a different locale when prompted. But you should probably just stick with the marketplace you used when you first created your Audible account.
OTP is supported if you use a two-factor authentication device. OTP is supported if you use a two-factor authentication device.
## Hacking
This project uses [uv](https://github.com/astral-sh/uv) for dependency management, the TUI is built with [Textual](https://textual.textualize.io/) (currently `textual>=8.0.0`).
```bash
# install dependencies (creates .venv)
$ uv sync
# modify the code...
# ...and run the TUI
$ uv run auditui
```
Don't forget to run the tests.
## Testing
As usual, tests are located in `tests` directory and use `pytest`.
Get the dev dependencies:
```bash
uv sync --extra dev
```
And run the tests:
```bash
uv run pytest
```
## License ## License
This project is licensed under the GPLv3+ License. See the [LICENSE](LICENSE) file for details. This project is licensed under the GPLv3+ License. See the [LICENSE](LICENSE) file for details.

3
auditui/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Auditui: Audible TUI client"""
__version__ = "0.2.0"

30
auditui/app/__init__.py Normal file
View File

@@ -0,0 +1,30 @@
"""Main Textual app: table, bindings, and orchestration of library, playback, and downloads."""
from __future__ import annotations
from textual.app import App, ComposeResult
from ..constants import TABLE_CSS
from .bindings import BINDINGS
from .state import init_auditui_state
from .layout import AppLayoutMixin
from .table import AppTableMixin
from .library import AppLibraryMixin
from .actions import AppActionsMixin
from .progress import AppProgressMixin
class Auditui(App, AppProgressMixin, AppActionsMixin, AppLibraryMixin, AppTableMixin, AppLayoutMixin):
"""Orchestrates the library table, playback, downloads, filter, and modal screens."""
SHOW_PALETTE = False
BINDINGS = BINDINGS
CSS = TABLE_CSS
def compose(self) -> ComposeResult:
yield from AppLayoutMixin.compose(self)
def __init__(self, auth=None, client=None) -> None:
super().__init__()
init_auditui_state(self, auth, client)

180
auditui/app/actions.py Normal file
View File

@@ -0,0 +1,180 @@
"""Selection, playback/download/finish actions, modals, and filter."""
from __future__ import annotations
from textual import work
from textual.widgets import DataTable
from ..constants import SEEK_SECONDS
from ..ui import FilterScreen, HelpScreen, StatsScreen
class AppActionsMixin:
def _get_selected_item(self) -> dict | None:
"""Return the currently selected library item from the table."""
table = self.query_one("#library_table", DataTable)
if table.row_count == 0:
self.update_status("No books available")
return None
cursor_row = table.cursor_row
if cursor_row >= len(self.current_items):
self.update_status("Invalid selection")
return None
return self.current_items[cursor_row]
def _get_naming_hints(self, item: dict | None) -> tuple[str | None, str | None]:
"""Return preferred title and author values used for download filenames."""
if not item or not self.library_client:
return (None, None)
return (
self.library_client.extract_title(item),
self.library_client.extract_authors(item),
)
def _get_selected_asin(self) -> str | None:
if not self.download_manager:
self.update_status("Not authenticated. Please restart and authenticate.")
return None
if not self.library_client:
self.update_status("Library client not available")
return None
selected_item = self._get_selected_item()
if not selected_item:
return None
asin = self.library_client.extract_asin(selected_item)
if not asin:
self.update_status("Could not get ASIN for selected book")
return None
return asin
def action_play_selected(self) -> None:
asin = self._get_selected_asin()
if asin:
self._start_playback_async(asin, self._get_selected_item())
def action_toggle_playback(self) -> None:
if not self.playback.toggle_playback():
self._no_playback_message()
def action_seek_forward(self) -> None:
if not self.playback.seek_forward(SEEK_SECONDS):
self._no_playback_message()
def action_seek_backward(self) -> None:
if not self.playback.seek_backward(SEEK_SECONDS):
self._no_playback_message()
def action_next_chapter(self) -> None:
if not self.playback.seek_to_next_chapter():
self._no_playback_message()
def action_previous_chapter(self) -> None:
if not self.playback.seek_to_previous_chapter():
self._no_playback_message()
def action_increase_speed(self) -> None:
if not self.playback.increase_speed():
self._no_playback_message()
def action_decrease_speed(self) -> None:
if not self.playback.decrease_speed():
self._no_playback_message()
def action_toggle_finished(self) -> None:
asin = self._get_selected_asin()
if asin:
self._toggle_finished_async(asin)
@work(exclusive=True, thread=True)
def _toggle_finished_async(self, asin: str) -> None:
if not self.library_client:
return
selected_item = None
for item in self.current_items:
if self.library_client.extract_asin(item) == asin:
selected_item = item
break
if not selected_item:
return
if self.library_client.is_finished(selected_item):
self.call_from_thread(self.update_status, "Already marked as finished")
return
success = self.library_client.mark_as_finished(asin, selected_item)
message = "Marked as finished" if success else "Failed to mark as finished"
self.call_from_thread(self.update_status, message)
if success:
if self.download_manager and self.download_manager.is_cached(asin):
self.download_manager.remove_cached(
asin, notify=self._thread_status_update
)
self.call_from_thread(self.fetch_library)
def _no_playback_message(self) -> None:
self.update_status("No playback active. Press Enter to play a book.")
def action_show_help(self) -> None:
self.push_screen(HelpScreen())
def action_show_stats(self) -> None:
self.push_screen(StatsScreen())
def action_filter(self) -> None:
self.push_screen(
FilterScreen(
self.filter_text,
on_change=self._apply_filter,
),
self._apply_filter,
)
def action_clear_filter(self) -> None:
if self.filter_text:
self.filter_text = ""
self._refresh_filtered_view()
self.update_status("Filter cleared")
def _apply_filter(self, filter_text: str | None) -> None:
self.filter_text = filter_text or ""
self._refresh_filtered_view()
def action_toggle_download(self) -> None:
asin = self._get_selected_asin()
if asin:
self._toggle_download_async(asin, self._get_selected_item())
@work(exclusive=True, thread=True)
def _toggle_download_async(self, asin: str, item: dict | None = None) -> None:
if not self.download_manager:
return
preferred_title, preferred_author = self._get_naming_hints(item)
if self.download_manager.is_cached(asin):
self.download_manager.remove_cached(asin, self._thread_status_update)
else:
self.download_manager.get_or_download(
asin,
self._thread_status_update,
preferred_title=preferred_title,
preferred_author=preferred_author,
)
self.call_from_thread(self._refresh_table)
@work(exclusive=True, thread=True)
def _start_playback_async(self, asin: str, item: dict | None = None) -> None:
if not self.download_manager:
return
preferred_title, preferred_author = self._get_naming_hints(item)
self.playback.prepare_and_start(
self.download_manager,
asin,
self._thread_status_update,
preferred_title,
preferred_author,
)

25
auditui/app/bindings.py Normal file
View File

@@ -0,0 +1,25 @@
"""Key bindings for the main app."""
from textual.binding import Binding
BINDINGS = [
("?", "show_help", "Help"),
("s", "show_stats", "Stats"),
("/", "filter", "Filter"),
("escape", "clear_filter", "Clear filter"),
("n", "sort", "Sort by name"),
("p", "sort_by_progress", "Sort by progress"),
("a", "show_all", "All/Unfinished"),
("r", "refresh", "Refresh"),
("enter", "play_selected", "Play"),
Binding("space", "toggle_playback", "Pause/Resume", priority=True),
("left", "seek_backward", "-30s"),
("right", "seek_forward", "+30s"),
("ctrl+left", "previous_chapter", "Previous chapter"),
("ctrl+right", "next_chapter", "Next chapter"),
("up", "increase_speed", "Increase speed"),
("down", "decrease_speed", "Decrease speed"),
("f", "toggle_finished", "Mark finished"),
("d", "toggle_download", "Download/Delete"),
("q", "quit", "Quit"),
]

111
auditui/app/layout.py Normal file
View File

@@ -0,0 +1,111 @@
"""Main layout: compose, mount, resize, status bar, table column widths."""
from __future__ import annotations
from textual.app import ComposeResult
from textual.containers import Horizontal
from textual.events import Resize
from textual.widgets import DataTable, ProgressBar, Static
from .. import __version__
from ..constants import TABLE_COLUMN_DEFS
class AppLayoutMixin:
def compose(self) -> ComposeResult:
yield Horizontal(
Static("? Help", id="top_left"),
Static(f"Auditui v{__version__}", id="top_center"),
Static("q Quit", id="top_right"),
id="top_bar",
)
yield Static("Loading...", id="status")
table = DataTable(id="library_table")
table.zebra_stripes = True
table.cursor_type = "row"
yield table
yield Static("", id="progress_info")
with Horizontal(id="progress_bar_container"):
yield ProgressBar(
id="progress_bar", show_eta=False, show_percentage=False, total=100
)
def on_mount(self) -> None:
self.theme = "textual-dark"
self.call_after_refresh(self._init_table_and_intervals)
def _init_table_and_intervals(self) -> None:
table = self.query_one("#library_table", DataTable)
for column_name, _ratio in TABLE_COLUMN_DEFS:
table.add_column(column_name)
self.call_after_refresh(lambda: self._apply_column_widths(table))
column_keys = list(table.columns.keys())
self.title_column_key = column_keys[0]
if self.client:
self.update_status("Fetching library...")
self.fetch_library()
else:
self.update_status(
"Not authenticated. Please restart and authenticate.")
self.set_interval(1.0, self._check_playback_status)
self.set_interval(0.5, self._update_progress)
self.set_interval(30.0, self._save_position_periodically)
def on_unmount(self) -> None:
self.playback.stop()
if self.download_manager:
self.download_manager.close()
def on_resize(self, event: Resize) -> None:
del event
try:
table = self.query_one("#library_table", DataTable)
except Exception:
return
self.call_after_refresh(lambda: self._apply_column_widths(table))
def update_status(self, message: str) -> None:
status = self.query_one("#status", Static)
status.display = True
status.update(message)
def _apply_column_widths(self, table: DataTable) -> None:
if not table.columns:
return
column_keys = list(table.columns.keys())
num_cols = len(column_keys)
ratios = [ratio for _, ratio in TABLE_COLUMN_DEFS]
total_ratio = sum(ratios) or num_cols
content_width = table.scrollable_content_region.width
if content_width <= 0:
content_width = table.size.width
if content_width <= 0:
return
padding_total = 2 * table.cell_padding * num_cols
distributable = max(num_cols, content_width - padding_total)
widths = []
for ratio in ratios:
w = max(1, (distributable * ratio) // total_ratio)
widths.append(w)
remainder = distributable - sum(widths)
if remainder > 0:
indices = sorted(
range(num_cols), key=lambda i: ratios[i], reverse=True)
for i in range(remainder):
widths[indices[i % num_cols]] += 1
for column_key, w in zip(column_keys, widths):
col = table.columns[column_key]
col.auto_width = False
col.width = w
table.refresh()
def _thread_status_update(self, message: str) -> None:
self.call_from_thread(self.update_status, message)

35
auditui/app/library.py Normal file
View File

@@ -0,0 +1,35 @@
"""Library fetch and load/error handlers."""
from __future__ import annotations
from textual import work
from textual.worker import get_current_worker
from ..types import LibraryItem
class AppLibraryMixin:
@work(exclusive=True, thread=True)
def fetch_library(self) -> None:
worker = get_current_worker()
if worker.is_cancelled or not self.library_client:
return
try:
all_items = self.library_client.fetch_all_items(self._thread_status_update)
self.call_from_thread(self.on_library_loaded, all_items)
except (OSError, ValueError, KeyError) as exc:
self.call_from_thread(self.on_library_error, str(exc))
def on_library_loaded(self, items: list[LibraryItem]) -> None:
"""Store fetched items and refresh the active library view."""
self.all_items = items
self._search_text_cache.clear()
self.update_status(f"Loaded {len(items)} books")
if self.show_all_mode:
self.show_all()
else:
self.show_unfinished()
def on_library_error(self, error: str) -> None:
self.update_status(f"Error fetching library: {error}")

94
auditui/app/progress.py Normal file
View File

@@ -0,0 +1,94 @@
"""Playback key handling, progress bar updates, and position save."""
from __future__ import annotations
from textual.containers import Horizontal
from textual.events import Key
from textual.widgets import DataTable, ProgressBar, Static
from ..library import LibraryClient
class AppProgressMixin:
def on_key(self, event: Key) -> None:
if self.playback.is_playing:
if event.key == "ctrl+left":
event.prevent_default()
self.action_previous_chapter()
return
elif event.key == "ctrl+right":
event.prevent_default()
self.action_next_chapter()
return
elif event.key == "left":
event.prevent_default()
self.action_seek_backward()
return
elif event.key == "right":
event.prevent_default()
self.action_seek_forward()
return
elif event.key == "up":
event.prevent_default()
self.action_increase_speed()
return
elif event.key == "down":
event.prevent_default()
self.action_decrease_speed()
return
if isinstance(self.focused, DataTable):
if event.key == "enter":
event.prevent_default()
self.action_play_selected()
elif event.key == "space":
event.prevent_default()
self.action_toggle_playback()
def _check_playback_status(self) -> None:
message = self.playback.check_status()
if message:
self.update_status(message)
self._hide_progress()
def _update_progress(self) -> None:
if not self.playback.is_playing:
self._hide_progress()
return
progress_data = self.playback.get_current_progress()
if not progress_data:
self._hide_progress()
return
chapter_name, chapter_elapsed, chapter_total = progress_data
if chapter_total <= 0:
self._hide_progress()
return
progress_info = self.query_one("#progress_info", Static)
progress_bar = self.query_one("#progress_bar", ProgressBar)
progress_bar_container = self.query_one(
"#progress_bar_container", Horizontal)
progress_percent = min(
100.0, max(0.0, (chapter_elapsed / chapter_total) * 100.0)
)
progress_bar.update(progress=progress_percent)
chapter_elapsed_str = LibraryClient.format_time(chapter_elapsed)
chapter_total_str = LibraryClient.format_time(chapter_total)
progress_info.update(
f"{chapter_name} | {chapter_elapsed_str} / {chapter_total_str}"
)
progress_info.display = True
progress_bar_container.display = True
def _hide_progress(self) -> None:
progress_info = self.query_one("#progress_info", Static)
progress_bar_container = self.query_one(
"#progress_bar_container", Horizontal)
progress_info.display = False
progress_bar_container.display = False
def _save_position_periodically(self) -> None:
self.playback.update_position_if_needed()

31
auditui/app/state.py Normal file
View File

@@ -0,0 +1,31 @@
"""App state initialization."""
from __future__ import annotations
from ..constants import PROGRESS_COLUMN_INDEX
from ..downloads import DownloadManager
from ..library import LibraryClient
from ..playback import PlaybackController
def init_auditui_state(self: object, auth=None, client=None) -> None:
setattr(self, "auth", auth)
setattr(self, "client", client)
setattr(self, "library_client", LibraryClient(client) if client else None)
setattr(
self,
"download_manager",
DownloadManager(auth, client) if auth and client else None,
)
notify = getattr(self, "update_status")
lib_client = LibraryClient(client) if client else None
setattr(self, "playback", PlaybackController(notify, lib_client))
setattr(self, "all_items", [])
setattr(self, "current_items", [])
setattr(self, "_search_text_cache", {})
setattr(self, "show_all_mode", False)
setattr(self, "filter_text", "")
setattr(self, "title_sort_reverse", False)
setattr(self, "progress_sort_reverse", False)
setattr(self, "title_column_key", None)
setattr(self, "progress_column_index", PROGRESS_COLUMN_INDEX)

129
auditui/app/table.py Normal file
View File

@@ -0,0 +1,129 @@
"""Table population, sorting, filter view, and search cache."""
from __future__ import annotations
from ..library import (
filter_items,
filter_unfinished_items,
format_item_as_row,
create_progress_sort_key,
create_title_sort_key,
)
from ..types import LibraryItem
from textual.widgets import DataTable, Static
class AppTableMixin:
def _populate_table(self, items: list[LibraryItem]) -> None:
"""Render library items into the table with stable unique row keys."""
table = self.query_one("#library_table", DataTable)
table.clear()
if not items or not self.library_client:
self.update_status("No books found.")
return
used_keys: set[str] = set()
for index, item in enumerate(items):
title, author, runtime, progress, downloaded = format_item_as_row(
item, self.library_client, self.download_manager
)
row_key = self._build_row_key(item, title, index, used_keys)
table.add_row(title, author, runtime, progress, downloaded, key=row_key)
self.current_items = items
status = self.query_one("#status", Static)
status.display = False
self._apply_column_widths(table)
def _build_row_key(
self,
item: LibraryItem,
title: str,
index: int,
used_keys: set[str],
) -> str:
"""Return a unique table row key derived from ASIN when available."""
asin = self.library_client.extract_asin(item) if self.library_client else None
base_key = asin or f"{title}#{index}"
if base_key not in used_keys:
used_keys.add(base_key)
return base_key
suffix = 2
candidate = f"{base_key}#{suffix}"
while candidate in used_keys:
suffix += 1
candidate = f"{base_key}#{suffix}"
used_keys.add(candidate)
return candidate
def _refresh_table(self) -> None:
if self.current_items:
self._populate_table(self.current_items)
def show_all(self) -> None:
if not self.all_items:
return
self.show_all_mode = True
self._refresh_filtered_view()
def show_unfinished(self) -> None:
if not self.all_items or not self.library_client:
return
self.show_all_mode = False
self._refresh_filtered_view()
def action_sort(self) -> None:
table = self.query_one("#library_table", DataTable)
if table.row_count > 0 and self.title_column_key:
title_key, reverse = create_title_sort_key(self.title_sort_reverse)
table.sort(key=title_key, reverse=reverse)
self.title_sort_reverse = not self.title_sort_reverse
def action_sort_by_progress(self) -> None:
table = self.query_one("#library_table", DataTable)
if table.row_count > 0:
self.progress_sort_reverse = not self.progress_sort_reverse
progress_key, reverse = create_progress_sort_key(
self.progress_column_index, self.progress_sort_reverse
)
table.sort(key=progress_key, reverse=reverse)
def action_show_all(self) -> None:
if self.show_all_mode:
self.show_unfinished()
else:
self.show_all()
def _refresh_filtered_view(self) -> None:
if not self.all_items:
return
items = self.all_items
if self.filter_text:
items = filter_items(items, self.filter_text, self._get_search_text)
self._populate_table(items)
self.update_status(f"Filter: '{self.filter_text}' ({len(items)} books)")
return
if not self.show_all_mode and self.library_client:
items = filter_unfinished_items(items, self.library_client)
self._populate_table(items)
def _get_search_text(self, item: LibraryItem) -> str:
cache_key = id(item)
cached = self._search_text_cache.get(cache_key)
if cached is not None:
return cached
from ..library import build_search_text
search_text = build_search_text(item, self.library_client)
self._search_text_cache[cache_key] = search_text
return search_text
def _prime_search_cache(self, items: list[LibraryItem]) -> None:
for item in items:
self._get_search_text(item)

26
auditui/auth/__init__.py Normal file
View File

@@ -0,0 +1,26 @@
"""Load saved Audible credentials and build authenticator and API client."""
from pathlib import Path
import audible
from ..constants import AUTH_PATH
def authenticate(
auth_path: Path = AUTH_PATH,
) -> tuple[audible.Authenticator, audible.Client]:
"""Load auth from file and return (Authenticator, Client). Raises if file missing or invalid."""
if not auth_path.exists():
raise FileNotFoundError(
"Authentication file not found. Please run 'auditui configure' to set up authentication."
)
try:
authenticator = audible.Authenticator.from_file(str(auth_path))
audible_client = audible.Client(auth=authenticator)
return authenticator, audible_client
except (OSError, ValueError, KeyError) as exc:
raise ValueError(
f"Failed to load existing authentication: {exc}"
) from exc

5
auditui/cli/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""CLI package; entry point is main() from .main."""
from .main import main
__all__ = ["main"]

52
auditui/cli/main.py Normal file
View File

@@ -0,0 +1,52 @@
"""CLI entrypoint: configure subcommand or authenticate and run the TUI."""
import argparse
import sys
from auditui import __version__
from auditui.app import Auditui
from auditui.auth import authenticate
from auditui.configure import configure
from auditui.constants import AUTH_PATH
def main() -> None:
parser = argparse.ArgumentParser(prog="auditui")
parser.add_argument(
"-v",
"--version",
action="version",
version=f"auditui {__version__}",
)
subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser("configure", help="Set up authentication")
args = parser.parse_args()
if args.command == "configure":
try:
configure()
print("Configuration completed successfully.")
except Exception as exc:
print(f"Configuration error: {exc}")
sys.exit(1)
return
config_dir = AUTH_PATH.parent
if not config_dir.exists():
print("No configuration yet, please run 'auditui configure'.")
sys.exit(1)
try:
auth, client = authenticate()
except Exception as exc:
print(f"Authentication error: {exc}")
if not AUTH_PATH.exists():
print("No configuration yet, please run 'auditui configure'.")
else:
print("Please re-authenticate by running 'auditui configure'.")
sys.exit(1)
app = Auditui(auth=auth, client=client)
app.run()

View File

@@ -0,0 +1,45 @@
"""Interactive setup of Audible credentials; writes auth and config files."""
import json
from getpass import getpass
from pathlib import Path
import audible
from ..constants import AUTH_PATH, CONFIG_PATH
def configure(
auth_path: Path = AUTH_PATH,
) -> tuple[audible.Authenticator, audible.Client]:
"""Prompt for email/password/locale, authenticate, and save auth.json and config.json."""
if auth_path.exists():
response = input(
"Configuration already exists. Are you sure you want to overwrite it? (y/N): "
).strip().lower()
if response not in ("yes", "y"):
print("Configuration cancelled.")
raise SystemExit(0)
print("Please authenticate with your Audible account.")
email = input("\nEmail: ")
password = getpass("Password: ")
marketplace = input(
"Marketplace locale (default: US): "
).strip().upper() or "US"
authenticator = audible.Authenticator.from_login(
username=email, password=password, locale=marketplace
)
auth_path.parent.mkdir(parents=True, exist_ok=True)
authenticator.to_file(str(auth_path))
config = {"email": email}
with open(CONFIG_PATH, "w", encoding="utf-8") as f:
json.dump(config, f)
print("Authentication successful!")
audible_client = audible.Client(auth=authenticator)
return authenticator, audible_client

View File

@@ -0,0 +1,29 @@
"""Compatibility exports for constants grouped by domain modules."""
from .downloads import DEFAULT_CHUNK_SIZE, DEFAULT_CODEC, DOWNLOAD_URL, MIN_FILE_SIZE
from .library import (
AUTHOR_NAME_DISPLAY_LENGTH,
AUTHOR_NAME_MAX_LENGTH,
PROGRESS_COLUMN_INDEX,
)
from .paths import AUTH_PATH, CACHE_DIR, CONFIG_PATH
from .playback import SEEK_SECONDS
from .table import TABLE_COLUMN_DEFS
from .ui import TABLE_CSS
__all__ = [
"AUTH_PATH",
"CONFIG_PATH",
"CACHE_DIR",
"DOWNLOAD_URL",
"DEFAULT_CODEC",
"MIN_FILE_SIZE",
"DEFAULT_CHUNK_SIZE",
"TABLE_COLUMN_DEFS",
"AUTHOR_NAME_MAX_LENGTH",
"AUTHOR_NAME_DISPLAY_LENGTH",
"PROGRESS_COLUMN_INDEX",
"SEEK_SECONDS",
"TABLE_CSS",
]

View File

@@ -0,0 +1,6 @@
"""Download-related constants for Audible file retrieval."""
DOWNLOAD_URL = "https://cde-ta-g7g.amazon.com/FionaCDEServiceEngine/FSDownloadContent"
DEFAULT_CODEC = "LC_128_44100_stereo"
MIN_FILE_SIZE = 1024 * 1024
DEFAULT_CHUNK_SIZE = 8192

View File

@@ -0,0 +1,5 @@
"""Library and table formatting constants."""
AUTHOR_NAME_MAX_LENGTH = 40
AUTHOR_NAME_DISPLAY_LENGTH = 37
PROGRESS_COLUMN_INDEX = 3

View File

@@ -0,0 +1,8 @@
"""Filesystem paths used by configuration and caching."""
from pathlib import Path
AUTH_PATH = Path.home() / ".config" / "auditui" / "auth.json"
CONFIG_PATH = Path.home() / ".config" / "auditui" / "config.json"
CACHE_DIR = Path.home() / ".cache" / "auditui" / "books"

View File

@@ -0,0 +1,3 @@
"""Playback behavior constants."""
SEEK_SECONDS = 30.0

View File

@@ -0,0 +1,9 @@
"""Main library table column definitions."""
TABLE_COLUMN_DEFS = (
("Title", 4),
("Author", 3),
("Length", 1),
("Progress", 1),
("Downloaded", 1),
)

255
auditui/constants/ui.py Normal file
View File

@@ -0,0 +1,255 @@
"""Textual CSS constants for the application UI."""
TABLE_CSS = """
Screen {
background: #141622;
}
#top_bar {
background: #10131f;
color: #d5d9f0;
text-style: bold;
height: 1;
margin: 0;
padding: 0;
}
#top_left,
#top_center,
#top_right {
width: 1fr;
padding: 0 1;
background: #10131f;
margin: 0;
}
#top_left {
text-align: left;
}
#top_center {
text-align: center;
}
#top_right {
text-align: right;
}
DataTable {
width: 100%;
height: 1fr;
background: #141622;
color: #c7cfe8;
border: solid #262a3f;
scrollbar-size-horizontal: 0;
}
DataTable:focus {
border: solid #7aa2f7;
}
DataTable > .datatable--header {
background: #1b2033;
color: #b9c3e3;
text-style: bold;
}
DataTable > .datatable--cursor {
background: #232842;
color: #e6ebff;
}
DataTable > .datatable--odd-row {
background: #121422;
}
DataTable > .datatable--even-row {
background: #15182a;
}
Static {
height: 1;
text-align: center;
background: #10131f;
color: #c7cfe8;
}
Static#status {
color: #b6bfdc;
}
Static#progress_info {
color: #7aa2f7;
text-style: bold;
margin: 0;
padding: 0;
text-align: center;
width: 100%;
}
#progress_bar_container {
align: center middle;
width: 100%;
height: 1;
}
ProgressBar#progress_bar {
height: 1;
background: #10131f;
border: none;
margin: 0;
padding: 0;
width: 50%;
}
ProgressBar#progress_bar Bar {
width: 100%;
}
ProgressBar#progress_bar > .progress-bar--track {
background: #262a3f;
}
ProgressBar#progress_bar > .progress-bar--bar {
background: #8bd5ca;
}
HelpScreen,
StatsScreen,
FilterScreen {
align: center middle;
background: rgba(0, 0, 0, 0.7);
}
HelpScreen Static,
StatsScreen Static,
FilterScreen Static {
background: transparent;
}
StatsScreen #help_container {
width: auto;
min-width: 55;
max-width: 70;
}
StatsScreen #help_content {
align: center middle;
width: 100%;
}
StatsScreen .help_list {
width: 100%;
}
StatsScreen .help_list > ListItem {
background: transparent;
height: 1;
}
StatsScreen .help_list > ListItem:hover {
background: #232842;
}
StatsScreen .help_list > ListItem > Label {
width: 100%;
text-align: left;
padding-left: 2;
}
#help_container {
width: 72%;
max-width: 90;
min-width: 44;
height: auto;
max-height: 80%;
min-height: 14;
background: #181a2a;
border: heavy #7aa2f7;
padding: 1 1;
}
#help_title {
width: 100%;
height: 2;
text-align: center;
text-style: bold;
color: #7aa2f7;
content-align: center middle;
margin-bottom: 0;
border-bottom: solid #4b5165;
}
#help_content {
width: 100%;
height: auto;
padding: 0;
margin: 0 0 1 0;
align: center middle;
}
.help_list {
width: 100%;
height: auto;
background: transparent;
padding: 0;
scrollbar-size: 0 0;
}
.help_list > ListItem {
background: #1b1f33;
padding: 0 1;
height: 1;
}
.help_list > ListItem:hover {
background: #2a2f45;
}
.help_list > ListItem > Label {
width: 100%;
padding: 0;
}
#help_footer {
width: 100%;
height: 2;
text-align: center;
content-align: center middle;
color: #b6bfdc;
margin-top: 0;
border-top: solid #4b5165;
}
#filter_container {
width: 60;
height: auto;
background: #181a2a;
border: heavy #7aa2f7;
padding: 1 2;
}
#filter_title {
width: 100%;
height: 2;
text-align: center;
text-style: bold;
color: #7aa2f7;
content-align: center middle;
margin-bottom: 1;
}
#filter_input {
width: 100%;
margin: 1 0;
}
#filter_footer {
width: 100%;
height: 2;
text-align: center;
content-align: center middle;
color: #b6bfdc;
margin-top: 1;
}
"""

View File

@@ -0,0 +1,5 @@
"""Download and cache of Audible AAX files."""
from .manager import DownloadManager
__all__ = ["DownloadManager"]

View File

@@ -0,0 +1,344 @@
"""Obtains AAX files from Audible (cache or download) and provides activation bytes."""
import re
import unicodedata
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
import audible
import httpx
from audible.activation_bytes import get_activation_bytes
from ..constants import (
CACHE_DIR,
DEFAULT_CHUNK_SIZE,
DEFAULT_CODEC,
DOWNLOAD_URL,
MIN_FILE_SIZE,
)
from ..types import StatusCallback
class DownloadManager:
"""Obtains AAX files from Audible (cache or download) and provides activation bytes."""
def __init__(
self,
auth: audible.Authenticator,
client: audible.Client,
cache_dir: Path = CACHE_DIR,
chunk_size: int = DEFAULT_CHUNK_SIZE,
) -> None:
self.auth = auth
self.client: Any = client
self.cache_dir = cache_dir
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.chunk_size = chunk_size
self._http_client = httpx.Client(auth=auth, timeout=30.0, follow_redirects=True)
self._download_client = httpx.Client(
timeout=httpx.Timeout(connect=30.0, read=None, write=30.0, pool=30.0),
follow_redirects=True,
)
def get_or_download(
self,
asin: str,
notify: StatusCallback | None = None,
preferred_title: str | None = None,
preferred_author: str | None = None,
) -> Path | None:
"""Return local path to AAX file; download and cache if not present."""
filename_stems = self._get_filename_stems_from_asin(
asin,
preferred_title=preferred_title,
preferred_author=preferred_author,
)
local_path = self.cache_dir / f"{filename_stems[0]}.aax"
cached_path = self._find_cached_path(filename_stems)
if cached_path:
if notify:
notify(f"Using cached file: {cached_path.name}")
return cached_path
if notify:
notify(f"Downloading to {local_path.name}...")
if not self._download_to_valid_file(asin, local_path, notify):
return None
return local_path
def _download_to_valid_file(
self,
asin: str,
local_path: Path,
notify: StatusCallback | None = None,
) -> bool:
"""Download with one retry and ensure resulting file has a valid size."""
for attempt in range(1, 3):
if not self._attempt_download(asin, local_path, notify):
return False
if local_path.exists() and local_path.stat().st_size >= MIN_FILE_SIZE:
return True
downloaded_size = local_path.stat().st_size if local_path.exists() else 0
if notify and attempt == 1:
notify(
f"Downloaded file too small ({downloaded_size} bytes), retrying..."
)
if notify and attempt == 2:
notify(
f"Download failed: file too small ({downloaded_size} bytes, expected >= {MIN_FILE_SIZE})"
)
self._cleanup_partial_file(local_path)
return False
def _attempt_download(
self,
asin: str,
local_path: Path,
notify: StatusCallback | None = None,
) -> bool:
"""Perform one download attempt including link lookup and URL validation."""
dl_link = self._get_download_link(asin, notify=notify)
if not dl_link:
if notify:
notify("Failed to get download link")
return False
if not self._validate_download_url(dl_link):
if notify:
notify("Invalid download URL")
return False
if not self._download_file(dl_link, local_path, notify):
if notify:
notify("Download failed")
return False
return True
def get_activation_bytes(self) -> str | None:
"""Return activation bytes as hex string for ffplay/ffmpeg."""
try:
activation_bytes = get_activation_bytes(self.auth)
if isinstance(activation_bytes, bytes):
return activation_bytes.hex()
return str(activation_bytes)
except (OSError, ValueError, KeyError, AttributeError):
return None
def get_cached_path(self, asin: str) -> Path | None:
"""Return path to cached AAX file if it exists and is valid size."""
return self._find_cached_path(self._get_filename_stems_from_asin(asin))
def is_cached(self, asin: str) -> bool:
"""Return True if the title is present in cache with valid size."""
return self.get_cached_path(asin) is not None
def remove_cached(self, asin: str, notify: StatusCallback | None = None) -> bool:
"""Delete the cached AAX file for the given ASIN. Returns True on success."""
cached_path = self.get_cached_path(asin)
if not cached_path:
if notify:
notify("Book is not cached")
return False
try:
cached_path.unlink()
if notify:
notify(f"Removed from cache: {cached_path.name}")
return True
except OSError as exc:
if notify:
notify(f"Failed to remove cache: {exc}")
return False
def _validate_download_url(self, url: str) -> bool:
"""Validate that the URL is a valid HTTP/HTTPS URL."""
try:
parsed = urlparse(url)
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
except (ValueError, AttributeError):
return False
def _sanitize_filename(self, filename: str) -> str:
"""Normalize a filename segment with ASCII letters, digits, and dashes."""
ascii_text = unicodedata.normalize("NFKD", filename)
ascii_text = ascii_text.encode("ascii", "ignore").decode("ascii")
ascii_text = re.sub(r"['`]+", "", ascii_text)
ascii_text = re.sub(r"[^A-Za-z0-9]+", "-", ascii_text)
ascii_text = re.sub(r"-+", "-", ascii_text)
ascii_text = ascii_text.strip("-._")
return ascii_text or "Unknown"
def _find_cached_path(self, filename_stems: list[str]) -> Path | None:
"""Return the first valid cached path matching any candidate filename stem."""
for filename_stem in filename_stems:
local_path = self.cache_dir / f"{filename_stem}.aax"
if local_path.exists() and local_path.stat().st_size >= MIN_FILE_SIZE:
return local_path
return None
def _get_filename_stems_from_asin(
self,
asin: str,
preferred_title: str | None = None,
preferred_author: str | None = None,
) -> list[str]:
"""Build preferred and fallback cache filename stems for an ASIN."""
if preferred_title:
preferred_combined = (
f"{self._sanitize_filename(preferred_author or 'Unknown Author')}_"
f"{self._sanitize_filename(preferred_title)}"
)
preferred_legacy = self._sanitize_filename(preferred_title)
fallback_asin = self._sanitize_filename(asin)
return list(
dict.fromkeys([preferred_combined, preferred_legacy, fallback_asin])
)
try:
product_info = self.client.get(
path=f"1.0/catalog/products/{asin}",
**{"response_groups": "contributors,product_desc,product_attrs"},
)
product = product_info.get("product", {})
title = product.get("title") or "Unknown Title"
author = self._get_primary_author(product)
combined = (
f"{self._sanitize_filename(author)}_{self._sanitize_filename(title)}"
)
legacy_title = self._sanitize_filename(title)
fallback_asin = self._sanitize_filename(asin)
return list(dict.fromkeys([combined, legacy_title, fallback_asin]))
except (OSError, ValueError, KeyError, AttributeError):
return [self._sanitize_filename(asin)]
def _get_primary_author(self, product: dict) -> str:
"""Extract a primary author name from product metadata."""
contributors = product.get("authors") or product.get("contributors") or []
for contributor in contributors:
if not isinstance(contributor, dict):
continue
name = contributor.get("name")
if isinstance(name, str) and name.strip():
return name
return "Unknown Author"
def _get_download_link(
self,
asin: str,
codec: str = DEFAULT_CODEC,
notify: StatusCallback | None = None,
) -> str | None:
"""Obtain CDN download URL for the given ASIN and codec."""
if self.auth.adp_token is None:
if notify:
notify("Missing ADP token (not authenticated?)")
return None
try:
params = {
"type": "AUDI",
"currentTransportMethod": "WIFI",
"key": asin,
"codec": codec,
}
response = self._http_client.get(
url=DOWNLOAD_URL,
params=params,
)
response.raise_for_status()
link = response.headers.get("Location")
if not link:
link = str(response.url)
locale = getattr(self.auth, "locale", None)
tld = getattr(locale, "domain", "com")
return link.replace("cds.audible.com", f"cds.audible.{tld}")
except httpx.HTTPError as exc:
if notify:
notify(f"Download-link request failed: {exc!s}")
return None
except (OSError, ValueError, KeyError, AttributeError) as exc:
if notify:
notify(f"Download-link error: {exc!s}")
return None
def _download_file(
self, url: str, dest_path: Path, notify: StatusCallback | None = None
) -> Path | None:
"""Stream download from URL to dest_path; reports progress via notify."""
try:
with self._download_client.stream("GET", url) as response:
response.raise_for_status()
total_size = int(response.headers.get("content-length", 0))
self._stream_to_file(response, dest_path, total_size, notify)
return dest_path
except httpx.HTTPStatusError as exc:
if notify:
notify(
f"Download HTTP error: {exc.response.status_code} {exc.response.reason_phrase}"
)
self._cleanup_partial_file(dest_path)
return None
except httpx.HTTPError as exc:
if notify:
notify(f"Download network error: {exc!s}")
self._cleanup_partial_file(dest_path)
return None
except (OSError, ValueError, KeyError) as exc:
if notify:
notify(f"Download error: {exc!s}")
self._cleanup_partial_file(dest_path)
return None
def _stream_to_file(
self,
response: httpx.Response,
dest_path: Path,
total_size: int,
notify: StatusCallback | None = None,
) -> None:
"""Write streamed response bytes to disk and emit progress messages."""
downloaded = 0
with open(dest_path, "wb") as file_handle:
for chunk in response.iter_bytes(chunk_size=self.chunk_size):
file_handle.write(chunk)
downloaded += len(chunk)
self._notify_download_progress(downloaded, total_size, notify)
def _notify_download_progress(
self,
downloaded: int,
total_size: int,
notify: StatusCallback | None = None,
) -> None:
"""Emit a formatted progress message when total size is known."""
if total_size <= 0 or not notify:
return
percent = (downloaded / total_size) * 100
downloaded_mb = downloaded / (1024 * 1024)
total_mb = total_size / (1024 * 1024)
notify(f"Downloading: {percent:.1f}% ({downloaded_mb:.1f}/{total_mb:.1f} MB)")
def _cleanup_partial_file(self, dest_path: Path) -> None:
"""Remove undersized partial download files after transfer failures."""
try:
if dest_path.exists() and dest_path.stat().st_size < MIN_FILE_SIZE:
dest_path.unlink()
except OSError:
return
def close(self) -> None:
"""Close internal HTTP clients. Safe to call multiple times."""
if hasattr(self, "_http_client"):
self._http_client.close()
if hasattr(self, "_download_client"):
self._download_client.close()

View File

@@ -0,0 +1,22 @@
"""Fetching, formatting, and filtering of the user's Audible library."""
from .client import LibraryClient
from .search import build_search_text, filter_items
from .table import (
create_progress_sort_key,
create_title_sort_key,
filter_unfinished_items,
format_item_as_row,
truncate_author_name,
)
__all__ = [
"LibraryClient",
"build_search_text",
"filter_items",
"create_progress_sort_key",
"create_title_sort_key",
"filter_unfinished_items",
"format_item_as_row",
"truncate_author_name",
]

25
auditui/library/client.py Normal file
View File

@@ -0,0 +1,25 @@
"""Client facade for Audible library fetch, extraction, and progress updates."""
from __future__ import annotations
import audible
from .client_extract import LibraryClientExtractMixin
from .client_fetch import LibraryClientFetchMixin
from .client_finished import LibraryClientFinishedMixin
from .client_format import LibraryClientFormatMixin
from .client_positions import LibraryClientPositionsMixin
class LibraryClient(
LibraryClientFetchMixin,
LibraryClientExtractMixin,
LibraryClientPositionsMixin,
LibraryClientFinishedMixin,
LibraryClientFormatMixin,
):
"""Audible library client composed from focused behavior mixins."""
def __init__(self, client: audible.Client) -> None:
"""Store authenticated Audible client used by all operations."""
self.client = client

View File

@@ -0,0 +1,84 @@
"""Metadata extraction helpers for library items."""
from __future__ import annotations
from ..types import LibraryItem
class LibraryClientExtractMixin:
"""Extracts display and status fields from library items."""
def extract_title(self, item: LibraryItem) -> str:
"""Return the book title from a library item."""
product = item.get("product", {})
return (
product.get("title")
or item.get("title")
or product.get("asin", "Unknown Title")
)
def extract_authors(self, item: LibraryItem) -> str:
"""Return comma-separated author names from a library item."""
product = item.get("product", {})
authors = product.get("authors") or product.get("contributors") or []
if not authors and "authors" in item:
authors = item.get("authors", [])
author_names = [
author.get("name", "") for author in authors if isinstance(author, dict)
]
return ", ".join(author_names) or "Unknown"
def extract_runtime_minutes(self, item: LibraryItem) -> int | None:
"""Return runtime in minutes if present."""
product = item.get("product", {})
runtime_fields = [
"runtime_length_min",
"runtime_length",
"vLength",
"length",
"duration",
]
runtime = None
for field in runtime_fields:
runtime = product.get(field) or item.get(field)
if runtime is not None:
break
if runtime is None:
return None
if isinstance(runtime, dict):
return int(runtime.get("min", 0))
if isinstance(runtime, (int, float)):
return int(runtime)
return None
def extract_progress_info(self, item: LibraryItem) -> float | None:
"""Return progress percentage (0-100) if present."""
percent_complete = item.get("percent_complete")
listening_status = item.get("listening_status", {})
if isinstance(listening_status, dict) and percent_complete is None:
percent_complete = listening_status.get("percent_complete")
return float(percent_complete) if percent_complete is not None else None
def extract_asin(self, item: LibraryItem) -> str | None:
"""Return the ASIN for a library item."""
product = item.get("product", {})
return item.get("asin") or product.get("asin")
def is_finished(self, item: LibraryItem) -> bool:
"""Return True if the item is marked or inferred as finished."""
is_finished_flag = item.get("is_finished")
percent_complete = item.get("percent_complete")
listening_status = item.get("listening_status")
if isinstance(listening_status, dict):
is_finished_flag = is_finished_flag or listening_status.get(
"is_finished", False
)
if percent_complete is None:
percent_complete = listening_status.get("percent_complete", 0)
return bool(is_finished_flag) or (
isinstance(percent_complete, (int, float)) and percent_complete >= 100
)

View File

@@ -0,0 +1,165 @@
"""Library page fetching helpers for the Audible API client."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
from ..types import LibraryItem, StatusCallback
class LibraryClientFetchMixin:
"""Fetches all library items from paginated Audible endpoints."""
client: Any
def fetch_all_items(
self, on_progress: StatusCallback | None = None
) -> list[LibraryItem]:
"""Fetch all library items from the API."""
response_groups = "contributors,product_attrs,product_desc,is_finished,listening_status,percent_complete"
return self._fetch_all_pages(response_groups, on_progress)
def _fetch_page(
self,
page: int,
page_size: int,
response_groups: str,
) -> tuple[int, list[LibraryItem]]:
"""Fetch one library page and return its index with items."""
library = self.client.get(
path="library",
num_results=page_size,
page=page,
response_groups=response_groups,
)
items = library.get("items", [])
return page, list(items)
def _fetch_all_pages(
self,
response_groups: str,
on_progress: StatusCallback | None = None,
) -> list[LibraryItem]:
"""Fetch all library pages using parallel requests after page one."""
library_response = None
page_size = 200
for attempt_size in [200, 100, 50]:
try:
library_response = self.client.get(
path="library",
num_results=attempt_size,
page=1,
response_groups=response_groups,
)
page_size = attempt_size
break
except Exception:
continue
if not library_response:
return []
first_page_items = library_response.get("items", [])
if not first_page_items:
return []
all_items: list[LibraryItem] = list(first_page_items)
if on_progress:
on_progress(f"Fetched page 1 ({len(first_page_items)} items)...")
if len(first_page_items) < page_size:
return all_items
estimated_pages = self._estimate_total_pages(library_response, page_size)
page_results = self._fetch_remaining_pages(
response_groups=response_groups,
page_size=page_size,
estimated_pages=estimated_pages,
initial_total=len(first_page_items),
on_progress=on_progress,
)
for page_num in sorted(page_results.keys()):
all_items.extend(page_results[page_num])
return all_items
def _estimate_total_pages(self, library_response: dict, page_size: int) -> int:
"""Estimate total pages from API metadata with a conservative cap."""
total_items_estimate = library_response.get(
"total_results"
) or library_response.get("total")
if not total_items_estimate:
return 500
estimated_pages = (total_items_estimate + page_size - 1) // page_size
return min(estimated_pages, 1000)
def _fetch_remaining_pages(
self,
response_groups: str,
page_size: int,
estimated_pages: int,
initial_total: int,
on_progress: StatusCallback | None = None,
) -> dict[int, list[LibraryItem]]:
"""Fetch pages 2..N with bounded in-flight requests for faster startup."""
page_results: dict[int, list[LibraryItem]] = {}
max_workers = min(16, max(1, estimated_pages - 1))
next_page_to_submit = 2
stop_page = estimated_pages + 1
completed_count = 0
total_items = initial_total
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_page: dict = {}
while (
next_page_to_submit <= estimated_pages
and next_page_to_submit < stop_page
and len(future_to_page) < max_workers
):
future = executor.submit(
self._fetch_page,
next_page_to_submit,
page_size,
response_groups,
)
future_to_page[future] = next_page_to_submit
next_page_to_submit += 1
while future_to_page:
future = next(as_completed(future_to_page))
page_num = future_to_page.pop(future)
try:
fetched_page, items = future.result()
except Exception:
continue
if items:
page_results[fetched_page] = items
total_items += len(items)
completed_count += 1
if on_progress and completed_count % 20 == 0:
on_progress(
f"Fetched {completed_count} pages ({total_items} items)..."
)
if len(items) < page_size:
stop_page = min(stop_page, fetched_page)
while (
next_page_to_submit <= estimated_pages
and next_page_to_submit < stop_page
and len(future_to_page) < max_workers
):
next_future = executor.submit(
self._fetch_page,
next_page_to_submit,
page_size,
response_groups,
)
future_to_page[next_future] = next_page_to_submit
next_page_to_submit += 1
return page_results

View File

@@ -0,0 +1,70 @@
"""Helpers for marking content as finished through Audible APIs."""
from __future__ import annotations
from typing import Any
from ..types import LibraryItem
class LibraryClientFinishedMixin:
"""Marks titles as finished and mutates in-memory item state."""
client: Any
def mark_as_finished(self, asin: str, item: LibraryItem | None = None) -> bool:
"""Mark a book as finished on Audible and optionally update item state."""
total_ms = self._get_runtime_ms(asin, item)
if not total_ms:
return False
acr = self._get_acr(asin)
if not acr:
return False
try:
self.client.put(
path=f"1.0/lastpositions/{asin}",
body={"asin": asin, "acr": acr, "position_ms": total_ms},
)
if item:
item["is_finished"] = True
listening_status = item.get("listening_status", {})
if isinstance(listening_status, dict):
listening_status["is_finished"] = True
return True
except Exception:
return False
def _get_runtime_ms(self, asin: str, item: LibraryItem | None = None) -> int | None:
"""Return total runtime in milliseconds from item or metadata endpoint."""
if item:
extract_runtime_minutes = getattr(self, "extract_runtime_minutes")
runtime_min = extract_runtime_minutes(item)
if runtime_min:
return runtime_min * 60 * 1000
try:
response = self.client.get(
path=f"1.0/content/{asin}/metadata",
response_groups="chapter_info",
)
chapter_info = response.get("content_metadata", {}).get("chapter_info", {})
return chapter_info.get("runtime_length_ms")
except Exception:
return None
def _get_acr(self, asin: str) -> str | None:
"""Fetch the ACR token required by finish/update write operations."""
try:
response = self.client.post(
path=f"1.0/content/{asin}/licenserequest",
body={
"response_groups": "content_reference",
"consumption_type": "Download",
"drm_type": "Adrm",
},
)
return response.get("content_license", {}).get("acr")
except Exception:
return None

View File

@@ -0,0 +1,37 @@
"""Formatting helpers exposed by the library client."""
from __future__ import annotations
class LibraryClientFormatMixin:
"""Formats durations and timestamps for display usage."""
@staticmethod
def format_duration(
value: int | None,
unit: str = "minutes",
default_none: str | None = None,
) -> str | None:
"""Format duration values as compact hour-minute strings."""
if value is None or value <= 0:
return default_none
total_minutes = int(value)
if unit == "seconds":
total_minutes //= 60
hours, minutes = divmod(total_minutes, 60)
if hours > 0:
return f"{hours}h{minutes:02d}" if minutes else f"{hours}h"
return f"{minutes}m"
@staticmethod
def format_time(seconds: float) -> str:
"""Format seconds as HH:MM:SS or MM:SS for display."""
total_seconds = int(seconds)
hours = total_seconds // 3600
minutes = (total_seconds % 3600) // 60
secs = total_seconds % 60
if hours > 0:
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
return f"{minutes:02d}:{secs:02d}"

View File

@@ -0,0 +1,85 @@
"""Playback position read and write helpers for library content."""
from __future__ import annotations
from typing import Any
class LibraryClientPositionsMixin:
"""Handles last-position retrieval and persistence."""
client: Any
def get_last_position(self, asin: str) -> float | None:
"""Get the last playback position for a book in seconds."""
try:
response = self.client.get(
path="1.0/annotations/lastpositions",
asins=asin,
)
annotations = response.get("asin_last_position_heard_annots", [])
for annotation in annotations:
if annotation.get("asin") != asin:
continue
last_position_heard = annotation.get("last_position_heard", {})
if not isinstance(last_position_heard, dict):
continue
if last_position_heard.get("status") == "DoesNotExist":
return None
position_ms = last_position_heard.get("position_ms")
if position_ms is not None:
return float(position_ms) / 1000.0
return None
except (OSError, ValueError, KeyError):
return None
def _get_content_reference(self, asin: str) -> dict | None:
"""Fetch content reference payload used by position update calls."""
try:
response = self.client.get(
path=f"1.0/content/{asin}/metadata",
response_groups="content_reference",
)
content_metadata = response.get("content_metadata", {})
content_reference = content_metadata.get("content_reference", {})
if isinstance(content_reference, dict):
return content_reference
return None
except (OSError, ValueError, KeyError):
return None
def _update_position(self, asin: str, position_seconds: float) -> bool:
"""Persist playback position to the API and return success state."""
if position_seconds < 0:
return False
content_ref = self._get_content_reference(asin)
if not content_ref:
return False
acr = content_ref.get("acr")
if not acr:
return False
body = {
"acr": acr,
"asin": asin,
"position_ms": int(position_seconds * 1000),
}
if version := content_ref.get("version"):
body["version"] = version
try:
self.client.put(
path=f"1.0/lastpositions/{asin}",
body=body,
)
return True
except (OSError, ValueError, KeyError):
return False
def save_last_position(self, asin: str, position_seconds: float) -> bool:
"""Save playback position to Audible and return success state."""
if position_seconds <= 0:
return False
return self._update_position(asin, position_seconds)

36
auditui/library/search.py Normal file
View File

@@ -0,0 +1,36 @@
"""Text search over library items for the filter feature."""
from __future__ import annotations
from typing import Callable
from ..types import LibraryItem
from .client import LibraryClient
def build_search_text(item: LibraryItem, library_client: LibraryClient | None) -> str:
"""Build a single lowercase string from title and authors for matching."""
if library_client:
title = library_client.extract_title(item)
authors = library_client.extract_authors(item)
else:
title = item.get("title", "")
authors = ", ".join(
a.get("name", "")
for a in item.get("authors", [])
if isinstance(a, dict) and a.get("name")
)
return f"{title} {authors}".lower()
def filter_items(
items: list[LibraryItem],
filter_text: str,
get_search_text: Callable[[LibraryItem], str],
) -> list[LibraryItem]:
"""Return items whose search text contains filter_text (case-insensitive)."""
if not filter_text:
return items
filter_lower = filter_text.lower()
return [item for item in items if filter_lower in get_search_text(item)]

84
auditui/library/table.py Normal file
View File

@@ -0,0 +1,84 @@
"""Formatting and sorting of library items for the main table."""
import unicodedata
from typing import TYPE_CHECKING, Callable
from ..constants import (
AUTHOR_NAME_DISPLAY_LENGTH,
AUTHOR_NAME_MAX_LENGTH,
PROGRESS_COLUMN_INDEX,
)
from ..types import LibraryItem
if TYPE_CHECKING:
from ..downloads import DownloadManager
def create_title_sort_key(reverse: bool = False) -> tuple[Callable, bool]:
"""Return a (key_fn, reverse) pair for DataTable sort by title column."""
def title_key(row_values):
title_cell = row_values[0]
if isinstance(title_cell, str):
normalized = unicodedata.normalize('NFD', title_cell)
return normalized.encode('ascii', 'ignore').decode('ascii').lower()
return str(title_cell).lower()
return title_key, reverse
def create_progress_sort_key(progress_column_index: int = PROGRESS_COLUMN_INDEX, reverse: bool = False) -> tuple[Callable, bool]:
"""Return a (key_fn, reverse) pair for DataTable sort by progress column."""
def progress_key(row_values):
progress_cell = row_values[progress_column_index]
if isinstance(progress_cell, str):
try:
return float(progress_cell.rstrip("%"))
except (ValueError, AttributeError):
return 0.0
return 0.0
return progress_key, reverse
def truncate_author_name(author_names: str) -> str:
"""Truncate author string to display length with ellipsis if over max."""
if author_names and len(author_names) > AUTHOR_NAME_MAX_LENGTH:
return f"{author_names[:AUTHOR_NAME_DISPLAY_LENGTH]}..."
return author_names
def format_item_as_row(item: LibraryItem, library_client, download_manager: "DownloadManager | None" = None) -> tuple[str, str, str, str, str]:
"""Turn a library item into (title, author, runtime, progress, downloaded) for the table."""
title = library_client.extract_title(item)
author_names = library_client.extract_authors(item)
author_names = truncate_author_name(author_names)
author_display = author_names or "Unknown"
minutes = library_client.extract_runtime_minutes(item)
runtime_str = library_client.format_duration(
minutes, unit="minutes", default_none="Unknown length"
) or "Unknown"
percent_complete = library_client.extract_progress_info(item)
progress_str = (
f"{percent_complete:.1f}%"
if percent_complete and percent_complete > 0
else "0%"
)
downloaded_str = ""
if download_manager:
asin = library_client.extract_asin(item)
if asin and download_manager.is_cached(asin):
downloaded_str = ""
return (title, author_display, runtime_str, progress_str, downloaded_str)
def filter_unfinished_items(items: list[LibraryItem], library_client) -> list[LibraryItem]:
"""Return only items that are not marked as finished."""
return [
item for item in items
if not library_client.is_finished(item)
]

View File

@@ -0,0 +1,6 @@
"""Playback control via ffplay and position sync with Audible."""
from .controller import PlaybackController
from .media_info import load_media_info
__all__ = ["PlaybackController", "load_media_info"]

View File

@@ -0,0 +1,30 @@
"""Chapter lookup by elapsed time."""
def get_current_chapter(
elapsed: float,
chapters: list[dict],
total_duration: float | None,
) -> tuple[str, float, float]:
"""Return (title, elapsed_in_chapter, chapter_duration) for the chapter at elapsed time."""
if not chapters:
return ("Unknown Chapter", elapsed, total_duration or 0.0)
for chapter in chapters:
if chapter["start_time"] <= elapsed < chapter["end_time"]:
chapter_elapsed = elapsed - chapter["start_time"]
chapter_total = chapter["end_time"] - chapter["start_time"]
return (chapter["title"], chapter_elapsed, chapter_total)
last = chapters[-1]
chapter_elapsed = max(0.0, elapsed - last["start_time"])
chapter_total = last["end_time"] - last["start_time"]
return (last["title"], chapter_elapsed, chapter_total)
def get_current_chapter_index(elapsed: float, chapters: list[dict]) -> int | None:
"""Return the index of the chapter containing the given elapsed time."""
if not chapters:
return None
for idx, chapter in enumerate(chapters):
if chapter["start_time"] <= elapsed < chapter["end_time"]:
return idx
return len(chapters) - 1

View File

@@ -0,0 +1,5 @@
"""Speed limits and increment for playback."""
MIN_SPEED = 0.5
MAX_SPEED = 2.0
SPEED_INCREMENT = 0.5

View File

@@ -0,0 +1,14 @@
"""Orchestrates ffplay process, position, chapters, seek, and speed; delegates to playback submodules."""
from __future__ import annotations
from ..library import LibraryClient
from ..types import StatusCallback
from .controller_seek_speed import ControllerSeekSpeedMixin
from .controller_lifecycle import ControllerLifecycleMixin
from .controller_state import ControllerStateMixin
class PlaybackController(ControllerSeekSpeedMixin, ControllerLifecycleMixin, ControllerStateMixin):
"""Controls ffplay: start/stop, pause/resume, seek, speed, and saving position to Audible."""

View File

@@ -0,0 +1,200 @@
"""Playback lifecycle: start, stop, pause, resume, prepare_and_start, restart at position."""
from __future__ import annotations
import signal
import subprocess
import time
from pathlib import Path
from ..downloads import DownloadManager
from ..library import LibraryClient
from ..types import StatusCallback
from . import process as process_mod
from .media_info import load_media_info
from .controller_state import ControllerStateMixin
class ControllerLifecycleMixin(ControllerStateMixin):
"""Start/stop, pause/resume, and restart-at-position logic."""
def start(
self,
path: Path,
activation_hex: str | None = None,
status_callback: StatusCallback | None = None,
start_position: float = 0.0,
speed: float | None = None,
) -> bool:
"""Start ffplay for the given AAX path. Returns True if playback started."""
notify = status_callback or self.notify
if not process_mod.is_ffplay_available():
notify("ffplay not found. Please install ffmpeg")
return False
if self.playback_process is not None:
self.stop()
self.activation_hex = activation_hex
self.seek_offset = start_position
if speed is not None:
self.playback_speed = speed
cmd = process_mod.build_ffplay_cmd(
path, activation_hex, start_position, self.playback_speed
)
try:
proc, return_code = process_mod.run_ffplay(cmd)
if proc is None:
if (
return_code == 0
and start_position > 0
and self.total_duration
and start_position >= self.total_duration - 5
):
notify("Reached end of file")
self._reset_state()
return False
notify(f"Playback process exited immediately (code: {return_code})")
return False
self.playback_process = proc
self.is_playing = True
self.is_paused = False
self.current_file_path = path
self.playback_start_time = time.time()
self.paused_duration = 0.0
self.pause_start_time = None
duration, chs = load_media_info(path, activation_hex)
self.total_duration = duration
self.chapters = chs
notify(f"Playing: {path.name}")
return True
except (OSError, ValueError, subprocess.SubprocessError) as exc:
notify(f"Error starting playback: {exc}")
return False
def stop(self) -> None:
"""Stop ffplay, save position to Audible, and reset state."""
if self.playback_process is None:
return
self._save_current_position()
try:
process_mod.terminate_process(self.playback_process)
finally:
self._reset_state()
def pause(self) -> None:
"""Send SIGSTOP to ffplay and mark state as paused."""
if not self._validate_playback_state(require_paused=False):
return
self.pause_start_time = time.time()
self._send_signal(signal.SIGSTOP, "Paused", "pause")
def resume(self) -> None:
"""Send SIGCONT to ffplay and clear paused state."""
if not self._validate_playback_state(require_paused=True):
return
if self.pause_start_time is not None:
self.paused_duration += time.time() - self.pause_start_time
self.pause_start_time = None
self._send_signal(signal.SIGCONT, "Playing", "resume")
def check_status(self) -> str | None:
"""If the process has exited, return a status message and reset state; else None."""
if self.playback_process is None:
return None
return_code = self.playback_process.poll()
if return_code is None:
return None
finished_file = self.current_file_path
self._reset_state()
if finished_file:
if return_code == 0:
return f"Finished: {finished_file.name}"
return f"Playback ended unexpectedly (code: {return_code}): {finished_file.name}"
return "Playback finished"
def prepare_and_start(
self,
download_manager: DownloadManager,
asin: str,
status_callback: StatusCallback | None = None,
preferred_title: str | None = None,
preferred_author: str | None = None,
) -> bool:
"""Download AAX if needed, get activation bytes, then start playback. Returns True on success."""
notify = status_callback or self.notify
if not download_manager:
notify("Could not download file")
return False
notify("Preparing playback...")
local_path = download_manager.get_or_download(
asin,
notify,
preferred_title=preferred_title,
preferred_author=preferred_author,
)
if not local_path:
notify("Could not download file")
return False
notify("Getting activation bytes...")
activation_hex = download_manager.get_activation_bytes()
if not activation_hex:
notify("Failed to get activation bytes")
return False
start_position = 0.0
if self.library_client:
try:
last = self.library_client.get_last_position(asin)
if last is not None and last > 0:
start_position = last
notify(f"Resuming from {LibraryClient.format_time(start_position)}")
except (OSError, ValueError, KeyError):
pass
notify(f"Starting playback of {local_path.name}...")
self.current_asin = asin
self.last_save_time = time.time()
return self.start(
local_path, activation_hex, notify, start_position, self.playback_speed
)
def toggle_playback(self) -> bool:
"""Toggle between pause and resume. Returns True if an action was performed."""
if not self.is_playing:
return False
if not self.is_alive():
self.stop()
self.notify("Playback has ended")
return False
if self.is_paused:
self.resume()
else:
self.pause()
return True
def _restart_at_position(
self,
new_position: float,
new_speed: float | None = None,
message: str | None = None,
) -> bool:
"""Stop current process and start again at new_position; optionally set speed and notify."""
if not self.is_playing or not self.current_file_path:
return False
was_paused = self.is_paused
saved = self._get_saved_state()
speed = new_speed if new_speed is not None else saved["speed"]
self._stop_process()
time.sleep(0.2)
if self.start(
saved["file_path"], saved["activation"], self.notify, new_position, speed
):
self.current_asin = saved["asin"]
self.total_duration = saved["duration"]
self.chapters = saved["chapters"]
if was_paused:
time.sleep(0.3)
self.pause()
if message:
self.notify(message)
return True
return False

View File

@@ -0,0 +1,127 @@
"""Seek, chapter, position save, and playback speed for the controller."""
from __future__ import annotations
import time
from . import chapters as chapters_mod
from . import seek as seek_mod
from .constants import MIN_SPEED, MAX_SPEED, SPEED_INCREMENT
from .controller_lifecycle import ControllerLifecycleMixin
class ControllerSeekSpeedMixin(ControllerLifecycleMixin):
"""Seek, chapter navigation, position persistence, and speed control."""
def _seek(self, seconds: float, direction: str) -> bool:
"""Seek forward or backward by seconds via restart at new position. Returns True if done."""
elapsed = self._get_current_elapsed()
current = self.seek_offset + elapsed
result = seek_mod.compute_seek_target(
current, self.total_duration, seconds, direction
)
if result is None:
self.notify("Already at end of file")
return False
new_position, message = result
return self._restart_at_position(new_position, message=message)
def seek_forward(self, seconds: float = 30.0) -> bool:
"""Seek forward by the given seconds. Returns True if seek was performed."""
return self._seek(seconds, "forward")
def seek_backward(self, seconds: float = 30.0) -> bool:
"""Seek backward by the given seconds. Returns True if seek was performed."""
return self._seek(seconds, "backward")
def get_current_progress(self) -> tuple[str, float, float] | None:
"""Return (chapter_title, chapter_elapsed, chapter_total) for progress display, or None."""
if not self.is_playing or self.playback_start_time is None:
return None
elapsed = self._get_current_elapsed()
total_elapsed = self.seek_offset + elapsed
return chapters_mod.get_current_chapter(
total_elapsed, self.chapters, self.total_duration
)
def seek_to_chapter(self, direction: str) -> bool:
"""Seek to the next or previous chapter. direction is 'next' or 'previous'. Returns True if done."""
if not self.is_playing or not self.current_file_path:
return False
if not self.chapters:
self.notify("No chapter information available")
return False
elapsed = self._get_current_elapsed()
current_total = self.seek_offset + elapsed
idx = chapters_mod.get_current_chapter_index(
current_total, self.chapters)
if idx is None:
self.notify("Could not determine current chapter")
return False
if direction == "next":
if idx >= len(self.chapters) - 1:
self.notify("Already at last chapter")
return False
target = self.chapters[idx + 1]
new_position = target["start_time"]
message = f"Next chapter: {target['title']}"
else:
if idx <= 0:
self.notify("Already at first chapter")
return False
target = self.chapters[idx - 1]
new_position = target["start_time"]
message = f"Previous chapter: {target['title']}"
return self._restart_at_position(new_position, message=message)
def seek_to_next_chapter(self) -> bool:
"""Seek to the next chapter. Returns True if seek was performed."""
return self.seek_to_chapter("next")
def seek_to_previous_chapter(self) -> bool:
"""Seek to the previous chapter. Returns True if seek was performed."""
return self.seek_to_chapter("previous")
def _save_current_position(self) -> None:
"""Persist current position to Audible via library_client."""
if not (self.library_client and self.current_asin and self.is_playing):
return
if self.playback_start_time is None:
return
current_position = self.seek_offset + self._get_current_elapsed()
if current_position <= 0:
return
try:
self.library_client.save_last_position(
self.current_asin, current_position)
except (OSError, ValueError, KeyError):
pass
def update_position_if_needed(self) -> None:
"""Save position to Audible if the save interval has elapsed since last save."""
if not (self.is_playing and self.library_client and self.current_asin):
return
current_time = time.time()
if current_time - self.last_save_time >= self.position_save_interval:
self._save_current_position()
self.last_save_time = current_time
def _change_speed(self, delta: float) -> bool:
"""Change speed by delta (clamped to MIN/MAX). Restarts playback. Returns True if changed."""
new_speed = max(MIN_SPEED, min(MAX_SPEED, self.playback_speed + delta))
if new_speed == self.playback_speed:
return False
elapsed = self._get_current_elapsed()
current_total = self.seek_offset + elapsed
return self._restart_at_position(
current_total, new_speed, f"Speed: {new_speed:.2f}x"
)
def increase_speed(self) -> bool:
"""Increase playback speed. Returns True if speed was changed."""
return self._change_speed(SPEED_INCREMENT)
def decrease_speed(self) -> bool:
"""Decrease playback speed. Returns True if speed was changed."""
return self._change_speed(-SPEED_INCREMENT)

View File

@@ -0,0 +1,124 @@
"""Playback state: init, reset, elapsed time, process validation and signals."""
from __future__ import annotations
import signal
import time
from pathlib import Path
from ..library import LibraryClient
from ..types import StatusCallback
from . import elapsed as elapsed_mod
from . import process as process_mod
class ControllerStateMixin:
"""State attributes and helpers for process/signal handling."""
def __init__(self, notify: StatusCallback, library_client: LibraryClient | None = None) -> None:
self.notify = notify
self.library_client = library_client
self.playback_process = None
self.is_playing = False
self.is_paused = False
self.current_file_path: Path | None = None
self.current_asin: str | None = None
self.playback_start_time: float | None = None
self.paused_duration: float = 0.0
self.pause_start_time: float | None = None
self.total_duration: float | None = None
self.chapters: list[dict] = []
self.seek_offset: float = 0.0
self.activation_hex: str | None = None
self.last_save_time: float = 0.0
self.position_save_interval: float = 30.0
self.playback_speed: float = 1.0
def _reset_state(self) -> None:
"""Clear playing/paused state and references to current file/asin."""
self.playback_process = None
self.is_playing = False
self.is_paused = False
self.current_file_path = None
self.current_asin = None
self.playback_start_time = None
self.paused_duration = 0.0
self.pause_start_time = None
self.total_duration = None
self.chapters = []
self.seek_offset = 0.0
self.activation_hex = None
self.last_save_time = 0.0
self.playback_speed = 1.0
def _get_saved_state(self) -> dict:
"""Return a snapshot of path, asin, activation, duration, chapters, speed for restart."""
return {
"file_path": self.current_file_path,
"asin": self.current_asin,
"activation": self.activation_hex,
"duration": self.total_duration,
"chapters": self.chapters.copy(),
"speed": self.playback_speed,
}
def _get_current_elapsed(self) -> float:
"""Return elapsed seconds since start, accounting for pauses."""
if self.pause_start_time is not None and not self.is_paused:
self.paused_duration += time.time() - self.pause_start_time
self.pause_start_time = None
return elapsed_mod.get_elapsed(
self.playback_start_time,
self.pause_start_time,
self.paused_duration,
self.is_paused,
)
def _stop_process(self) -> None:
"""Terminate the process and clear playing state without saving position."""
process_mod.terminate_process(self.playback_process)
self.playback_process = None
self.is_playing = False
self.is_paused = False
self.playback_start_time = None
self.paused_duration = 0.0
self.pause_start_time = None
def _validate_playback_state(self, require_paused: bool) -> bool:
"""Return True if process is running and paused state matches require_paused."""
if not (self.playback_process and self.is_playing):
return False
if require_paused and not self.is_paused:
return False
if not require_paused and self.is_paused:
return False
if not self.is_alive():
self.stop()
self.notify("Playback process has ended")
return False
return True
def _send_signal(self, sig: signal.Signals, status_prefix: str, action: str) -> None:
"""Send sig to the process, update is_paused, and notify."""
if self.playback_process is None:
return
try:
process_mod.send_signal(self.playback_process, sig)
self.is_paused = sig == signal.SIGSTOP
filename = self.current_file_path.name if self.current_file_path else None
msg = f"{status_prefix}: {filename}" if filename else status_prefix
self.notify(msg)
except ProcessLookupError:
self.stop()
self.notify("Process no longer exists")
except PermissionError:
self.notify(f"Permission denied: cannot {action} playback")
except (OSError, ValueError) as exc:
self.notify(f"Error {action}ing playback: {exc}")
def is_alive(self) -> bool:
"""Return True if the ffplay process is still running."""
if self.playback_process is None:
return False
return self.playback_process.poll() is None

View File

@@ -0,0 +1,23 @@
"""Elapsed playback time accounting for pauses."""
import time
def get_elapsed(
playback_start_time: float | None,
pause_start_time: float | None,
paused_duration: float,
is_paused: bool,
) -> float:
"""Return elapsed seconds since start, accounting for pauses."""
if playback_start_time is None:
return 0.0
current_time = time.time()
if is_paused and pause_start_time is not None:
return (pause_start_time - playback_start_time) - paused_duration
if pause_start_time is not None:
paused_duration += current_time - pause_start_time
return max(
0.0,
(current_time - playback_start_time) - paused_duration,
)

View File

@@ -0,0 +1,42 @@
"""Duration and chapter list for AAX files via ffprobe."""
import json
import shutil
import subprocess
from pathlib import Path
def load_media_info(path: Path, activation_hex: str | None = None) -> tuple[float | None, list[dict]]:
"""Return (total_duration_seconds, chapters) for the AAX file. Chapters have start_time, end_time, title."""
if not shutil.which("ffprobe"):
return None, []
try:
cmd = ["ffprobe", "-v", "quiet", "-print_format",
"json", "-show_format", "-show_chapters"]
if activation_hex:
cmd.extend(["-activation_bytes", activation_hex])
cmd.append(str(path))
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return None, []
data = json.loads(result.stdout)
format_info = data.get("format", {})
duration_str = format_info.get("duration")
duration = float(duration_str) if duration_str else None
chapters_data = data.get("chapters", [])
chapters = [
{
"start_time": float(ch.get("start_time", 0)),
"end_time": float(ch.get("end_time", 0)),
"title": ch.get("tags", {}).get("title", f"Chapter {idx + 1}"),
}
for idx, ch in enumerate(chapters_data)
]
return duration, chapters
except (json.JSONDecodeError, subprocess.TimeoutExpired, ValueError, KeyError):
return None, []

View File

@@ -0,0 +1,68 @@
"""FFplay process: build command, spawn, terminate, and send signals."""
import os
import shutil
import signal
import subprocess
import time
from pathlib import Path
def build_ffplay_cmd(
path: Path,
activation_hex: str | None,
start_position: float,
speed: float,
) -> list[str]:
"""Build the ffplay command line for the given path and options."""
cmd = ["ffplay", "-nodisp", "-autoexit"]
if activation_hex:
cmd.extend(["-activation_bytes", activation_hex])
if start_position > 0:
cmd.extend(["-ss", str(start_position)])
if speed != 1.0:
cmd.extend(["-af", f"atempo={speed:.2f}"])
cmd.append(str(path))
return cmd
def is_ffplay_available() -> bool:
"""Return True if ffplay is on PATH."""
return shutil.which("ffplay") is not None
def run_ffplay(cmd: list[str]) -> tuple[subprocess.Popen | None, int | None]:
"""Spawn ffplay. Returns (proc, None) on success, (None, return_code) if process exited immediately, (None, None) if ffplay missing or spawn failed."""
if not is_ffplay_available():
return (None, None)
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
time.sleep(0.2)
if proc.poll() is not None:
return (None, proc.returncode)
return (proc, None)
except (OSError, ValueError, subprocess.SubprocessError):
return (None, None)
def terminate_process(proc: subprocess.Popen | None) -> None:
"""Terminate the process; kill if it does not exit within timeout."""
if proc is None:
return
try:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=2)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
except (ProcessLookupError, ValueError):
pass
def send_signal(proc: subprocess.Popen, sig: signal.Signals) -> None:
"""Send sig to the process. May raise ProcessLookupError, PermissionError, OSError."""
os.kill(proc.pid, sig)

19
auditui/playback/seek.py Normal file
View File

@@ -0,0 +1,19 @@
"""Seek target computation: new position and message from direction and seconds."""
def compute_seek_target(
current_position: float,
total_duration: float | None,
seconds: float,
direction: str,
) -> tuple[float, str] | None:
"""Return (new_position, message) for a seek, or None if seek is invalid (e.g. at end)."""
if direction == "forward":
new_position = current_position + seconds
if total_duration is not None:
if new_position >= total_duration - 2:
return None
new_position = min(new_position, total_duration - 2)
return (new_position, f"Skipped forward {int(seconds)}s")
new_position = max(0.0, current_position - seconds)
return (new_position, f"Skipped backward {int(seconds)}s")

View File

@@ -0,0 +1,5 @@
"""Listening and account statistics for the stats screen."""
from .aggregator import StatsAggregator
__all__ = ["StatsAggregator"]

71
auditui/stats/account.py Normal file
View File

@@ -0,0 +1,71 @@
"""Account and subscription data from the API."""
from typing import Any
def get_account_info(client: Any) -> dict:
if not client:
return {}
account_info = {}
endpoints = [
(
"1.0/account/information",
"subscription_details,plan_summary,subscription_details_payment_instrument,delinquency_status,customer_benefits,customer_segments,directed_ids",
),
(
"1.0/customer/information",
"subscription_details_premium,subscription_details_rodizio,customer_segment,subscription_details_channels,migration_details",
),
(
"1.0/customer/status",
"benefits_status,member_giving_status,prime_benefits_status,prospect_benefits_status",
),
]
for endpoint, response_groups in endpoints:
try:
response = client.get(endpoint, response_groups=response_groups)
account_info.update(response)
except Exception:
pass
return account_info
def get_subscription_details(account_info: dict) -> dict:
paths = [
["customer_details", "subscription", "subscription_details"],
["customer", "customer_details", "subscription", "subscription_details"],
["subscription_details"],
["subscription", "subscription_details"],
]
for path in paths:
data = account_info
for key in path:
if isinstance(data, dict):
data = data.get(key)
else:
break
if isinstance(data, list) and data:
return data[0]
return {}
def get_country(auth: Any) -> str:
if not auth:
return "Unknown"
try:
locale_obj = getattr(auth, "locale", None)
if not locale_obj:
return "Unknown"
if hasattr(locale_obj, "country_code"):
return locale_obj.country_code.upper()
if hasattr(locale_obj, "domain"):
return locale_obj.domain.upper()
if isinstance(locale_obj, str):
return (
locale_obj.split("_")[-1].upper()
if "_" in locale_obj
else locale_obj.upper()
)
return str(locale_obj)
except Exception:
return "Unknown"

View File

@@ -0,0 +1,85 @@
"""Aggregates listening time, account info, and subscription data for display."""
from datetime import date
from typing import Any
from ..types import LibraryItem
from . import account as account_mod
from . import email as email_mod
from . import format as format_mod
from . import listening as listening_mod
class StatsAggregator:
"""Builds a list of (label, value) stats from the API, auth, and library."""
def __init__(
self,
client: Any,
auth: Any,
library_client: Any,
all_items: list[LibraryItem],
) -> None:
self.client = client
self.auth = auth
self.library_client = library_client
self.all_items = all_items
def get_stats(self, today: date | None = None) -> list[tuple[str, str]]:
if not self.client:
return []
today = today or date.today()
signup_year = listening_mod.get_signup_year(self.client)
month_time = listening_mod.get_listening_time(
self.client, 1, today.strftime("%Y-%m")
)
year_time = listening_mod.get_listening_time(
self.client, 12, today.strftime("%Y-01")
)
finished_count = listening_mod.get_finished_books_count(
self.library_client, self.all_items or []
)
total_books = len(self.all_items) if self.all_items else 0
email = email_mod.resolve_email(
self.auth,
self.client,
get_account_info=lambda: account_mod.get_account_info(self.client),
)
country = account_mod.get_country(self.auth)
subscription_name = "Unknown"
subscription_price = "Unknown"
next_bill_date = "Unknown"
account_info = account_mod.get_account_info(self.client)
if account_info:
subscription_data = account_mod.get_subscription_details(
account_info)
if subscription_data:
if name := subscription_data.get("name"):
subscription_name = name
if bill_date := subscription_data.get("next_bill_date"):
next_bill_date = format_mod.format_date(bill_date)
if bill_amount := subscription_data.get("next_bill_amount", {}):
amount = bill_amount.get("currency_value")
currency = bill_amount.get("currency_code", "EUR")
if amount is not None:
subscription_price = f"{amount} {currency}"
stats_items = []
if email != "Unknown":
stats_items.append(("Email", email))
stats_items.append(("Country Store", country))
stats_items.append(
("Signup Year", str(signup_year) if signup_year > 0 else "Unknown")
)
if next_bill_date != "Unknown":
stats_items.append(("Next Credit", next_bill_date))
stats_items.append(("Next Bill", next_bill_date))
if subscription_name != "Unknown":
stats_items.append(("Subscription", subscription_name))
if subscription_price != "Unknown":
stats_items.append(("Price", subscription_price))
stats_items.append(("This Month", format_mod.format_time(month_time)))
stats_items.append(("This Year", format_mod.format_time(year_time)))
stats_items.append(
("Books Finished", f"{finished_count} / {total_books}"))
return stats_items

155
auditui/stats/email.py Normal file
View File

@@ -0,0 +1,155 @@
"""Email resolution from auth, config, auth file, and account API."""
import json
from pathlib import Path
from typing import Any, Callable
from ..constants import AUTH_PATH, CONFIG_PATH
def find_email_in_data(data: Any) -> str | None:
if data is None:
return None
stack = [data]
while stack:
current = stack.pop()
if isinstance(current, dict):
stack.extend(current.values())
elif isinstance(current, list):
stack.extend(current)
elif isinstance(current, str):
if "@" in current:
local, _, domain = current.partition("@")
if local and "." in domain:
return current
return None
def first_email(*values: str | None) -> str | None:
for value in values:
if value and value != "Unknown":
return value
return None
def get_email_from_auth(auth: Any) -> str | None:
if not auth:
return None
try:
email = first_email(
getattr(auth, "username", None),
getattr(auth, "login", None),
getattr(auth, "email", None),
)
if email:
return email
except Exception:
return None
try:
customer_info = getattr(auth, "customer_info", None)
if isinstance(customer_info, dict):
email = first_email(
customer_info.get("email"),
customer_info.get("email_address"),
customer_info.get("primary_email"),
)
if email:
return email
except Exception:
return None
try:
data = getattr(auth, "data", None)
if isinstance(data, dict):
return first_email(
data.get("username"),
data.get("email"),
data.get("login"),
data.get("user_email"),
)
except Exception:
return None
return None
def get_email_from_config(config_path: Path | None = None) -> str | None:
path = config_path or CONFIG_PATH
try:
if path.exists():
with open(path, "r", encoding="utf-8") as f:
config = json.load(f)
return first_email(
config.get("email"),
config.get("username"),
config.get("login"),
)
except Exception:
return None
return None
def get_email_from_auth_file(auth_path: Path | None = None) -> str | None:
path = auth_path or AUTH_PATH
try:
if path.exists():
with open(path, "r", encoding="utf-8") as f:
auth_file_data = json.load(f)
return first_email(
auth_file_data.get("username"),
auth_file_data.get("email"),
auth_file_data.get("login"),
auth_file_data.get("user_email"),
)
except Exception:
return None
return None
def get_email_from_account_info(account_info: dict) -> str | None:
email = first_email(
account_info.get("email"),
account_info.get("customer_email"),
account_info.get("username"),
)
if email:
return email
customer_info = account_info.get("customer_info", {})
if isinstance(customer_info, dict):
return first_email(
customer_info.get("email"),
customer_info.get("email_address"),
customer_info.get("primary_email"),
)
return None
def resolve_email(
auth: Any,
client: Any,
config_path: Path | None = None,
auth_path: Path | None = None,
get_account_info: Callable[[], dict] | None = None,
) -> str:
config_path = config_path or CONFIG_PATH
auth_path = auth_path or AUTH_PATH
for getter in (
lambda: get_email_from_auth(auth),
lambda: get_email_from_config(config_path),
lambda: get_email_from_auth_file(auth_path),
lambda: get_email_from_account_info(
get_account_info()) if get_account_info else None,
):
email = getter()
if email:
return email
auth_data = None
if auth:
try:
auth_data = getattr(auth, "data", None)
except Exception:
pass
account_info = get_account_info() if get_account_info else {}
for candidate in (auth_data, account_info):
email = find_email_in_data(candidate)
if email:
return email
return "Unknown"

22
auditui/stats/format.py Normal file
View File

@@ -0,0 +1,22 @@
"""Time and date formatting for stats display."""
from datetime import datetime
def format_time(milliseconds: int) -> str:
total_seconds = int(milliseconds) // 1000
hours, remainder = divmod(total_seconds, 3600)
minutes, _ = divmod(remainder, 60)
if hours > 0:
return f"{hours}h{minutes:02d}"
return f"{minutes}m"
def format_date(date_str: str | None) -> str:
if not date_str:
return "Unknown"
try:
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d")
except ValueError:
return date_str

View File

@@ -0,0 +1,75 @@
"""Listening time and signup year from stats API; finished books count."""
from datetime import date
from typing import Any
from ..types import LibraryItem
def has_activity(stats: dict) -> bool:
monthly_stats = stats.get("aggregated_monthly_listening_stats", [])
return bool(
monthly_stats and any(s.get("aggregated_sum", 0)
> 0 for s in monthly_stats)
)
def get_listening_time(client: Any, duration: int, start_date: str) -> int:
if not client:
return 0
try:
stats = client.get(
"1.0/stats/aggregates",
monthly_listening_interval_duration=str(duration),
monthly_listening_interval_start_date=start_date,
store="Audible",
)
monthly_stats = stats.get("aggregated_monthly_listening_stats", [])
return sum(s.get("aggregated_sum", 0) for s in monthly_stats)
except Exception:
return 0
def get_signup_year(client: Any) -> int:
if not client:
return 0
current_year = date.today().year
try:
stats = client.get(
"1.0/stats/aggregates",
monthly_listening_interval_duration="12",
monthly_listening_interval_start_date=f"{current_year}-01",
store="Audible",
)
if not has_activity(stats):
return 0
except Exception:
return 0
left, right = 1995, current_year
earliest_year = current_year
while left <= right:
middle = (left + right) // 2
try:
stats = client.get(
"1.0/stats/aggregates",
monthly_listening_interval_duration="12",
monthly_listening_interval_start_date=f"{middle}-01",
store="Audible",
)
has_activity_ = has_activity(stats)
except Exception:
has_activity_ = False
if has_activity_:
earliest_year = middle
right = middle - 1
else:
left = middle + 1
return earliest_year
def get_finished_books_count(
library_client: Any, all_items: list[LibraryItem]
) -> int:
if not library_client or not all_items:
return 0
return sum(1 for item in all_items if library_client.is_finished(item))

View File

@@ -0,0 +1,8 @@
"""Shared type aliases for the Audible TUI."""
from __future__ import annotations
from typing import Callable
LibraryItem = dict
StatusCallback = Callable[[str], None]

7
auditui/ui/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
"""Modal screens: help keybindings, filter input, and listening/account statistics."""
from .filter_screen import FilterScreen
from .help_screen import HelpScreen
from .stats_screen import StatsScreen
__all__ = ["FilterScreen", "HelpScreen", "StatsScreen"]

30
auditui/ui/common.py Normal file
View File

@@ -0,0 +1,30 @@
"""Shared protocol, constants, and mixin for modal screens that need app context."""
from typing import Any, Protocol, cast
class _AppContext(Protocol):
BINDINGS: list[tuple[str, str, str]]
client: Any
auth: Any
library_client: Any
all_items: list[dict]
KEY_DISPLAY_MAP = {
"ctrl+": "^",
"left": "",
"right": "",
"up": "",
"down": "",
"space": "Space",
"enter": "Enter",
}
KEY_COLOR = "#f9e2af"
DESC_COLOR = "#cdd6f4"
class AppContextMixin:
def _app(self) -> _AppContext:
return cast(_AppContext, cast(Any, self).app)

View File

@@ -0,0 +1,66 @@
"""Filter modal with input; returns filter string on dismiss."""
from typing import Callable
from textual.app import ComposeResult
from textual.containers import Container
from textual.screen import ModalScreen
from textual.timer import Timer
from textual.widgets import Input, Static
from .common import KEY_COLOR
class FilterScreen(ModalScreen[str]):
BINDINGS = [("escape", "cancel", "Cancel")]
def __init__(
self,
initial_filter: str = "",
on_change: Callable[[str], None] | None = None,
debounce_seconds: float = 0.2,
) -> None:
super().__init__()
self._initial_filter = initial_filter
self._on_change = on_change
self._debounce_seconds = debounce_seconds
self._debounce_timer: Timer | None = None
def compose(self) -> ComposeResult:
with Container(id="filter_container"):
yield Static("Filter Library", id="filter_title")
yield Input(
value=self._initial_filter,
placeholder="Type to filter by title or author...",
id="filter_input",
)
yield Static(
f"Press [bold {KEY_COLOR}]Enter[/] to apply, "
f"[bold {KEY_COLOR}]Escape[/] to clear",
id="filter_footer",
)
def on_mount(self) -> None:
self.query_one("#filter_input", Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
self.dismiss(event.value)
def on_input_changed(self, event: Input.Changed) -> None:
callback = self._on_change
if not callback:
return
if self._debounce_timer:
self._debounce_timer.stop()
value = event.value
self._debounce_timer = self.set_timer(
self._debounce_seconds,
lambda: callback(value),
)
def action_cancel(self) -> None:
self.dismiss("")
def on_unmount(self) -> None:
if self._debounce_timer:
self._debounce_timer.stop()

54
auditui/ui/help_screen.py Normal file
View File

@@ -0,0 +1,54 @@
"""Help modal that lists keybindings from the main app."""
from typing import TYPE_CHECKING, Any
from textual.app import ComposeResult
from textual.containers import Container, Vertical
from textual.screen import ModalScreen
from textual.widgets import Label, ListItem, ListView, Static
from .common import KEY_COLOR, KEY_DISPLAY_MAP, DESC_COLOR, AppContextMixin
if TYPE_CHECKING:
from textual.binding import Binding
class HelpScreen(AppContextMixin, ModalScreen):
BINDINGS = [("escape", "dismiss", "Close"), ("?", "dismiss", "Close")]
@staticmethod
def _format_key_display(key: str) -> str:
result = key
for old, new in KEY_DISPLAY_MAP.items():
result = result.replace(old, new)
return result
@staticmethod
def _parse_binding(binding: "Binding | tuple[str, str, str]") -> tuple[str, str]:
if isinstance(binding, tuple):
return binding[0], binding[2]
return binding.key, binding.description
def _make_item(self, binding: "Binding | tuple[str, str, str]") -> ListItem:
key, description = self._parse_binding(binding)
key_display = self._format_key_display(key)
text = f"[bold {KEY_COLOR}]{key_display:>16}[/] [{DESC_COLOR}]{description:<25}[/]"
return ListItem(Label(text))
def compose(self) -> ComposeResult:
app = self._app()
bindings = list(app.BINDINGS)
with Container(id="help_container"):
yield Static("Keybindings", id="help_title")
with Vertical(id="help_content"):
yield ListView(
*[self._make_item(b) for b in bindings],
classes="help_list",
)
yield Static(
f"Press [bold {KEY_COLOR}]?[/] or [bold {KEY_COLOR}]Escape[/] to close",
id="help_footer",
)
async def action_dismiss(self, result: Any | None = None) -> None:
await self.dismiss(result)

View File

@@ -0,0 +1,54 @@
"""Statistics modal showing listening time and account info via StatsAggregator."""
from datetime import date
from typing import Any
from textual.app import ComposeResult
from textual.containers import Container, Vertical
from textual.screen import ModalScreen
from textual.widgets import Label, ListItem, ListView, Static
from ..stats import StatsAggregator
from .common import KEY_COLOR, DESC_COLOR, AppContextMixin
class StatsScreen(AppContextMixin, ModalScreen):
BINDINGS = [("escape", "dismiss", "Close"), ("s", "dismiss", "Close")]
def _make_stat_item(self, label: str, value: str) -> ListItem:
text = f"[bold {KEY_COLOR}]{label:>16}[/] [{DESC_COLOR}]{value:<25}[/]"
return ListItem(Label(text))
def compose(self) -> ComposeResult:
app = self._app()
if not app.client:
with Container(id="help_container"):
yield Static("Statistics", id="help_title")
yield Static(
"Not authenticated. Please restart and authenticate.",
classes="help_row",
)
yield Static(
f"Press [bold {KEY_COLOR}]s[/] or [bold {KEY_COLOR}]Escape[/] to close",
id="help_footer",
)
return
aggregator = StatsAggregator(
app.client, app.auth, app.library_client, app.all_items or []
)
stats_items = aggregator.get_stats(date.today())
with Container(id="help_container"):
yield Static("Statistics", id="help_title")
with Vertical(id="help_content"):
yield ListView(
*[self._make_stat_item(label, value)
for label, value in stats_items],
classes="help_list",
)
yield Static(
f"Press [bold {KEY_COLOR}]s[/] or [bold {KEY_COLOR}]Escape[/] to close",
id="help_footer",
)
async def action_dismiss(self, result: Any | None = None) -> None:
await self.dismiss(result)

271
main.py
View File

@@ -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
View File

@@ -0,0 +1,32 @@
[project]
name = "auditui"
version = "0.2.0"
description = "An Audible TUI client"
readme = "README.md"
requires-python = ">=3.10,<3.13"
dependencies = ["audible>=0.10.0", "httpx>=0.28.1", "textual>=8.0.0"]
[project.optional-dependencies]
dev = [
"coverage[toml]>=7.0",
"pytest>=7.0",
"pytest-asyncio>=0.23",
"httpx>=0.28.1",
]
[project.scripts]
auditui = "auditui.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
branch = true
source = ["auditui"]
[tool.coverage.report]
show_missing = true
skip_covered = true
[tool.uv]
package = true

View File

@@ -1,2 +0,0 @@
audible
textual

View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, cast
from auditui.app.actions import AppActionsMixin
@dataclass(slots=True)
class FakeTable:
"""Minimal table shim exposing cursor and row count."""
row_count: int
cursor_row: int = 0
class DummyActionsApp(AppActionsMixin):
"""Minimal app host used for download naming hint tests."""
def __init__(self) -> None:
"""Initialize state required by action helpers."""
self.current_items: list[dict] = []
self.download_manager = object()
self.library_client = type(
"Library", (), {"extract_asin": lambda self, item: item.get("asin")}
)()
self._table = FakeTable(row_count=0, cursor_row=0)
def update_status(self, message: str) -> None:
"""Ignore status in this focused behavior test."""
del message
def query_one(self, selector: str, _type: object) -> FakeTable:
"""Return the fake table used in selection tests."""
assert selector == "#library_table"
return self._table
def test_action_toggle_download_passes_selected_item() -> None:
"""Ensure download toggle forwards selected item for naming hints."""
app = DummyActionsApp()
seen: list[tuple[str, str | None]] = []
def capture_toggle(asin: str, item: dict | None = None) -> None:
"""Capture download toggle arguments for assertions."""
seen.append((asin, item.get("title") if item else None))
setattr(cast(Any, app), "_toggle_download_async", capture_toggle)
app._table = FakeTable(row_count=1, cursor_row=0)
app.current_items = [{"asin": "ASIN", "title": "Book"}]
app.action_toggle_download()
assert seen == [("ASIN", "Book")]

View File

@@ -0,0 +1,135 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, cast
from auditui.app.actions import AppActionsMixin
@dataclass(slots=True)
class FakeTable:
"""Minimal table shim exposing cursor and row count."""
row_count: int
cursor_row: int = 0
class FakePlayback:
"""Playback stub with togglable boolean return values."""
def __init__(self, result: bool) -> None:
"""Store deterministic toggle result for tests."""
self._result = result
self.calls: list[str] = []
def toggle_playback(self) -> bool:
"""Return configured result and record call."""
self.calls.append("toggle")
return self._result
def seek_forward(self, _seconds: float) -> bool:
"""Return configured result and record call."""
self.calls.append("seek_forward")
return self._result
class DummyActionsApp(AppActionsMixin):
"""Mixin host with just enough state for action method tests."""
def __init__(self) -> None:
"""Initialize fake app state used by action helpers."""
self.messages: list[str] = []
self.current_items: list[dict] = []
self.download_manager = object()
self.library_client = type(
"Library",
(),
{
"extract_asin": lambda self, item: item.get("asin"),
"extract_title": lambda self, item: item.get("title"),
"extract_authors": lambda self, item: item.get("authors"),
},
)()
self.playback = FakePlayback(True)
self.filter_text = "hello"
self._refreshed = 0
self._table = FakeTable(row_count=0, cursor_row=0)
def update_status(self, message: str) -> None:
"""Collect status messages for assertions."""
self.messages.append(message)
def query_one(self, selector: str, _type: object) -> FakeTable:
"""Return the fake table used in selection tests."""
assert selector == "#library_table"
return self._table
def _refresh_filtered_view(self) -> None:
"""Record refresh invocations for filter tests."""
self._refreshed += 1
def test_get_selected_asin_requires_non_empty_table() -> None:
"""Ensure selection fails gracefully when table has no rows."""
app = DummyActionsApp()
app._table = FakeTable(row_count=0)
assert app._get_selected_asin() is None
assert app.messages[-1] == "No books available"
def test_get_selected_asin_returns_current_row_asin() -> None:
"""Ensure selected row index maps to current_items ASIN."""
app = DummyActionsApp()
app._table = FakeTable(row_count=2, cursor_row=1)
app.current_items = [{"asin": "A1"}, {"asin": "A2"}]
assert app._get_selected_asin() == "A2"
def test_action_play_selected_starts_async_playback() -> None:
"""Ensure play action calls async starter with selected ASIN."""
app = DummyActionsApp()
seen: list[str] = []
def capture_start(asin: str, item: dict | None = None) -> None:
"""Capture playback start arguments for assertions."""
suffix = f":{item.get('title')}" if item else ""
seen.append(f"start:{asin}{suffix}")
setattr(cast(Any, app), "_start_playback_async", capture_start)
app._table = FakeTable(row_count=1, cursor_row=0)
app.current_items = [{"asin": "ASIN", "title": "Book"}]
app.action_play_selected()
assert seen[-1] == "start:ASIN:Book"
def test_action_toggle_playback_shows_hint_when_no_playback() -> None:
"""Ensure toggle action displays no-playback hint on false return."""
app = DummyActionsApp()
app.playback = FakePlayback(False)
app.action_toggle_playback()
assert app.messages[-1] == "No playback active. Press Enter to play a book."
def test_action_seek_forward_shows_hint_when_seek_fails() -> None:
"""Ensure failed seek action reuses no-playback helper status."""
app = DummyActionsApp()
app.playback = FakePlayback(False)
app.action_seek_forward()
assert app.messages[-1] == "No playback active. Press Enter to play a book."
def test_action_clear_filter_resets_filter_and_refreshes() -> None:
"""Ensure clearing filter resets text and refreshes filtered view."""
app = DummyActionsApp()
app.action_clear_filter()
assert app.filter_text == ""
assert app._refreshed == 1
assert app.messages[-1] == "Filter cleared"
def test_apply_filter_coerces_none_to_empty_string() -> None:
"""Ensure apply_filter normalizes None and refreshes list view."""
app = DummyActionsApp()
app._apply_filter(None)
assert app.filter_text == ""
assert app._refreshed == 1

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from typing import TypeAlias
from auditui.app.bindings import BINDINGS
from textual.binding import Binding
BindingTuple: TypeAlias = tuple[str, str, str]
NormalizedBinding: TypeAlias = tuple[str, str, str, bool]
EXPECTED_BINDINGS: tuple[NormalizedBinding, ...] = (
("?", "show_help", "Help", False),
("s", "show_stats", "Stats", False),
("/", "filter", "Filter", False),
("escape", "clear_filter", "Clear filter", False),
("n", "sort", "Sort by name", False),
("p", "sort_by_progress", "Sort by progress", False),
("a", "show_all", "All/Unfinished", False),
("r", "refresh", "Refresh", False),
("enter", "play_selected", "Play", False),
("space", "toggle_playback", "Pause/Resume", True),
("left", "seek_backward", "-30s", False),
("right", "seek_forward", "+30s", False),
("ctrl+left", "previous_chapter", "Previous chapter", False),
("ctrl+right", "next_chapter", "Next chapter", False),
("up", "increase_speed", "Increase speed", False),
("down", "decrease_speed", "Decrease speed", False),
("f", "toggle_finished", "Mark finished", False),
("d", "toggle_download", "Download/Delete", False),
("q", "quit", "Quit", False),
)
def _normalize_binding(binding: Binding | BindingTuple) -> NormalizedBinding:
"""Return key, action, description, and priority from one binding item."""
if isinstance(binding, Binding):
return (binding.key, binding.action, binding.description, binding.priority)
key, action, description = binding
return (key, action, description, False)
def _all_bindings() -> list[NormalizedBinding]:
"""Normalize all app bindings into a stable comparable structure."""
return [_normalize_binding(binding) for binding in BINDINGS]
def test_bindings_match_expected_shortcuts() -> None:
"""Ensure the shipped shortcut list stays stable and explicit."""
assert _all_bindings() == list(EXPECTED_BINDINGS)
def test_binding_keys_are_unique() -> None:
"""Ensure each key is defined only once to avoid dispatch ambiguity."""
keys = [binding[0] for binding in _all_bindings()]
assert len(keys) == len(set(keys))

View File

@@ -0,0 +1,114 @@
from __future__ import annotations
from auditui.app.library import AppLibraryMixin
from auditui.app import library as library_mod
class DummyLibraryApp(AppLibraryMixin):
"""Mixin host exposing only members used by AppLibraryMixin."""
def __init__(self) -> None:
"""Initialize in-memory app state and call tracking."""
self.all_items: list[dict] = []
self.show_all_mode = False
self._search_text_cache: dict[int, str] = {1: "x"}
self.messages: list[str] = []
self.call_log: list[tuple[str, tuple]] = []
self.library_client = None
def _prime_search_cache(self, items: list[dict]) -> None:
"""Store a marker so callers can assert this method was reached."""
self.call_log.append(("prime", (items,)))
def show_all(self) -> None:
"""Record show_all invocation for assertion."""
self.call_log.append(("show_all", ()))
def show_unfinished(self) -> None:
"""Record show_unfinished invocation for assertion."""
self.call_log.append(("show_unfinished", ()))
def update_status(self, message: str) -> None:
"""Capture status messages."""
self.messages.append(message)
def call_from_thread(self, func, *args) -> None:
"""Execute callback immediately to simplify tests."""
func(*args)
def _thread_status_update(self, message: str) -> None:
"""Capture worker-thread status update messages."""
self.messages.append(message)
def test_on_library_loaded_refreshes_cache_and_shows_unfinished() -> None:
"""Ensure loaded items reset cache and default to unfinished view."""
app = DummyLibraryApp()
items = [{"asin": "a"}, {"asin": "b"}]
app.on_library_loaded(items)
assert app.all_items == items
assert app._search_text_cache == {}
assert app.messages[-1] == "Loaded 2 books"
assert app.call_log[-1][0] == "show_unfinished"
def test_on_library_loaded_uses_show_all_mode() -> None:
"""Ensure loaded items respect show_all mode when enabled."""
app = DummyLibraryApp()
app.show_all_mode = True
app.on_library_loaded([{"asin": "a"}])
assert app.call_log[-1][0] == "show_all"
def test_on_library_error_formats_message() -> None:
"""Ensure library errors are surfaced through status updates."""
app = DummyLibraryApp()
app.on_library_error("boom")
assert app.messages == ["Error fetching library: boom"]
def test_fetch_library_calls_on_loaded(monkeypatch) -> None:
"""Ensure fetch_library forwards fetched items through call_from_thread."""
app = DummyLibraryApp()
class Worker:
"""Simple worker shim exposing cancellation state."""
is_cancelled = False
class LibraryClient:
"""Fake client returning a deterministic item list."""
def fetch_all_items(self, callback):
"""Invoke callback and return one item."""
callback("progress")
return [{"asin": "x"}]
app.library_client = LibraryClient()
monkeypatch.setattr(library_mod, "get_current_worker", lambda: Worker())
AppLibraryMixin.fetch_library.__wrapped__(app)
assert app.all_items == [{"asin": "x"}]
assert "Loaded 1 books" in app.messages
def test_fetch_library_handles_expected_exception(monkeypatch) -> None:
"""Ensure fetch exceptions call on_library_error with error text."""
app = DummyLibraryApp()
class Worker:
"""Simple worker shim exposing cancellation state."""
is_cancelled = False
class BrokenClient:
"""Fake client raising an expected fetch exception."""
def fetch_all_items(self, callback):
"""Raise the same exception family handled by mixin."""
del callback
raise ValueError("bad fetch")
app.library_client = BrokenClient()
monkeypatch.setattr(library_mod, "get_current_worker", lambda: Worker())
AppLibraryMixin.fetch_library.__wrapped__(app)
assert app.messages[-1] == "Error fetching library: bad fetch"

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import cast
from auditui.app.progress import AppProgressMixin
from textual.events import Key
from textual.widgets import DataTable
@dataclass(slots=True)
class FakeKeyEvent:
"""Minimal key event carrying key value and prevent_default state."""
key: str
prevented: bool = False
def prevent_default(self) -> None:
"""Mark event as prevented."""
self.prevented = True
@dataclass(slots=True)
class FakeStatic:
"""Minimal static widget with text and visibility fields."""
display: bool = False
text: str = ""
def update(self, value: str) -> None:
"""Store rendered text value."""
self.text = value
@dataclass(slots=True)
class FakeProgressBar:
"""Minimal progress bar widget storing latest progress value."""
progress: float = 0.0
def update(self, progress: float) -> None:
"""Store progress value for assertions."""
self.progress = progress
@dataclass(slots=True)
class FakeContainer:
"""Minimal container exposing display property."""
display: bool = False
class DummyPlayback:
"""Playback shim exposing only members used by AppProgressMixin."""
def __init__(self) -> None:
"""Initialize playback state and update counters."""
self.is_playing = False
self._status: str | None = None
self._progress: tuple[str, float, float] | None = None
self.saved_calls = 0
def check_status(self):
"""Return configurable status check message."""
return self._status
def get_current_progress(self):
"""Return configurable progress tuple."""
return self._progress
def update_position_if_needed(self) -> None:
"""Record periodic save invocations."""
self.saved_calls += 1
class DummyProgressApp(AppProgressMixin):
"""Mixin host that records action dispatch and widget updates."""
def __init__(self) -> None:
"""Initialize fake widgets and playback state."""
self.playback = DummyPlayback()
self.focused = object()
self.actions: list[str] = []
self.messages: list[str] = []
self.progress_info = FakeStatic()
self.progress_bar = FakeProgressBar()
self.progress_container = FakeContainer()
def action_seek_backward(self) -> None:
"""Record backward seek action dispatch."""
self.actions.append("seek_backward")
def action_toggle_playback(self) -> None:
"""Record toggle playback action dispatch."""
self.actions.append("toggle")
def update_status(self, message: str) -> None:
"""Capture status messages for assertions."""
self.messages.append(message)
def query_one(self, selector: str, _type: object):
"""Return fake widgets by selector used by progress mixin."""
return {
"#progress_info": self.progress_info,
"#progress_bar": self.progress_bar,
"#progress_bar_container": self.progress_container,
}[selector]
def test_on_key_dispatches_seek_when_playing() -> None:
"""Ensure left key is intercepted and dispatched to seek action."""
app = DummyProgressApp()
app.playback.is_playing = True
event = FakeKeyEvent("left")
app.on_key(cast(Key, event))
assert event.prevented is True
assert app.actions == ["seek_backward"]
def test_on_key_dispatches_space_when_table_focused() -> None:
"""Ensure space is intercepted and dispatched when table is focused."""
app = DummyProgressApp()
app.focused = DataTable()
event = FakeKeyEvent("space")
app.on_key(cast(Key, event))
assert event.prevented is True
assert app.actions == ["toggle"]
def test_check_playback_status_hides_progress_after_message() -> None:
"""Ensure playback status message triggers hide-progress behavior."""
app = DummyProgressApp()
app.playback._status = "Finished"
app._check_playback_status()
assert app.messages[-1] == "Finished"
assert app.progress_info.display is False
assert app.progress_container.display is False
def test_update_progress_renders_visible_progress_row() -> None:
"""Ensure valid progress data updates widgets and makes them visible."""
app = DummyProgressApp()
app.playback.is_playing = True
app.playback._progress = ("Chapter", 30.0, 60.0)
app._update_progress()
assert app.progress_bar.progress == 50.0
assert app.progress_info.display is True
assert app.progress_container.display is True

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from auditui.app.progress import AppProgressMixin
class DummyPlayback:
"""Playback stub exposing periodic update method."""
def __init__(self) -> None:
"""Initialize call counter."""
self.saved_calls = 0
def update_position_if_needed(self) -> None:
"""Increment call counter for assertions."""
self.saved_calls += 1
class DummyProgressApp(AppProgressMixin):
"""Minimal app host containing playback dependency only."""
def __init__(self) -> None:
"""Initialize playback stub."""
self.playback = DummyPlayback()
def test_save_position_periodically_delegates_to_playback() -> None:
"""Ensure periodic save method delegates to playback updater."""
app = DummyProgressApp()
app._save_position_periodically()
assert app.playback.saved_calls == 1

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, cast
from auditui.app import Auditui
from auditui.library import build_search_text, filter_items
class StubLibrary:
"""Minimal library facade used by search-related app helpers."""
def extract_title(self, item: dict) -> str:
"""Return title from a synthetic item."""
return item.get("title", "")
def extract_authors(self, item: dict) -> str:
"""Return authors from a synthetic item."""
return item.get("authors", "")
@dataclass(slots=True)
class DummyAuditui:
"""Narrow object compatible with Auditui search-cache helper calls."""
_search_text_cache: dict[int, str] = field(default_factory=dict)
library_client: StubLibrary = field(default_factory=StubLibrary)
def test_get_search_text_is_cached() -> None:
"""Ensure repeated text extraction for one item reuses cache entries."""
item = {"title": "Title", "authors": "Author"}
dummy = DummyAuditui()
first = Auditui._get_search_text(cast(Auditui, dummy), item)
second = Auditui._get_search_text(cast(Auditui, dummy), item)
assert first == "title author"
assert first == second
assert len(dummy._search_text_cache) == 1
def test_filter_items_uses_cached_callable() -> None:
"""Ensure filter_items cooperates with a memoized search text callback."""
library = StubLibrary()
cache: dict[int, str] = {}
items = [
{"title": "Alpha", "authors": "Author One"},
{"title": "Beta", "authors": "Author Two"},
]
def cached(item: dict) -> str:
"""Build and cache normalized search text per object identity."""
cache_key = id(item)
if cache_key not in cache:
cache[cache_key] = build_search_text(item, cast(Any, library))
return cache[cache_key]
result = filter_items(items, "beta", cached)
assert result == [items[1]]
def test_build_search_text_without_library_client() -> None:
"""Ensure fallback search text path handles inline author dicts."""
item = {"title": "Title", "authors": [{"name": "A"}, {"name": "B"}]}
assert build_search_text(item, None) == "title a, b"

View File

@@ -0,0 +1,78 @@
from __future__ import annotations
from auditui.app import state as state_mod
class DummyApp:
"""Lightweight app object for state initialization tests."""
def __init__(self) -> None:
"""Expose update_status to satisfy init dependencies."""
self.messages: list[str] = []
def update_status(self, message: str) -> None:
"""Collect status updates for assertions."""
self.messages.append(message)
def test_init_state_without_auth_or_client(monkeypatch) -> None:
"""Ensure baseline state is initialized when no auth/client is provided."""
app = DummyApp()
playback_args: list[tuple[object, object]] = []
class FakePlayback:
"""Playback constructor recorder for init tests."""
def __init__(self, notify, library_client) -> None:
"""Capture arguments passed by init_auditui_state."""
playback_args.append((notify, library_client))
monkeypatch.setattr(state_mod, "PlaybackController", FakePlayback)
state_mod.init_auditui_state(app)
assert app.library_client is None
assert app.download_manager is None
assert app.all_items == []
assert app.current_items == []
assert app.filter_text == ""
assert app.show_all_mode is False
assert playback_args and playback_args[0][1] is None
def test_init_state_with_auth_and_client_builds_dependencies(monkeypatch) -> None:
"""Ensure init constructs library, downloads, and playback dependencies."""
app = DummyApp()
auth = object()
client = object()
class FakeLibraryClient:
"""Fake library client constructor for dependency wiring checks."""
def __init__(self, value) -> None:
"""Store constructor argument for assertions."""
self.value = value
class FakeDownloadManager:
"""Fake download manager constructor for dependency wiring checks."""
def __init__(self, auth_value, client_value) -> None:
"""Store constructor arguments for assertions."""
self.args = (auth_value, client_value)
class FakePlayback:
"""Fake playback constructor for dependency wiring checks."""
def __init__(self, notify, library_client) -> None:
"""Store constructor arguments for assertions."""
self.notify = notify
self.library_client = library_client
monkeypatch.setattr(state_mod, "LibraryClient", FakeLibraryClient)
monkeypatch.setattr(state_mod, "DownloadManager", FakeDownloadManager)
monkeypatch.setattr(state_mod, "PlaybackController", FakePlayback)
state_mod.init_auditui_state(app, auth=auth, client=client)
assert isinstance(app.library_client, FakeLibraryClient)
assert isinstance(app.download_manager, FakeDownloadManager)
assert isinstance(app.playback, FakePlayback)
assert app.library_client.value is client
assert app.download_manager.args == (auth, client)
assert app.playback.library_client.value is client

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from auditui.app.table import AppTableMixin
class DummyTableApp(AppTableMixin):
"""Minimal host exposing library client for row key helper tests."""
def __init__(self) -> None:
"""Initialize a fake library client with ASIN extraction."""
self.library_client = type(
"Library",
(),
{"extract_asin": lambda self, item: item.get("asin")},
)()
def test_build_row_key_prefers_asin_and_remains_unique() -> None:
"""Ensure duplicate ASINs receive deterministic unique key suffixes."""
app = DummyTableApp()
used: set[str] = set()
item = {"asin": "ASIN1"}
first = app._build_row_key(item, "Title", 0, used)
second = app._build_row_key(item, "Title", 1, used)
assert first == "ASIN1"
assert second == "ASIN1#2"
def test_build_row_key_falls_back_to_title_and_index() -> None:
"""Ensure missing ASIN values use title-index fallback keys."""
app = DummyTableApp()
used: set[str] = set()
key = app._build_row_key({"asin": None}, "Unknown Title", 3, used)
assert key == "Unknown Title#3"

41
tests/conftest.py Normal file
View File

@@ -0,0 +1,41 @@
from __future__ import annotations
import sys
from pathlib import Path
from types import ModuleType
from typing import Any, cast
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
try:
import audible # noqa: F401
except ModuleNotFoundError:
audible_stub = ModuleType("audible")
class Authenticator:
"""Minimal audible authenticator test stub."""
pass
class Client:
"""Minimal audible client test stub."""
pass
setattr(cast(Any, audible_stub), "Authenticator", Authenticator)
setattr(cast(Any, audible_stub), "Client", Client)
activation_bytes = ModuleType("audible.activation_bytes")
def get_activation_bytes(_auth: Authenticator | None = None) -> bytes:
"""Return deterministic empty activation bytes for tests."""
return b""
setattr(cast(Any, activation_bytes), "get_activation_bytes", get_activation_bytes)
sys.modules["audible"] = audible_stub
sys.modules["audible.activation_bytes"] = activation_bytes

View File

@@ -0,0 +1,91 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, cast
import pytest
from auditui.constants import MIN_FILE_SIZE
from auditui.downloads import DownloadManager
def _manager_with_cache_dir(tmp_path: Path) -> DownloadManager:
"""Build a lightweight DownloadManager instance without real HTTP clients."""
manager = DownloadManager.__new__(DownloadManager)
manager.cache_dir = tmp_path
manager.chunk_size = 1024
return manager
def test_sanitize_filename_replaces_invalid_characters() -> None:
"""Ensure filename normalization uses ASCII words and dashes."""
manager = DownloadManager.__new__(DownloadManager)
assert (
manager._sanitize_filename("Stephen King 11/22/63") == "Stephen-King-11-22-63"
)
def test_validate_download_url_accepts_only_http_schemes() -> None:
"""Ensure download URL validation only accepts HTTP and HTTPS links."""
manager = DownloadManager.__new__(DownloadManager)
assert manager._validate_download_url("https://example.com/file") is True
assert manager._validate_download_url("http://example.com/file") is True
assert manager._validate_download_url("ftp://example.com/file") is False
def test_get_cached_path_and_remove_cached(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure cache lookup and cache deletion work for valid files."""
manager = _manager_with_cache_dir(tmp_path)
monkeypatch.setattr(
manager,
"_get_filename_stems_from_asin",
lambda asin: ["Stephen-King_11-22-63", "11-22-63"],
)
cached_path = tmp_path / "Stephen-King_11-22-63.aax"
cached_path.write_bytes(b"0" * MIN_FILE_SIZE)
messages: list[str] = []
assert manager.get_cached_path("ASIN123") == cached_path
assert manager.is_cached("ASIN123") is True
assert manager.remove_cached("ASIN123", notify=messages.append) is True
assert not cached_path.exists()
assert "Removed from cache" in messages[-1]
def test_get_cached_path_ignores_small_files(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure undersized files are not treated as valid cache entries."""
manager = _manager_with_cache_dir(tmp_path)
monkeypatch.setattr(
manager,
"_get_filename_stems_from_asin",
lambda asin: ["Stephen-King_11-22-63", "11-22-63"],
)
cached_path = tmp_path / "Stephen-King_11-22-63.aax"
cached_path.write_bytes(b"0" * (MIN_FILE_SIZE - 1))
assert manager.get_cached_path("ASIN123") is None
def test_get_filename_stems_include_author_title_and_legacy_title() -> None:
"""Ensure filename candidates include new author_title and legacy title names."""
manager = DownloadManager.__new__(DownloadManager)
manager.client = cast(
Any,
type(
"Client",
(),
{
"get": lambda self, path, **kwargs: {
"product": {
"title": "11/22/63",
"authors": [{"name": "Stephen King"}],
}
}
},
)(),
)
stems = manager._get_filename_stems_from_asin("B00TEST")
assert stems[0] == "Stephen-King_11-22-63"
assert "11-22-63" in stems

View File

@@ -0,0 +1,160 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, cast
import pytest
from auditui.constants import MIN_FILE_SIZE
from auditui.downloads import DownloadManager
from auditui.downloads import manager as manager_mod
def _bare_manager(tmp_path: Path) -> DownloadManager:
"""Create manager without invoking constructor side effects."""
manager = DownloadManager.__new__(DownloadManager)
manager.cache_dir = tmp_path
manager.chunk_size = 1024
manager.auth = cast(
Any,
type(
"Auth",
(),
{"adp_token": "x", "locale": type("Loc", (), {"domain": "fr"})()},
)(),
)
return manager
def test_get_activation_bytes_returns_hex(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Ensure activation bytes are converted to lowercase hex string."""
manager = _bare_manager(tmp_path)
monkeypatch.setattr(manager_mod, "get_activation_bytes", lambda _auth: b"\xde\xad")
assert manager.get_activation_bytes() == "dead"
def test_get_activation_bytes_handles_errors(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Ensure activation retrieval failures are handled gracefully."""
manager = _bare_manager(tmp_path)
def _boom(_auth: object) -> bytes:
"""Raise a deterministic failure for exception-path coverage."""
raise OSError("no auth")
monkeypatch.setattr(manager_mod, "get_activation_bytes", _boom)
assert manager.get_activation_bytes() is None
def test_get_or_download_uses_cached_file_when_available(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure cached files bypass link generation and download work."""
manager = _bare_manager(tmp_path)
monkeypatch.setattr(
manager,
"_get_filename_stems_from_asin",
lambda asin, preferred_title=None, preferred_author=None: ["Author_Book"],
)
cached_path = tmp_path / "Author_Book.aax"
cached_path.write_bytes(b"1" * MIN_FILE_SIZE)
messages: list[str] = []
assert manager.get_or_download("ASIN", notify=messages.append) == cached_path
assert "Using cached file" in messages[0]
def test_get_or_download_reports_invalid_url(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure workflow reports invalid download URLs and aborts."""
manager = _bare_manager(tmp_path)
monkeypatch.setattr(
manager,
"_get_filename_stems_from_asin",
lambda asin, preferred_title=None, preferred_author=None: ["Author_Book"],
)
monkeypatch.setattr(
manager, "_get_download_link", lambda asin, notify=None: "ftp://bad"
)
messages: list[str] = []
assert manager.get_or_download("ASIN", notify=messages.append) is None
assert "Invalid download URL" in messages
def test_get_or_download_handles_download_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure workflow reports failures when stream download does not complete."""
manager = _bare_manager(tmp_path)
monkeypatch.setattr(
manager,
"_get_filename_stems_from_asin",
lambda asin, preferred_title=None, preferred_author=None: ["Author_Book"],
)
monkeypatch.setattr(
manager, "_get_download_link", lambda asin, notify=None: "https://ok"
)
monkeypatch.setattr(manager, "_download_file", lambda url, path, notify=None: None)
messages: list[str] = []
assert manager.get_or_download("ASIN", notify=messages.append) is None
assert "Download failed" in messages
def test_get_or_download_uses_preferred_naming_hints(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure preferred title/author are forwarded to filename stem selection."""
manager = _bare_manager(tmp_path)
captured: list[tuple[str | None, str | None]] = []
def stems(
asin: str,
preferred_title: str | None = None,
preferred_author: str | None = None,
) -> list[str]:
"""Capture naming hints and return one deterministic filename stem."""
del asin
captured.append((preferred_title, preferred_author))
return ["Author_Book"]
monkeypatch.setattr(manager, "_get_filename_stems_from_asin", stems)
monkeypatch.setattr(manager, "_get_download_link", lambda asin, notify=None: None)
manager.get_or_download(
"ASIN",
preferred_title="11/22/63",
preferred_author="Stephen King",
)
assert captured == [("11/22/63", "Stephen King")]
def test_get_or_download_retries_when_file_is_too_small(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Ensure small downloads are retried and then reported with exact byte size."""
manager = _bare_manager(tmp_path)
monkeypatch.setattr(
manager,
"_get_filename_stems_from_asin",
lambda asin, preferred_title=None, preferred_author=None: ["Author_Book"],
)
monkeypatch.setattr(
manager, "_get_download_link", lambda asin, notify=None: "https://ok"
)
attempts = {"count": 0}
def write_small_file(url: str, path: Path, notify=None) -> Path:
"""Write an undersized file to trigger retry and final failure messages."""
del url, notify
attempts["count"] += 1
path.write_bytes(b"x" * 100)
return path
monkeypatch.setattr(manager, "_download_file", write_small_file)
messages: list[str] = []
assert manager.get_or_download("ASIN", notify=messages.append) is None
assert attempts["count"] == 2
assert any("retrying" in message for message in messages)
assert any("file too small" in message for message in messages)

View File

@@ -0,0 +1,111 @@
from __future__ import annotations
from dataclasses import dataclass, field
from auditui.library import LibraryClient
@dataclass(slots=True)
class MockClient:
"""Client double that records writes and serves configurable responses."""
put_calls: list[tuple[str, dict]] = field(default_factory=list)
post_calls: list[tuple[str, dict]] = field(default_factory=list)
_post_response: dict = field(default_factory=dict)
raise_on_put: bool = False
def put(self, path: str, body: dict) -> dict:
"""Record put payload or raise when configured."""
if self.raise_on_put:
raise RuntimeError("put failed")
self.put_calls.append((path, body))
return {}
def post(self, path: str, body: dict) -> dict:
"""Record post payload and return configured response."""
self.post_calls.append((path, body))
return self._post_response
def get(self, path: str, **kwargs: dict) -> dict:
"""Return empty data for extractor-focused tests."""
del path, kwargs
return {}
def build_item(
*,
title: str | None = None,
product_title: str | None = None,
authors: list[dict] | None = None,
runtime_min: int | None = None,
listening_status: dict | None = None,
percent_complete: int | float | None = None,
asin: str | None = None,
) -> dict:
"""Construct synthetic library items for extractor and finish tests."""
item: dict = {}
if title is not None:
item["title"] = title
if percent_complete is not None:
item["percent_complete"] = percent_complete
if listening_status is not None:
item["listening_status"] = listening_status
if asin is not None:
item["asin"] = asin
product: dict = {}
if product_title is not None:
product["title"] = product_title
if runtime_min is not None:
product["runtime_length"] = {"min": runtime_min}
if authors is not None:
product["authors"] = authors
if asin is not None:
product["asin"] = asin
if product:
item["product"] = product
if runtime_min is not None:
item["runtime_length_min"] = runtime_min
return item
def test_extract_title_prefers_product_title() -> None:
"""Ensure product title has precedence over outer item title."""
library = LibraryClient(MockClient()) # type: ignore[arg-type]
assert (
library.extract_title(build_item(title="Outer", product_title="Inner"))
== "Inner"
)
def test_extract_title_falls_back_to_asin() -> None:
"""Ensure title fallback uses product ASIN when no title exists."""
library = LibraryClient(MockClient()) # type: ignore[arg-type]
assert library.extract_title({"product": {"asin": "A1"}}) == "A1"
def test_extract_authors_joins_names() -> None:
"""Ensure author dictionaries are converted to a readable list."""
library = LibraryClient(MockClient()) # type: ignore[arg-type]
item = build_item(authors=[{"name": "A"}, {"name": "B"}])
assert library.extract_authors(item) == "A, B"
def test_extract_runtime_minutes_handles_dict_and_number() -> None:
"""Ensure runtime extraction supports dict and numeric payloads."""
library = LibraryClient(MockClient()) # type: ignore[arg-type]
assert library.extract_runtime_minutes(build_item(runtime_min=12)) == 12
assert library.extract_runtime_minutes({"runtime_length": 42}) == 42
def test_extract_progress_info_prefers_listening_status_when_needed() -> None:
"""Ensure progress can be sourced from listening_status when top-level is absent."""
library = LibraryClient(MockClient()) # type: ignore[arg-type]
item = build_item(listening_status={"percent_complete": 25.0})
assert library.extract_progress_info(item) == 25.0
def test_extract_asin_prefers_item_then_product() -> None:
"""Ensure ASIN extraction works from both item and product fields."""
library = LibraryClient(MockClient()) # type: ignore[arg-type]
assert library.extract_asin(build_item(asin="ASIN1")) == "ASIN1"
assert library.extract_asin({"product": {"asin": "ASIN2"}}) == "ASIN2"

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
from dataclasses import dataclass, field
from auditui.library import LibraryClient
@dataclass(slots=True)
class ProgressClient:
"""Client double for position and finished-state API methods."""
get_responses: dict[str, dict] = field(default_factory=dict)
put_calls: list[tuple[str, dict]] = field(default_factory=list)
post_response: dict = field(default_factory=dict)
fail_put: bool = False
def get(self, path: str, **kwargs: object) -> dict:
"""Return preconfigured payloads by API path."""
del kwargs
return self.get_responses.get(path, {})
def put(self, path: str, body: dict) -> dict:
"""Record payloads or raise to exercise error handling."""
if self.fail_put:
raise OSError("write failed")
self.put_calls.append((path, body))
return {}
def post(self, path: str, body: dict) -> dict:
"""Return licenserequest response for ACR extraction."""
del path, body
return self.post_response
def test_is_finished_true_from_percent_complete() -> None:
"""Ensure 100 percent completion is treated as finished."""
library = LibraryClient(ProgressClient()) # type: ignore[arg-type]
assert library.is_finished({"percent_complete": 100}) is True
def test_get_last_position_reads_matching_annotation() -> None:
"""Ensure last position is read in seconds from matching annotation."""
client = ProgressClient(
get_responses={
"1.0/annotations/lastpositions": {
"asin_last_position_heard_annots": [
{"asin": "X", "last_position_heard": {"position_ms": 9000}}
]
}
}
)
library = LibraryClient(client) # type: ignore[arg-type]
assert library.get_last_position("X") == 9.0
def test_get_last_position_returns_none_for_missing_state() -> None:
"""Ensure DoesNotExist status is surfaced as no saved position."""
client = ProgressClient(
get_responses={
"1.0/annotations/lastpositions": {
"asin_last_position_heard_annots": [
{"asin": "X", "last_position_heard": {"status": "DoesNotExist"}}
]
}
}
)
library = LibraryClient(client) # type: ignore[arg-type]
assert library.get_last_position("X") is None
def test_save_last_position_validates_non_positive_values() -> None:
"""Ensure save_last_position short-circuits on non-positive input."""
library = LibraryClient(ProgressClient()) # type: ignore[arg-type]
assert library.save_last_position("A", 0) is False
def test_update_position_writes_version_when_available() -> None:
"""Ensure version is included in payload when metadata provides it."""
client = ProgressClient(
get_responses={
"1.0/content/A/metadata": {
"content_metadata": {
"content_reference": {"acr": "token", "version": "2"}
}
}
}
)
library = LibraryClient(client) # type: ignore[arg-type]
assert library._update_position("A", 5.5) is True
path, body = client.put_calls[0]
assert path == "1.0/lastpositions/A"
assert body["position_ms"] == 5500
assert body["version"] == "2"
def test_mark_as_finished_updates_item_in_place() -> None:
"""Ensure successful finish update mutates local item flags."""
client = ProgressClient(post_response={"content_license": {"acr": "token"}})
library = LibraryClient(client) # type: ignore[arg-type]
item = {"runtime_length_min": 1, "listening_status": {}}
assert library.mark_as_finished("ASIN", item) is True
assert item["is_finished"] is True
assert item["listening_status"]["is_finished"] is True

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from auditui.library import build_search_text, filter_items
class SearchLibrary:
"""Simple search extraction adapter for build_search_text tests."""
def extract_title(self, item: dict) -> str:
"""Return a title value from a synthetic item."""
return item.get("t", "")
def extract_authors(self, item: dict) -> str:
"""Return an author value from a synthetic item."""
return item.get("a", "")
def test_build_search_text_uses_library_client_when_present() -> None:
"""Ensure search text delegates to library extractor methods."""
item = {"t": "The Book", "a": "The Author"}
assert build_search_text(item, SearchLibrary()) == "the book the author"
def test_filter_items_returns_input_when_filter_empty() -> None:
"""Ensure empty filter bypasses per-item search callback evaluation."""
items = [{"k": 1}, {"k": 2}]
assert filter_items(items, "", lambda _item: "ignored") == items
def test_filter_items_matches_case_insensitively() -> None:
"""Ensure search matching is case-insensitive across computed text."""
items = [{"name": "Alpha"}, {"name": "Beta"}]
result = filter_items(items, "BETA", lambda item: item["name"].lower())
assert result == [items[1]]

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, cast
from auditui.constants import AUTHOR_NAME_MAX_LENGTH
from auditui.library import (
create_progress_sort_key,
create_title_sort_key,
filter_unfinished_items,
format_item_as_row,
truncate_author_name,
)
class StubLibrary:
"""Library facade exposing only helpers needed by table formatting code."""
def extract_title(self, item: dict) -> str:
"""Return synthetic title value."""
return item.get("title", "")
def extract_authors(self, item: dict) -> str:
"""Return synthetic authors value."""
return item.get("authors", "")
def extract_runtime_minutes(self, item: dict) -> int | None:
"""Return synthetic minute duration."""
return item.get("minutes")
def format_duration(
self, value: int | None, unit: str = "minutes", default_none: str | None = None
) -> str | None:
"""Render runtime in compact minute format for tests."""
del unit
return default_none if value is None else f"{value}m"
def extract_progress_info(self, item: dict) -> float | None:
"""Return synthetic progress percentage value."""
return item.get("percent")
def extract_asin(self, item: dict) -> str | None:
"""Return synthetic ASIN value."""
return item.get("asin")
def is_finished(self, item: dict) -> bool:
"""Return synthetic finished flag from the item."""
return bool(item.get("finished"))
@dataclass(slots=True)
class StubDownloads:
"""Download cache adapter exposing just is_cached."""
cached: set[str]
def is_cached(self, asin: str) -> bool:
"""Return whether an ASIN is cached."""
return asin in self.cached
def test_create_title_sort_key_normalizes_accents() -> None:
"""Ensure title sorting removes accents before case-fold compare."""
key_fn, _ = create_title_sort_key()
assert key_fn(["Ecole"]) == key_fn(["École"])
def test_create_progress_sort_key_parses_percent_strings() -> None:
"""Ensure progress sorting converts percentages and handles invalid values."""
key_fn, _ = create_progress_sort_key()
assert key_fn(["0", "0", "0", "42.5%", ""]) == 42.5
assert key_fn(["0", "0", "0", "bad", ""]) == 0.0
def test_truncate_author_name_clamps_long_values() -> None:
"""Ensure very long author strings are shortened with ellipsis."""
long_name = "A" * (AUTHOR_NAME_MAX_LENGTH + 5)
out = truncate_author_name(long_name)
assert out.endswith("...")
assert len(out) <= AUTHOR_NAME_MAX_LENGTH
def test_format_item_as_row_marks_downloaded_titles() -> None:
"""Ensure downloaded ASINs are shown with a checkmark in table rows."""
item = {
"title": "Title",
"authors": "Author",
"minutes": 90,
"percent": 12.34,
"asin": "A1",
}
row = format_item_as_row(item, StubLibrary(), cast(Any, StubDownloads({"A1"})))
assert row == ("Title", "Author", "90m", "12.3%", "")
def test_filter_unfinished_items_keeps_only_incomplete() -> None:
"""Ensure unfinished filter excludes items marked as finished."""
items = [{"id": 1, "finished": False}, {"id": 2, "finished": True}]
assert filter_unfinished_items(items, StubLibrary()) == [items[0]]

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from auditui.playback.chapters import get_current_chapter, get_current_chapter_index
CHAPTERS = [
{"title": "One", "start_time": 0.0, "end_time": 60.0},
{"title": "Two", "start_time": 60.0, "end_time": 120.0},
]
def test_get_current_chapter_handles_empty_chapter_list() -> None:
"""Ensure empty chapter metadata still returns a sensible fallback row."""
assert get_current_chapter(12.0, [], 300.0) == ("Unknown Chapter", 12.0, 300.0)
def test_get_current_chapter_returns_matching_chapter_window() -> None:
"""Ensure chapter selection returns title and chapter-relative timing."""
assert get_current_chapter(75.0, CHAPTERS, 120.0) == ("Two", 15.0, 60.0)
def test_get_current_chapter_falls_back_to_last_chapter() -> None:
"""Ensure elapsed values past known ranges map to last chapter."""
assert get_current_chapter(150.0, CHAPTERS, 200.0) == ("Two", 90.0, 60.0)
def test_get_current_chapter_index_returns_none_without_chapters() -> None:
"""Ensure chapter index lookup returns None when no chapters exist."""
assert get_current_chapter_index(10.0, []) is None
def test_get_current_chapter_index_returns_last_when_past_end() -> None:
"""Ensure chapter index lookup falls back to the final chapter index."""
assert get_current_chapter_index(200.0, CHAPTERS) == 1

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, cast
from auditui.playback import controller_lifecycle as lifecycle_mod
from auditui.playback.controller import PlaybackController
class Proc:
"""Process shim used for lifecycle tests."""
def __init__(self, poll_value=None) -> None:
"""Set initial poll result."""
self._poll_value = poll_value
def poll(self):
"""Return process running status."""
return self._poll_value
def _controller() -> tuple[PlaybackController, list[str]]:
"""Build controller and message capture list."""
messages: list[str] = []
return PlaybackController(messages.append, None), messages
def test_start_reports_missing_ffplay(monkeypatch) -> None:
"""Ensure start fails fast when ffplay is unavailable."""
controller, messages = _controller()
monkeypatch.setattr(lifecycle_mod.process_mod, "is_ffplay_available", lambda: False)
assert controller.start(Path("book.aax")) is False
assert messages[-1] == "ffplay not found. Please install ffmpeg"
def test_start_sets_state_on_success(monkeypatch) -> None:
"""Ensure successful start initializes playback state and metadata."""
controller, messages = _controller()
monkeypatch.setattr(lifecycle_mod.process_mod, "is_ffplay_available", lambda: True)
monkeypatch.setattr(
lifecycle_mod.process_mod, "build_ffplay_cmd", lambda *args: ["ffplay"]
)
monkeypatch.setattr(
lifecycle_mod.process_mod, "run_ffplay", lambda cmd: (Proc(None), None)
)
monkeypatch.setattr(
lifecycle_mod,
"load_media_info",
lambda path, activation: (600.0, [{"title": "ch"}]),
)
monkeypatch.setattr(lifecycle_mod.time, "time", lambda: 100.0)
ok = controller.start(
Path("book.aax"), activation_hex="abcd", start_position=10.0, speed=1.2
)
assert ok is True
assert controller.is_playing is True
assert controller.current_file_path == Path("book.aax")
assert controller.total_duration == 600.0
assert messages[-1] == "Playing: book.aax"
def test_prepare_and_start_uses_last_position(monkeypatch) -> None:
"""Ensure prepare flow resumes from saved position when available."""
messages: list[str] = []
lib = type("Lib", (), {"get_last_position": lambda self, asin: 75.0})()
controller = PlaybackController(messages.append, cast(Any, lib))
started: list[tuple] = []
class DM:
"""Download manager shim returning path and activation token."""
def get_or_download(
self,
asin,
notify,
preferred_title: str | None = None,
preferred_author: str | None = None,
):
"""Return deterministic downloaded file path."""
del asin, notify, preferred_title, preferred_author
return Path("book.aax")
def get_activation_bytes(self):
"""Return deterministic activation token."""
return "abcd"
monkeypatch.setattr(controller, "start", lambda *args: started.append(args) or True)
monkeypatch.setattr(lifecycle_mod.time, "time", lambda: 200.0)
assert controller.prepare_and_start(cast(Any, DM()), "ASIN") is True
assert started and started[0][3] == 75.0
assert "Resuming from 01:15" in messages
def test_toggle_playback_uses_pause_and_resume_paths(monkeypatch) -> None:
"""Ensure toggle dispatches pause or resume based on paused flag."""
controller, _ = _controller()
controller.is_playing = True
controller.playback_process = cast(Any, Proc(None))
called: list[str] = []
monkeypatch.setattr(controller, "pause", lambda: called.append("pause"))
monkeypatch.setattr(controller, "resume", lambda: called.append("resume"))
controller.is_paused = False
assert controller.toggle_playback() is True
controller.is_paused = True
assert controller.toggle_playback() is True
assert called == ["pause", "resume"]
def test_restart_at_position_restores_state_and_notifies(monkeypatch) -> None:
"""Ensure restart logic preserves metadata and emits custom message."""
controller, messages = _controller()
controller.is_playing = True
controller.is_paused = True
controller.current_file_path = Path("book.aax")
controller.current_asin = "ASIN"
controller.activation_hex = "abcd"
controller.total_duration = 400.0
controller.chapters = [{"title": "One"}]
controller.playback_speed = 1.0
monkeypatch.setattr(controller, "_stop_process", lambda: None)
monkeypatch.setattr(lifecycle_mod.time, "sleep", lambda _s: None)
monkeypatch.setattr(controller, "start", lambda *args: True)
paused: list[str] = []
monkeypatch.setattr(controller, "pause", lambda: paused.append("pause"))
assert controller._restart_at_position(120.0, message="Jumped") is True
assert controller.current_asin == "ASIN"
assert controller.chapters == [{"title": "One"}]
assert paused == ["pause"]
assert messages[-1] == "Jumped"

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from auditui.playback import controller_seek_speed as seek_speed_mod
from auditui.playback.controller import PlaybackController
def _controller() -> tuple[PlaybackController, list[str]]:
"""Build controller and in-memory notification sink."""
messages: list[str] = []
return PlaybackController(messages.append, None), messages
def test_seek_notifies_when_target_invalid(monkeypatch) -> None:
"""Ensure seek reports end-of-file condition when target is invalid."""
controller, messages = _controller()
monkeypatch.setattr(controller, "_get_current_elapsed", lambda: 20.0)
controller.seek_offset = 100.0
controller.total_duration = 120.0
monkeypatch.setattr(
seek_speed_mod.seek_mod, "compute_seek_target", lambda *args: None
)
assert controller._seek(30.0, "forward") is False
assert messages[-1] == "Already at end of file"
def test_seek_to_chapter_reports_bounds(monkeypatch) -> None:
"""Ensure chapter seek reports first and last chapter boundaries."""
controller, messages = _controller()
controller.is_playing = True
controller.current_file_path = object()
controller.chapters = [{"title": "One", "start_time": 0.0, "end_time": 10.0}]
monkeypatch.setattr(controller, "_get_current_elapsed", lambda: 1.0)
monkeypatch.setattr(
seek_speed_mod.chapters_mod,
"get_current_chapter_index",
lambda elapsed, chapters: 0,
)
assert controller.seek_to_chapter("next") is False
assert messages[-1] == "Already at last chapter"
assert controller.seek_to_chapter("previous") is False
assert messages[-1] == "Already at first chapter"
def test_save_current_position_writes_positive_values() -> None:
"""Ensure save_current_position persists elapsed time via library client."""
calls: list[tuple[str, float]] = []
library = type(
"Library",
(),
{"save_last_position": lambda self, asin, pos: calls.append((asin, pos))},
)()
controller = PlaybackController(lambda _msg: None, library)
controller.current_asin = "ASIN"
controller.is_playing = True
controller.playback_start_time = 1.0
controller.seek_offset = 10.0
controller._get_current_elapsed = lambda: 15.0
controller._save_current_position()
assert calls == [("ASIN", 25.0)]
def test_update_position_if_needed_honors_interval(monkeypatch) -> None:
"""Ensure periodic save runs only when interval has elapsed."""
controller, _ = _controller()
controller.is_playing = True
controller.current_asin = "ASIN"
controller.library_client = object()
controller.last_save_time = 10.0
controller.position_save_interval = 30.0
saves: list[str] = []
monkeypatch.setattr(
controller, "_save_current_position", lambda: saves.append("save")
)
monkeypatch.setattr(seek_speed_mod.time, "time", lambda: 20.0)
controller.update_position_if_needed()
monkeypatch.setattr(seek_speed_mod.time, "time", lambda: 45.0)
controller.update_position_if_needed()
assert saves == ["save"]
def test_change_speed_restarts_with_new_rate(monkeypatch) -> None:
"""Ensure speed changes restart playback at current position."""
controller, _ = _controller()
controller.playback_speed = 1.0
controller.seek_offset = 5.0
monkeypatch.setattr(controller, "_get_current_elapsed", lambda: 10.0)
seen: list[tuple[float, float, str]] = []
def fake_restart(
position: float, speed: float | None = None, message: str | None = None
) -> bool:
"""Capture restart call parameters."""
seen.append((position, speed or 0.0, message or ""))
return True
monkeypatch.setattr(controller, "_restart_at_position", fake_restart)
assert controller.increase_speed() is True
assert seen and seen[0][0] == 15.0
assert seen[0][1] > 1.0
assert seen[0][2].startswith("Speed: ")

View File

@@ -0,0 +1,76 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, cast
from auditui.playback import controller_state as state_mod
from auditui.playback.controller import PlaybackController
class Proc:
"""Simple process shim exposing poll and pid for state tests."""
def __init__(self, poll_value=None) -> None:
"""Store poll return value and fake pid."""
self._poll_value = poll_value
self.pid = 123
def poll(self):
"""Return configured process status code or None."""
return self._poll_value
def _controller() -> tuple[PlaybackController, list[str]]:
"""Build playback controller and collected notifications list."""
messages: list[str] = []
return PlaybackController(messages.append, None), messages
def test_get_current_elapsed_rolls_pause_into_duration(monkeypatch) -> None:
"""Ensure elapsed helper absorbs stale pause_start_time when resumed."""
controller, _ = _controller()
controller.pause_start_time = 100.0
controller.is_paused = False
monkeypatch.setattr(state_mod.time, "time", lambda: 120.0)
monkeypatch.setattr(state_mod.elapsed_mod, "get_elapsed", lambda *args: 50.0)
assert controller._get_current_elapsed() == 50.0
assert controller.paused_duration == 20.0
assert controller.pause_start_time is None
def test_validate_playback_state_stops_when_process_ended() -> None:
"""Ensure state validation stops and reports when process is gone."""
controller, messages = _controller()
controller.playback_process = cast(Any, Proc(poll_value=1))
controller.is_playing = True
controller.current_file_path = Path("book.aax")
ok = controller._validate_playback_state(require_paused=False)
assert ok is False
assert messages[-1] == "Playback process has ended"
def test_send_signal_sets_paused_state_and_notifies(monkeypatch) -> None:
"""Ensure SIGSTOP updates paused state and includes filename in status."""
controller, messages = _controller()
controller.playback_process = cast(Any, Proc())
controller.current_file_path = Path("book.aax")
monkeypatch.setattr(state_mod.process_mod, "send_signal", lambda proc, sig: None)
controller._send_signal(state_mod.signal.SIGSTOP, "Paused", "pause")
assert controller.is_paused is True
assert messages[-1] == "Paused: book.aax"
def test_send_signal_handles_process_lookup(monkeypatch) -> None:
"""Ensure missing process lookup errors are handled with user-facing message."""
controller, messages = _controller()
controller.playback_process = cast(Any, Proc())
def raise_lookup(proc, sig):
"""Raise process lookup error to exercise exception path."""
del proc, sig
raise ProcessLookupError("gone")
monkeypatch.setattr(state_mod.process_mod, "send_signal", raise_lookup)
monkeypatch.setattr(state_mod.process_mod, "terminate_process", lambda proc: None)
controller._send_signal(state_mod.signal.SIGCONT, "Playing", "resume")
assert messages[-1] == "Process no longer exists"

View File

@@ -0,0 +1,21 @@
from __future__ import annotations
from auditui.playback.elapsed import get_elapsed
from auditui.playback import elapsed as elapsed_mod
def test_get_elapsed_returns_zero_without_start_time() -> None:
"""Ensure elapsed computation returns zero when playback has not started."""
assert get_elapsed(None, None, 0.0, False) == 0.0
def test_get_elapsed_while_paused_uses_pause_start(monkeypatch) -> None:
"""Ensure paused elapsed is fixed at pause_start minus previous pauses."""
monkeypatch.setattr(elapsed_mod.time, "time", lambda: 500.0)
assert get_elapsed(100.0, 250.0, 20.0, True) == 130.0
def test_get_elapsed_subtracts_pause_duration_when_resumed(monkeypatch) -> None:
"""Ensure resumed elapsed removes newly accumulated paused duration."""
monkeypatch.setattr(elapsed_mod.time, "time", lambda: 400.0)
assert get_elapsed(100.0, 300.0, 10.0, False) == 190.0

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from auditui.playback import process as process_mod
class DummyProc:
"""Minimal subprocess-like object for terminate_process tests."""
def __init__(self, alive: bool = True) -> None:
"""Initialize process state and bookkeeping flags."""
self._alive = alive
self.terminated = False
self.killed = False
self.pid = 123
def poll(self) -> int | None:
"""Return None while process is alive and 0 when stopped."""
return None if self._alive else 0
def terminate(self) -> None:
"""Mark process as terminated and no longer alive."""
self.terminated = True
self._alive = False
def wait(self, timeout: float | None = None) -> int:
"""Return immediately to emulate a cooperative shutdown."""
del timeout
return 0
def kill(self) -> None:
"""Mark process as killed and no longer alive."""
self.killed = True
self._alive = False
def test_build_ffplay_cmd_includes_activation_seek_and_speed() -> None:
"""Ensure ffplay command includes optional playback arguments when set."""
cmd = process_mod.build_ffplay_cmd(Path("book.aax"), "abcd", 12.5, 1.2)
assert "-activation_bytes" in cmd
assert "-ss" in cmd
assert "atempo=1.20" in " ".join(cmd)
def test_terminate_process_handles_alive_process() -> None:
"""Ensure terminate_process gracefully shuts down a running process."""
proc = DummyProc(alive=True)
process_mod.terminate_process(proc) # type: ignore[arg-type]
assert proc.terminated is True
def test_run_ffplay_returns_none_when_unavailable(monkeypatch) -> None:
"""Ensure ffplay launch exits early when binary is not on PATH."""
monkeypatch.setattr(process_mod, "is_ffplay_available", lambda: False)
assert process_mod.run_ffplay(["ffplay", "book.aax"]) == (None, None)
def test_send_signal_delegates_to_os_kill(monkeypatch) -> None:
"""Ensure send_signal forwards process PID and signal to os.kill."""
seen: list[tuple[int, object]] = []
monkeypatch.setattr(
process_mod.os, "kill", lambda pid, sig: seen.append((pid, sig))
)
process_mod.send_signal(DummyProc(), process_mod.signal.SIGSTOP) # type: ignore[arg-type]
assert seen and seen[0][0] == 123

View File

@@ -0,0 +1,20 @@
from __future__ import annotations
from auditui.playback.seek import compute_seek_target
def test_forward_seek_returns_new_position_and_message() -> None:
"""Ensure forward seek computes expected position and status message."""
target = compute_seek_target(10.0, 100.0, 30.0, "forward")
assert target == (40.0, "Skipped forward 30s")
def test_forward_seek_returns_none_near_end() -> None:
"""Ensure seeking too close to end returns an invalid seek result."""
assert compute_seek_target(95.0, 100.0, 10.0, "forward") is None
def test_backward_seek_clamps_to_zero() -> None:
"""Ensure backward seek cannot go below zero."""
target = compute_seek_target(5.0, None, 30.0, "backward")
assert target == (0.0, "Skipped backward 30s")

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from auditui.stats.account import (
get_account_info,
get_country,
get_subscription_details,
)
class AccountClient:
"""Minimal API client returning endpoint-specific account responses."""
def __init__(self, responses: dict[str, dict]) -> None:
"""Store endpoint response map for deterministic tests."""
self._responses = responses
def get(self, path: str, **kwargs: object) -> dict:
"""Return configured response and ignore query parameters."""
del kwargs
return self._responses.get(path, {})
def test_get_account_info_merges_multiple_endpoints() -> None:
"""Ensure account info aggregator combines endpoint payload dictionaries."""
client = AccountClient(
{
"1.0/account/information": {"a": 1},
"1.0/customer/information": {"b": 2},
"1.0/customer/status": {"c": 3},
}
)
assert get_account_info(client) == {"a": 1, "b": 2, "c": 3}
def test_get_subscription_details_uses_known_nested_paths() -> None:
"""Ensure first valid subscription_details list entry is returned."""
info = {
"customer_details": {
"subscription": {"subscription_details": [{"name": "Plan"}]}
}
}
assert get_subscription_details(info) == {"name": "Plan"}
def test_get_country_supports_locale_variants() -> None:
"""Ensure country extraction supports object, domain, and locale string forms."""
auth_country_code = type(
"Auth", (), {"locale": type("Loc", (), {"country_code": "us"})()}
)()
auth_domain = type("Auth", (), {"locale": type("Loc", (), {"domain": "fr"})()})()
auth_string = type("Auth", (), {"locale": "en_gb"})()
assert get_country(auth_country_code) == "US"
assert get_country(auth_domain) == "FR"
assert get_country(auth_string) == "GB"

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from datetime import date
from auditui.stats.aggregator import StatsAggregator
from auditui.stats import aggregator as aggregator_mod
def test_get_stats_returns_empty_without_client() -> None:
"""Ensure stats aggregation short-circuits when API client is absent."""
aggregator = StatsAggregator(
client=None, auth=None, library_client=None, all_items=[]
)
assert aggregator.get_stats() == []
def test_get_stats_builds_expected_rows(monkeypatch) -> None:
"""Ensure aggregator assembles rows from listening, account, and email sources."""
monkeypatch.setattr(
aggregator_mod.listening_mod, "get_signup_year", lambda _client: 2015
)
monkeypatch.setattr(
aggregator_mod.listening_mod,
"get_listening_time",
lambda _client, duration, start_date: 120_000 if duration == 1 else 3_600_000,
)
monkeypatch.setattr(
aggregator_mod.listening_mod, "get_finished_books_count", lambda _lc, _items: 7
)
monkeypatch.setattr(
aggregator_mod.email_mod,
"resolve_email",
lambda *args, **kwargs: "user@example.com",
)
monkeypatch.setattr(aggregator_mod.account_mod, "get_country", lambda _auth: "US")
monkeypatch.setattr(
aggregator_mod.account_mod,
"get_account_info",
lambda _client: {
"subscription_details": [
{
"name": "Premium",
"next_bill_date": "2026-02-01T00:00:00Z",
"next_bill_amount": {
"currency_value": "14.95",
"currency_code": "USD",
},
}
]
},
)
aggregator = StatsAggregator(
client=object(),
auth=object(),
library_client=object(),
all_items=[{}, {}, {}],
)
stats = dict(aggregator.get_stats(today=date(2026, 2, 1)))
assert stats["Email"] == "user@example.com"
assert stats["Country Store"] == "US"
assert stats["Signup Year"] == "2015"
assert stats["Subscription"] == "Premium"
assert stats["Price"] == "14.95 USD"
assert stats["This Month"] == "2m"
assert stats["This Year"] == "1h00"
assert stats["Books Finished"] == "7 / 3"

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
import json
from pathlib import Path
from auditui.stats.email import (
find_email_in_data,
first_email,
get_email_from_account_info,
get_email_from_auth,
get_email_from_auth_file,
get_email_from_config,
resolve_email,
)
def test_find_email_in_nested_data() -> None:
"""Ensure nested structures are scanned until a plausible email is found."""
data = {"a": {"b": ["nope", "user@example.com"]}}
assert find_email_in_data(data) == "user@example.com"
def test_first_email_skips_unknown_and_none() -> None:
"""Ensure first_email ignores empty and Unknown sentinel values."""
assert first_email(None, "Unknown", "ok@example.com") == "ok@example.com"
def test_get_email_from_config_and_auth_file(tmp_path: Path) -> None:
"""Ensure config and auth-file readers extract valid email fields."""
config_path = tmp_path / "config.json"
auth_path = tmp_path / "auth.json"
config_path.write_text(
json.dumps({"email": "config@example.com"}), encoding="utf-8"
)
auth_path.write_text(json.dumps({"email": "auth@example.com"}), encoding="utf-8")
assert get_email_from_config(config_path) == "config@example.com"
assert get_email_from_auth_file(auth_path) == "auth@example.com"
def test_get_email_from_auth_prefers_username() -> None:
"""Ensure auth object attributes are checked in expected precedence order."""
auth = type(
"Auth", (), {"username": "user@example.com", "login": None, "email": None}
)()
assert get_email_from_auth(auth) == "user@example.com"
def test_get_email_from_account_info_supports_nested_customer_info() -> None:
"""Ensure account email can be discovered in nested customer_info payload."""
info = {"customer_info": {"primary_email": "nested@example.com"}}
assert get_email_from_account_info(info) == "nested@example.com"
def test_resolve_email_falls_back_to_account_getter(tmp_path: Path) -> None:
"""Ensure resolve_email checks account-info callback when local sources miss."""
auth = object()
value = resolve_email(
auth,
client=object(),
config_path=tmp_path / "missing-config.json",
auth_path=tmp_path / "missing-auth.json",
get_account_info=lambda: {"customer_email": "account@example.com"},
)
assert value == "account@example.com"

View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from auditui.stats.format import format_date, format_time
def test_format_time_handles_minutes_and_hours() -> None:
"""Ensure format_time outputs minute-only and hour-minute formats."""
assert format_time(90_000) == "1m"
assert format_time(3_660_000) == "1h01"
def test_format_date_handles_iso_and_invalid_values() -> None:
"""Ensure format_date normalizes ISO timestamps and preserves invalid input."""
assert format_date("2026-01-15T10:20:30Z") == "2026-01-15"
assert format_date("not-a-date") == "not-a-date"
assert format_date(None) == "Unknown"

View File

@@ -0,0 +1,64 @@
from __future__ import annotations
from auditui.stats.listening import (
get_finished_books_count,
get_listening_time,
get_signup_year,
has_activity,
)
class StatsClient:
"""Client double for monthly aggregate lookups keyed by start date."""
def __init__(self, sums_by_start_date: dict[str, list[int]]) -> None:
"""Store aggregate sums grouped by monthly_listening_interval_start_date."""
self._sums = sums_by_start_date
def get(self, path: str, **kwargs: str) -> dict:
"""Return aggregate payload based on requested interval start date."""
del path
start_date = kwargs["monthly_listening_interval_start_date"]
sums = self._sums.get(start_date, [0])
return {
"aggregated_monthly_listening_stats": [{"aggregated_sum": s} for s in sums]
}
def test_has_activity_detects_non_zero_months() -> None:
"""Ensure activity helper returns true when any month has positive sum."""
assert (
has_activity(
{
"aggregated_monthly_listening_stats": [
{"aggregated_sum": 0},
{"aggregated_sum": 1},
]
}
)
is True
)
def test_get_listening_time_sums_aggregated_months() -> None:
"""Ensure monthly aggregate sums are added into one listening total."""
client = StatsClient({"2026-01": [1000, 2000, 3000]})
assert get_listening_time(client, duration=1, start_date="2026-01") == 6000
def test_get_signup_year_returns_earliest_year_with_activity() -> None:
"""Ensure signup year search finds first active year via binary search."""
client = StatsClient(
{"2026-01": [1], "2010-01": [1], "2002-01": [1], "2001-01": [0]}
)
year = get_signup_year(client)
assert year <= 2010
def test_get_finished_books_count_uses_library_is_finished() -> None:
"""Ensure finished books count delegates to library client predicate."""
library_client = type(
"Library", (), {"is_finished": lambda self, item: item.get("done", False)}
)()
items = [{"done": True}, {"done": False}, {"done": True}]
assert get_finished_books_count(library_client, items) == 2

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, cast
from auditui.ui import FilterScreen
from textual.widgets import Input
@dataclass(slots=True)
class DummyEvent:
"""Minimal event object carrying an input value for tests."""
value: str
@dataclass(slots=True)
class FakeTimer:
"""Timer substitute recording whether stop() was called."""
callback: Callable[[], None]
stopped: bool = False
def stop(self) -> None:
"""Mark timer as stopped."""
self.stopped = True
def test_filter_debounce_uses_latest_value(monkeypatch) -> None:
"""Ensure debounce cancels previous timer and emits latest input value."""
seen: list[str] = []
timers: list[FakeTimer] = []
def on_change(value: str) -> None:
"""Capture emitted filter values."""
seen.append(value)
screen = FilterScreen(on_change=on_change, debounce_seconds=0.2)
def fake_set_timer(_delay: float, callback: Callable[[], None]) -> FakeTimer:
"""Record timer callbacks instead of scheduling real timers."""
timer = FakeTimer(callback)
timers.append(timer)
return timer
monkeypatch.setattr(screen, "set_timer", fake_set_timer)
screen.on_input_changed(cast(Input.Changed, DummyEvent("a")))
screen.on_input_changed(cast(Input.Changed, DummyEvent("ab")))
assert len(timers) == 2
assert timers[0].stopped is True
assert timers[1].stopped is False
timers[1].callback()
assert seen == ["ab"]
def test_on_unmount_stops_pending_timer() -> None:
"""Ensure screen unmount stops pending debounce timer when present."""
screen = FilterScreen(on_change=lambda _value: None)
timer = FakeTimer(lambda: None)
screen._debounce_timer = timer
screen.on_unmount()
assert timer.stopped is True

View File

@@ -1,55 +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")
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("Title")
def action_reverse_sort(self) -> None:
table = self.query_one(DataTable)
table.sort("Title", reverse=True)
if __name__ == "__main__":
app = AudituiApp()
app.run()

504
uv.lock generated Normal file
View File

@@ -0,0 +1,504 @@
version = 1
revision = 3
requires-python = ">=3.10, <3.13"
[[package]]
name = "anyio"
version = "4.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "idna" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
]
[[package]]
name = "audible"
version = "0.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "httpx" },
{ name = "pbkdf2" },
{ name = "pillow" },
{ name = "pyaes" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/3e/2dd2d81116b81d91fca4bdff86e2dfd41fc8668655e228ab3979beb0d03a/audible-0.10.0.tar.gz", hash = "sha256:125b3accc9ffbda020dd25818264cabe5d748a40559cb9b9c10611d87bb14ebb", size = 43286, upload-time = "2024-09-26T15:36:40.724Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/8e/b89637aeb78f5cc9914a136fe8602ec314b94ea441d92766b0b17d803810/audible-0.10.0-py3-none-any.whl", hash = "sha256:5f59082c0bb07f111a31b86358e07719d57c159bbc144c2724bec0d35a8e7e2c", size = 46636, upload-time = "2024-09-26T15:36:39.12Z" },
]
[[package]]
name = "auditui"
version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "audible" },
{ name = "httpx" },
{ name = "textual" },
]
[package.optional-dependencies]
dev = [
{ name = "coverage", extra = ["toml"] },
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "audible", specifier = ">=0.10.0" },
{ name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = ">=7.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "textual", specifier = ">=8.0.0" },
]
provides-extras = ["dev"]
[[package]]
name = "backports-asyncio-runner"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.14.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" },
]
[[package]]
name = "certifi"
version = "2025.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" },
{ url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" },
{ url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" },
{ url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" },
{ url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" },
{ url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" },
{ url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" },
{ url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" },
{ url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" },
{ url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" },
{ url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" },
{ url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" },
{ url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" },
{ url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" },
{ url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" },
{ url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" },
{ url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" },
{ url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" },
{ url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" },
{ url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" },
{ url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" },
{ url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" },
{ url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" },
{ url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" },
{ url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" },
{ url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" },
{ url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" },
{ url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" },
{ url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" },
{ url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" },
{ url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" },
{ url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" },
{ url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" },
{ url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" },
{ url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" },
{ url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" },
{ url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" },
{ url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" },
]
[package.optional-dependencies]
toml = [
{ name = "tomli", marker = "python_full_version <= '3.11'" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "linkify-it-py"
version = "2.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "uc-micro-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/ae/bb56c6828e4797ba5a4821eec7c43b8bf40f69cda4d4f5f8c8a2810ec96a/linkify-it-py-2.0.3.tar.gz", hash = "sha256:68cda27e162e9215c17d786649d1da0021a451bdc436ef9e0fa0ba5234b9b048", size = 27946, upload-time = "2024-02-04T14:48:04.179Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/1e/b832de447dee8b582cac175871d2f6c3d5077cc56d5575cadba1fd1cccfa/linkify_it_py-2.0.3-py3-none-any.whl", hash = "sha256:6bcbc417b0ac14323382aef5c5192c0075bf8a9d6b41820a2b66371eac6b6d79", size = 19820, upload-time = "2024-02-04T14:48:02.496Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[package.optional-dependencies]
linkify = [
{ name = "linkify-it-py" },
]
[[package]]
name = "mdit-py-plugins"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pbkdf2"
version = "1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/c0/6a2376ae81beb82eda645a091684c0b0becb86b972def7849ea9066e3d5e/pbkdf2-1.3.tar.gz", hash = "sha256:ac6397369f128212c43064a2b4878038dab78dab41875364554aaf2a684e6979", size = 6360, upload-time = "2011-06-14T05:18:10.981Z" }
[[package]]
name = "pillow"
version = "12.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/08/26e68b6b5da219c2a2cb7b563af008b53bb8e6b6fcb3fa40715fcdb2523a/pillow-12.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:3adfb466bbc544b926d50fe8f4a4e6abd8c6bffd28a26177594e6e9b2b76572b", size = 5289809, upload-time = "2025-10-15T18:21:27.791Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e9/4e58fb097fb74c7b4758a680aacd558810a417d1edaa7000142976ef9d2f/pillow-12.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1ac11e8ea4f611c3c0147424eae514028b5e9077dd99ab91e1bd7bc33ff145e1", size = 4650606, upload-time = "2025-10-15T18:21:29.823Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/1fa492aa9f77b3bc6d471c468e62bfea1823056bf7e5e4f1914d7ab2565e/pillow-12.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d49e2314c373f4c2b39446fb1a45ed333c850e09d0c59ac79b72eb3b95397363", size = 6221023, upload-time = "2025-10-15T18:21:31.415Z" },
{ url = "https://files.pythonhosted.org/packages/c1/09/4de7cd03e33734ccd0c876f0251401f1314e819cbfd89a0fcb6e77927cc6/pillow-12.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c7b2a63fd6d5246349f3d3f37b14430d73ee7e8173154461785e43036ffa96ca", size = 8024937, upload-time = "2025-10-15T18:21:33.453Z" },
{ url = "https://files.pythonhosted.org/packages/2e/69/0688e7c1390666592876d9d474f5e135abb4acb39dcb583c4dc5490f1aff/pillow-12.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d64317d2587c70324b79861babb9c09f71fbb780bad212018874b2c013d8600e", size = 6334139, upload-time = "2025-10-15T18:21:35.395Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1c/880921e98f525b9b44ce747ad1ea8f73fd7e992bafe3ca5e5644bf433dea/pillow-12.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d77153e14b709fd8b8af6f66a3afbb9ed6e9fc5ccf0b6b7e1ced7b036a228782", size = 7026074, upload-time = "2025-10-15T18:21:37.219Z" },
{ url = "https://files.pythonhosted.org/packages/28/03/96f718331b19b355610ef4ebdbbde3557c726513030665071fd025745671/pillow-12.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:32ed80ea8a90ee3e6fa08c21e2e091bba6eda8eccc83dbc34c95169507a91f10", size = 6448852, upload-time = "2025-10-15T18:21:39.168Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a0/6a193b3f0cc9437b122978d2c5cbce59510ccf9a5b48825096ed7472da2f/pillow-12.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c828a1ae702fc712978bda0320ba1b9893d99be0badf2647f693cc01cf0f04fa", size = 7117058, upload-time = "2025-10-15T18:21:40.997Z" },
{ url = "https://files.pythonhosted.org/packages/a7/c4/043192375eaa4463254e8e61f0e2ec9a846b983929a8d0a7122e0a6d6fff/pillow-12.0.0-cp310-cp310-win32.whl", hash = "sha256:bd87e140e45399c818fac4247880b9ce719e4783d767e030a883a970be632275", size = 6295431, upload-time = "2025-10-15T18:21:42.518Z" },
{ url = "https://files.pythonhosted.org/packages/92/c6/c2f2fc7e56301c21827e689bb8b0b465f1b52878b57471a070678c0c33cd/pillow-12.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:455247ac8a4cfb7b9bc45b7e432d10421aea9fc2e74d285ba4072688a74c2e9d", size = 7000412, upload-time = "2025-10-15T18:21:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/b2/d2/5f675067ba82da7a1c238a73b32e3fd78d67f9d9f80fbadd33a40b9c0481/pillow-12.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:6ace95230bfb7cd79ef66caa064bbe2f2a1e63d93471c3a2e1f1348d9f22d6b7", size = 2435903, upload-time = "2025-10-15T18:21:46.29Z" },
{ url = "https://files.pythonhosted.org/packages/0e/5a/a2f6773b64edb921a756eb0729068acad9fc5208a53f4a349396e9436721/pillow-12.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:0fd00cac9c03256c8b2ff58f162ebcd2587ad3e1f2e397eab718c47e24d231cc", size = 5289798, upload-time = "2025-10-15T18:21:47.763Z" },
{ url = "https://files.pythonhosted.org/packages/2e/05/069b1f8a2e4b5a37493da6c5868531c3f77b85e716ad7a590ef87d58730d/pillow-12.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3475b96f5908b3b16c47533daaa87380c491357d197564e0ba34ae75c0f3257", size = 4650589, upload-time = "2025-10-15T18:21:49.515Z" },
{ url = "https://files.pythonhosted.org/packages/61/e3/2c820d6e9a36432503ead175ae294f96861b07600a7156154a086ba7111a/pillow-12.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:110486b79f2d112cf6add83b28b627e369219388f64ef2f960fef9ebaf54c642", size = 6230472, upload-time = "2025-10-15T18:21:51.052Z" },
{ url = "https://files.pythonhosted.org/packages/4f/89/63427f51c64209c5e23d4d52071c8d0f21024d3a8a487737caaf614a5795/pillow-12.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5269cc1caeedb67e6f7269a42014f381f45e2e7cd42d834ede3c703a1d915fe3", size = 8033887, upload-time = "2025-10-15T18:21:52.604Z" },
{ url = "https://files.pythonhosted.org/packages/f6/1b/c9711318d4901093c15840f268ad649459cd81984c9ec9887756cca049a5/pillow-12.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa5129de4e174daccbc59d0a3b6d20eaf24417d59851c07ebb37aeb02947987c", size = 6343964, upload-time = "2025-10-15T18:21:54.619Z" },
{ url = "https://files.pythonhosted.org/packages/41/1e/db9470f2d030b4995083044cd8738cdd1bf773106819f6d8ba12597d5352/pillow-12.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bee2a6db3a7242ea309aa7ee8e2780726fed67ff4e5b40169f2c940e7eb09227", size = 7034756, upload-time = "2025-10-15T18:21:56.151Z" },
{ url = "https://files.pythonhosted.org/packages/cc/b0/6177a8bdd5ee4ed87cba2de5a3cc1db55ffbbec6176784ce5bb75aa96798/pillow-12.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:90387104ee8400a7b4598253b4c406f8958f59fcf983a6cea2b50d59f7d63d0b", size = 6458075, upload-time = "2025-10-15T18:21:57.759Z" },
{ url = "https://files.pythonhosted.org/packages/bc/5e/61537aa6fa977922c6a03253a0e727e6e4a72381a80d63ad8eec350684f2/pillow-12.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc91a56697869546d1b8f0a3ff35224557ae7f881050e99f615e0119bf934b4e", size = 7125955, upload-time = "2025-10-15T18:21:59.372Z" },
{ url = "https://files.pythonhosted.org/packages/1f/3d/d5033539344ee3cbd9a4d69e12e63ca3a44a739eb2d4c8da350a3d38edd7/pillow-12.0.0-cp311-cp311-win32.whl", hash = "sha256:27f95b12453d165099c84f8a8bfdfd46b9e4bda9e0e4b65f0635430027f55739", size = 6298440, upload-time = "2025-10-15T18:22:00.982Z" },
{ url = "https://files.pythonhosted.org/packages/4d/42/aaca386de5cc8bd8a0254516957c1f265e3521c91515b16e286c662854c4/pillow-12.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b583dc9070312190192631373c6c8ed277254aa6e6084b74bdd0a6d3b221608e", size = 6999256, upload-time = "2025-10-15T18:22:02.617Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f1/9197c9c2d5708b785f631a6dfbfa8eb3fb9672837cb92ae9af812c13b4ed/pillow-12.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:759de84a33be3b178a64c8ba28ad5c135900359e85fb662bc6e403ad4407791d", size = 2436025, upload-time = "2025-10-15T18:22:04.598Z" },
{ url = "https://files.pythonhosted.org/packages/2c/90/4fcce2c22caf044e660a198d740e7fbc14395619e3cb1abad12192c0826c/pillow-12.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:53561a4ddc36facb432fae7a9d8afbfaf94795414f5cdc5fc52f28c1dca90371", size = 5249377, upload-time = "2025-10-15T18:22:05.993Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e0/ed960067543d080691d47d6938ebccbf3976a931c9567ab2fbfab983a5dd/pillow-12.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71db6b4c1653045dacc1585c1b0d184004f0d7e694c7b34ac165ca70c0838082", size = 4650343, upload-time = "2025-10-15T18:22:07.718Z" },
{ url = "https://files.pythonhosted.org/packages/e7/a1/f81fdeddcb99c044bf7d6faa47e12850f13cee0849537a7d27eeab5534d4/pillow-12.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2fa5f0b6716fc88f11380b88b31fe591a06c6315e955c096c35715788b339e3f", size = 6232981, upload-time = "2025-10-15T18:22:09.287Z" },
{ url = "https://files.pythonhosted.org/packages/88/e1/9098d3ce341a8750b55b0e00c03f1630d6178f38ac191c81c97a3b047b44/pillow-12.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82240051c6ca513c616f7f9da06e871f61bfd7805f566275841af15015b8f98d", size = 8041399, upload-time = "2025-10-15T18:22:10.872Z" },
{ url = "https://files.pythonhosted.org/packages/a7/62/a22e8d3b602ae8cc01446d0c57a54e982737f44b6f2e1e019a925143771d/pillow-12.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55f818bd74fe2f11d4d7cbc65880a843c4075e0ac7226bc1a23261dbea531953", size = 6347740, upload-time = "2025-10-15T18:22:12.769Z" },
{ url = "https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8", size = 7040201, upload-time = "2025-10-15T18:22:14.813Z" },
{ url = "https://files.pythonhosted.org/packages/dc/4d/435c8ac688c54d11755aedfdd9f29c9eeddf68d150fe42d1d3dbd2365149/pillow-12.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c607c90ba67533e1b2355b821fef6764d1dd2cbe26b8c1005ae84f7aea25ff79", size = 6462334, upload-time = "2025-10-15T18:22:16.375Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f2/ad34167a8059a59b8ad10bc5c72d4d9b35acc6b7c0877af8ac885b5f2044/pillow-12.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:21f241bdd5080a15bc86d3466a9f6074a9c2c2b314100dd896ac81ee6db2f1ba", size = 7134162, upload-time = "2025-10-15T18:22:17.996Z" },
{ url = "https://files.pythonhosted.org/packages/0c/b1/a7391df6adacf0a5c2cf6ac1cf1fcc1369e7d439d28f637a847f8803beb3/pillow-12.0.0-cp312-cp312-win32.whl", hash = "sha256:dd333073e0cacdc3089525c7df7d39b211bcdf31fc2824e49d01c6b6187b07d0", size = 6298769, upload-time = "2025-10-15T18:22:19.923Z" },
{ url = "https://files.pythonhosted.org/packages/a2/0b/d87733741526541c909bbf159e338dcace4f982daac6e5a8d6be225ca32d/pillow-12.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe611163f6303d1619bbcb653540a4d60f9e55e622d60a3108be0d5b441017a", size = 7001107, upload-time = "2025-10-15T18:22:21.644Z" },
{ url = "https://files.pythonhosted.org/packages/bc/96/aaa61ce33cc98421fb6088af2a03be4157b1e7e0e87087c888e2370a7f45/pillow-12.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:7dfb439562f234f7d57b1ac6bc8fe7f838a4bd49c79230e0f6a1da93e82f1fad", size = 2436012, upload-time = "2025-10-15T18:22:23.621Z" },
{ url = "https://files.pythonhosted.org/packages/1d/b3/582327e6c9f86d037b63beebe981425d6811104cb443e8193824ef1a2f27/pillow-12.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b22bd8c974942477156be55a768f7aa37c46904c175be4e158b6a86e3a6b7ca8", size = 5215068, upload-time = "2025-10-15T18:23:59.594Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d6/67748211d119f3b6540baf90f92fae73ae51d5217b171b0e8b5f7e5d558f/pillow-12.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:805ebf596939e48dbb2e4922a1d3852cfc25c38160751ce02da93058b48d252a", size = 4614994, upload-time = "2025-10-15T18:24:01.669Z" },
{ url = "https://files.pythonhosted.org/packages/2d/e1/f8281e5d844c41872b273b9f2c34a4bf64ca08905668c8ae730eedc7c9fa/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cae81479f77420d217def5f54b5b9d279804d17e982e0f2fa19b1d1e14ab5197", size = 5246639, upload-time = "2025-10-15T18:24:03.403Z" },
{ url = "https://files.pythonhosted.org/packages/94/5a/0d8ab8ffe8a102ff5df60d0de5af309015163bf710c7bb3e8311dd3b3ad0/pillow-12.0.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeaefa96c768fc66818730b952a862235d68825c178f1b3ffd4efd7ad2edcb7c", size = 6986839, upload-time = "2025-10-15T18:24:05.344Z" },
{ url = "https://files.pythonhosted.org/packages/20/2e/3434380e8110b76cd9eb00a363c484b050f949b4bbe84ba770bb8508a02c/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f2d0abef9e4e2f349305a4f8cc784a8a6c2f58a8c4892eea13b10a943bd26e", size = 5313505, upload-time = "2025-10-15T18:24:07.137Z" },
{ url = "https://files.pythonhosted.org/packages/57/ca/5a9d38900d9d74785141d6580950fe705de68af735ff6e727cb911b64740/pillow-12.0.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdee52571a343d721fb2eb3b090a82d959ff37fc631e3f70422e0c2e029f3e76", size = 5963654, upload-time = "2025-10-15T18:24:09.579Z" },
{ url = "https://files.pythonhosted.org/packages/95/7e/f896623c3c635a90537ac093c6a618ebe1a90d87206e42309cb5d98a1b9e/pillow-12.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b290fd8aa38422444d4b50d579de197557f182ef1068b75f5aa8558638b8d0a5", size = 6997850, upload-time = "2025-10-15T18:24:11.495Z" },
]
[[package]]
name = "platformdirs"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pyaes"
version = "1.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536, upload-time = "2017-09-20T21:17:54.23Z" }
[[package]]
name = "pyasn1"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
{ name = "pytest" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "rich"
version = "14.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]]
name = "soupsieve"
version = "2.8"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
]
[[package]]
name = "textual"
version = "8.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", extra = ["linkify"] },
{ name = "mdit-py-plugins" },
{ name = "platformdirs" },
{ name = "pygments" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/08/1e1f705825359590ddfaeda57653bd518c4ff7a96bb2c3239ba1b6fc4c51/textual-8.0.0.tar.gz", hash = "sha256:ce48f83a3d686c0fac0e80bf9136e1f8851c653aa6a4502e43293a151df18809", size = 1595895, upload-time = "2026-02-16T17:12:14.215Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/be/e191c2a15da20530fde03564564e3e4b4220eb9d687d4014957e5c6a5e85/textual-8.0.0-py3-none-any.whl", hash = "sha256:8908f4ebe93a6b4f77ca7262197784a52162bc88b05f4ecf50ac93a92d49bb8f", size = 718904, upload-time = "2026-02-16T17:12:11.962Z" },
]
[[package]]
name = "tomli"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/2e/299f62b401438d5fe1624119c723f5d877acc86a4c2492da405626665f12/tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45", size = 153236, upload-time = "2025-10-08T22:01:00.137Z" },
{ url = "https://files.pythonhosted.org/packages/86/7f/d8fffe6a7aefdb61bced88fcb5e280cfd71e08939da5894161bd71bea022/tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba", size = 148084, upload-time = "2025-10-08T22:01:01.63Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" },
{ url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" },
{ url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" },
{ url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" },
{ url = "https://files.pythonhosted.org/packages/b6/eb/a7679c8ac85208706d27436e8d421dfa39d4c914dcf5fa8083a9305f58d9/tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456", size = 96445, upload-time = "2025-10-08T22:01:06.896Z" },
{ url = "https://files.pythonhosted.org/packages/0a/fe/3d3420c4cb1ad9cb462fb52967080575f15898da97e21cb6f1361d505383/tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be", size = 107165, upload-time = "2025-10-08T22:01:08.107Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b7/40f36368fcabc518bb11c8f06379a0fd631985046c038aca08c6d6a43c6e/tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac", size = 154891, upload-time = "2025-10-08T22:01:09.082Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3f/d9dd692199e3b3aab2e4e4dd948abd0f790d9ded8cd10cbaae276a898434/tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22", size = 148796, upload-time = "2025-10-08T22:01:10.266Z" },
{ url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" },
{ url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" },
{ url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" },
{ url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" },
{ url = "https://files.pythonhosted.org/packages/a0/b7/a7a7042715d55c9ba6e8b196d65d2cb662578b4d8cd17d882d45322b0d78/tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876", size = 97124, upload-time = "2025-10-08T22:01:15.629Z" },
{ url = "https://files.pythonhosted.org/packages/06/1e/f22f100db15a68b520664eb3328fb0ae4e90530887928558112c8d1f4515/tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878", size = 107698, upload-time = "2025-10-08T22:01:16.51Z" },
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "uc-micro-py"
version = "1.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/91/7a/146a99696aee0609e3712f2b44c6274566bc368dfe8375191278045186b8/uc-micro-py-1.0.3.tar.gz", hash = "sha256:d321b92cff673ec58027c04015fcaa8bb1e005478643ff4a500882eaab88c48a", size = 6043, upload-time = "2024-02-09T16:52:01.654Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" },
]