add program files
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
Source lives in `src/audiobooksorter/`: ingestion (`ingest.py`), sorting rules (`sorter/series_rules.py`), exporting (`export.py`), and the CLI (`cli.py`). Config defaults sit in `config/`, constants in `constants.py`, and shared logging in `logging.py`. Sample inputs for quick runs live in `samples/`. Test suites mirror the package under `tests/`, with fixtures in `tests/conftest.py`. Assets that do not execute (icons, mock exports) belong in `assets/`. Local library fixtures for manual checks live in `testdata/`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
Use Python 3.11+. Set up a venv and install dev tooling:
|
||||||
|
```
|
||||||
|
python -m venv .venv && source .venv/bin/activate
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -e .[dev]
|
||||||
|
```
|
||||||
|
Preview ordering without writing files via `python -m audiobooksorter.cli --library ./testdata --dry-run`. Run against a JSON export with `python -m audiobooksorter.cli --library ./samples/library.json --output ./sorted_library.json`. Format and lint with `ruff check --fix` (plus `ruff format` if desired). Execute the suite using `pytest -q` (coverage gates are pre-configured).
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
Follow PEP 8 with 4-space indentation and type hints on public surfaces. Prefer descriptive module names (`series_rules.py`, `ingest.py`) and uppercase constants. Use dataclasses for structured audiobook records and the shared logger from `logging.py` to centralize verbosity control.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
Tests target `pytest` with coverage enforced at 90% (`pytest --cov=audiobooksorter --cov-report=term-missing`). Name files `test_<unit>.py` and tests `test_<behavior>`. Add regression cases whenever sorting order changes—especially around edge series, mixed narrators, or missing metadata—in both `tests/` and `testdata/` when a folder-based scenario is required.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
Commit messages follow the `type(scope): summary` pattern (e.g., `feat(cli): add dry-run preview`). Reference issues in bodies (`Fixes #ID`). Pull requests should include a clear description of user-facing changes, testing evidence (command output is fine), and any migration notes for library paths or naming rules. Request review for sorting rule tweaks to avoid regressions in existing shelves.
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
Do not commit personal Audiobookshelf exports; anonymize anything checked into `samples/` or `testdata/`. Keep secrets (.env, tokens) out of the repo and load them at runtime. Validate file paths before writing outputs to avoid clobbering a user’s library.
|
||||||
@@ -1,2 +1,90 @@
|
|||||||
# audibooksorter
|
# audibooksorter
|
||||||
|
|
||||||
|
An audiobook sorter for Audiobookshelf libraries. Give the root path of your Audiobookshelf library and the tool will materialize a sorted structure arranged by:
|
||||||
|
|
||||||
|
-Author
|
||||||
|
--Series
|
||||||
|
----Title
|
||||||
|
|
||||||
|
|
||||||
|
# Title Folder Naming
|
||||||
|
|
||||||
|
In addition to the book title, the title folder can include the publish year, series sequence, the subtitle, and the narrator.
|
||||||
|
|
||||||
|
Here are a bunch of ways the same book could be named:
|
||||||
|
|
||||||
|
Wizards First Rule
|
||||||
|
|
||||||
|
Wizards First Rule {Sam Tsoutsouvas}
|
||||||
|
|
||||||
|
1994 - Wizards First Rule
|
||||||
|
|
||||||
|
Wizards First Rule - A Really Good Subtitle
|
||||||
|
|
||||||
|
1994 - Book 1 - Wizards First Rule
|
||||||
|
|
||||||
|
1994 - Volume 1. Wizards First Rule {Sam Tsoutsouvas}
|
||||||
|
|
||||||
|
Vol 1 - 1994 - Wizards First Rule
|
||||||
|
|
||||||
|
1994 - Wizards First Rule - Volume 1
|
||||||
|
|
||||||
|
Vol. 1 - 1994 - Wizards First Rule - A Really Good Subtitle {Sam Tsoutsouvas}
|
||||||
|
|
||||||
|
(1994) - Wizards First Rule - A Really Good Subtitle
|
||||||
|
|
||||||
|
1 - Wizards First Rule
|
||||||
|
|
||||||
|
1. Wizards First Rule
|
||||||
|
|
||||||
|
Subtitle: Parsing out subtitles into a separate field is optional and must be enabled in settings. Subtitle must be separated by " - ".
|
||||||
|
Series Sequence: Case insensitive & decimals supported.
|
||||||
|
The sequence can be placed anywhere in the folder name.
|
||||||
|
It must be followed by " - " or ". "
|
||||||
|
If it is not at the beginning of the folder name, it must be preceded by " - " and "Vol" "Vol." "Volume" or "Book"
|
||||||
|
Publish Year: The publish year must be the first part of the name OR directly after a series sequence, and separated by " - " on both sides.
|
||||||
|
Narrator: Must be wrapped in curly braces. e.g. {Sam Tsoutsouvas}.
|
||||||
|
Disc Numbers: The title folder can contain subfolders for discs as long as they are named "Disc", "CD", or "Disk" with a number following (case insensitive). Example: "Disc 1", "CD 2", "Disk 3", "Disc 004", or no space between like "Disc1".
|
||||||
|
|
||||||
|
# Author Folder Naming
|
||||||
|
Supports "Last, First" author naming as well as multiple authors separated by ",", ";", "&" or "and".
|
||||||
|
|
||||||
|
Valid author folder names:
|
||||||
|
|
||||||
|
Ichiro Kishimi
|
||||||
|
|
||||||
|
Kishimi, Ichiro
|
||||||
|
|
||||||
|
Ichiro Kishimi, Fumitake Koga
|
||||||
|
|
||||||
|
Kishimi, Ichiro, Koga, Fumitake
|
||||||
|
|
||||||
|
Ichiro Kishimi & Fumitake Koga
|
||||||
|
|
||||||
|
Kishimi, Ichiro & Koga, Fumitake
|
||||||
|
|
||||||
|
Terry Goodkind, Ichiro Kishimi and Fumitake Koga
|
||||||
|
|
||||||
|
## Development setup
|
||||||
|
|
||||||
|
This repository now uses [PDM](https://pdm.fming.dev) for packaging so tooling, dependencies, and the src layout stay in sync.
|
||||||
|
|
||||||
|
1. Install PDM (`pipx install pdm` or `pip install --user pdm`).
|
||||||
|
2. Create a virtual environment via `pdm venv create --with python3.11` if your system Python does not match the required version.
|
||||||
|
3. Install the application and dev requirements with `pdm install --dev`.
|
||||||
|
|
||||||
|
PDM writes metadata to `pdm.lock`; regenerate it with `pdm lock --refresh` whenever dependencies change.
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
- Run the CLI: `pdm run python -m audiobooksorter.cli --library /path/to/library --output ./sorted_library` (pass a JSON export from `samples/` only when running tests)
|
||||||
|
- Format and lint: `pdm run ruff check --fix && pdm run ruff format`
|
||||||
|
- Test with coverage: `pdm run pytest -q`
|
||||||
|
|
||||||
|
## Library ingestion
|
||||||
|
|
||||||
|
The sorter walks the supplied library directory looking for `metadata.json` files (one per audiobook folder). Those metadata files must at least contain `title` and `author`, and may optionally declare `series` and `seriesIndex`. For integration tests and CI you can still point `--library` at a JSON export; the CLI will treat JSON inputs as fixtures and skip filesystem moves.
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
Source code lives under `src/audiobooksorter/` with ingestion, sorter, and export modules split into subpackages so the command-line interface stays thin. Static assets are kept in `assets/`, while anonymized example exports live in `samples/`. Pytest suites mirror the package structure inside `tests/`.
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
# This file is @generated by PDM.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
|
||||||
|
[metadata]
|
||||||
|
groups = ["default", "dev"]
|
||||||
|
strategy = ["inherit_metadata"]
|
||||||
|
lock_version = "4.5.0"
|
||||||
|
content_hash = "sha256:a9e0e59614457c6779aec164dec9d7f5b4c4da75ff9d7b7f0382f6c244b438dd"
|
||||||
|
|
||||||
|
[[metadata.targets]]
|
||||||
|
requires_python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||||
|
summary = "Cross-platform colored terminal text."
|
||||||
|
groups = ["dev"]
|
||||||
|
marker = "sys_platform == \"win32\""
|
||||||
|
files = [
|
||||||
|
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||||
|
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.11.3"
|
||||||
|
requires_python = ">=3.10"
|
||||||
|
summary = "Code coverage measurement for Python"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd"},
|
||||||
|
{file = "coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe"},
|
||||||
|
{file = "coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
version = "7.11.3"
|
||||||
|
extras = ["toml"]
|
||||||
|
requires_python = ">=3.10"
|
||||||
|
summary = "Code coverage measurement for Python"
|
||||||
|
groups = ["dev"]
|
||||||
|
dependencies = [
|
||||||
|
"coverage==7.11.3",
|
||||||
|
"tomli; python_full_version <= \"3.11.0a6\"",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:200bb89fd2a8a07780eafcdff6463104dec459f3c838d980455cfa84f5e5e6e1"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d264402fc179776d43e557e1ca4a7d953020d3ee95f7ec19cc2c9d769277f06"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:385977d94fc155f8731c895accdfcc3dd0d9dd9ef90d102969df95d3c637ab80"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0542ddf6107adbd2592f29da9f59f5d9cff7947b5bb4f734805085c327dcffaa"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d60bf4d7f886989ddf80e121a7f4d140d9eac91f1d2385ce8eb6bda93d563297"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0a3b6e32457535df0d41d2d895da46434706dd85dbaf53fbc0d3bd7d914b362"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:876a3ee7fd2613eb79602e4cdb39deb6b28c186e76124c3f29e580099ec21a87"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a730cd0824e8083989f304e97b3f884189efb48e2151e07f57e9e138ab104200"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b5cd111d3ab7390be0c07ad839235d5ad54d2ca497b5f5db86896098a77180a4"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:074e6a5cd38e06671580b4d872c1a67955d4e69639e4b04e87fc03b494c1f060"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-win32.whl", hash = "sha256:86d27d2dd7c7c5a44710565933c7dc9cd70e65ef97142e260d16d555667deef7"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:ca90ef33a152205fb6f2f0c1f3e55c50df4ef049bb0940ebba666edd4cdebc55"},
|
||||||
|
{file = "coverage-7.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:56f909a40d68947ef726ce6a34eb38f0ed241ffbe55c5007c64e616663bcbafc"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b771b59ac0dfb7f139f70c85b42717ef400a6790abb6475ebac1ecee8de782f"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:603c4414125fc9ae9000f17912dcfd3d3eb677d4e360b85206539240c96ea76e"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:77ffb3b7704eb7b9b3298a01fe4509cef70117a52d50bcba29cffc5f53dd326a"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4d4ca49f5ba432b0755ebb0fc3a56be944a19a16bb33802264bbc7311622c0d1"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05fd3fb6edff0c98874d752013588836f458261e5eba587afe4c547bba544afd"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e920567f8c3a3ce68ae5a42cf7c2dc4bb6cc389f18bff2235dd8c03fa405de5"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bec8c7160688bd5a34e65c82984b25409563134d63285d8943d0599efbc448e"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adb9b7b42c802bd8cb3927de8c1c26368ce50c8fdaa83a9d8551384d77537044"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c8f563b245b4ddb591e99f28e3cd140b85f114b38b7f95b2e42542f0603eb7d7"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e2a96fdc7643c9517a317553aca13b5cae9bad9a5f32f4654ce247ae4d321405"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-win32.whl", hash = "sha256:e8feeb5e8705835f0622af0fe7ff8d5cb388948454647086494d6c41ec142c2e"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:abb903ffe46bd319d99979cdba350ae7016759bb69f47882242f7b93f3356055"},
|
||||||
|
{file = "coverage-7.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:1451464fd855d9bd000c19b71bb7dafea9ab815741fb0bd9e813d9b671462d6f"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84b892e968164b7a0498ddc5746cdf4e985700b902128421bb5cec1080a6ee36"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f761dbcf45e9416ec4698e1a7649248005f0064ce3523a47402d1bff4af2779e"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1410bac9e98afd9623f53876fae7d8a5db9f5a0ac1c9e7c5188463cb4b3212e2"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:004cdcea3457c0ea3233622cd3464c1e32ebba9b41578421097402bee6461b63"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f067ada2c333609b52835ca4d4868645d3b63ac04fb2b9a658c55bba7f667d3"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07bc7745c945a6d95676953e86ba7cebb9f11de7773951c387f4c07dc76d03f5"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bba7e4743e37484ae17d5c3b8eb1ce78b564cb91b7ace2e2182b25f0f764cb5"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbffc22d80d86fbe456af9abb17f7a7766e7b2101f7edaacc3535501691563f7"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0dba4da36730e384669e05b765a2c49f39514dd3012fcc0398dd66fba8d746d5"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ae12fe90b00b71a71b69f513773310782ce01d5f58d2ceb2b7c595ab9d222094"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-win32.whl", hash = "sha256:12d821de7408292530b0d241468b698bce18dd12ecaf45316149f53877885f8c"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:6bb599052a974bb6cedfa114f9778fedfad66854107cf81397ec87cb9b8fbcf2"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:bb9d7efdb063903b3fdf77caec7b77c3066885068bdc0d44bc1b0c171033f944"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:fb58da65e3339b3dbe266b607bb936efb983d86b00b03eb04c4ad5b442c58428"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8d16bbe566e16a71d123cd66382c1315fcd520c7573652a8074a8fe281b38c6a"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8258f10059b5ac837232c589a350a2df4a96406d6d5f2a09ec587cbdd539655"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4c5627429f7fbff4f4131cfdd6abd530734ef7761116811a707b88b7e205afd7"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:465695268414e149bab754c54b0c45c8ceda73dd4a5c3ba255500da13984b16d"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ebcddfcdfb4c614233cff6e9a3967a09484114a8b2e4f2c7a62dc83676ba13f"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:13b2066303a1c1833c654d2af0455bb009b6e1727b3883c9964bc5c2f643c1d0"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d8750dd20362a1b80e3cf84f58013d4672f89663aee457ea59336df50fab6739"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ab6212e62ea0e1006531a2234e209607f360d98d18d532c2fa8e403c1afbdd71"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b17c2b5e0b9bb7702449200f93e2d04cb04b1414c41424c08aa1e5d352da76"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-win32.whl", hash = "sha256:426559f105f644b69290ea414e154a0d320c3ad8a2bb75e62884731f69cf8e2c"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:90a96fcd824564eae6137ec2563bd061d49a32944858d4bdbae5c00fb10e76ac"},
|
||||||
|
{file = "coverage-7.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:1e33d0bebf895c7a0905fcfaff2b07ab900885fc78bba2a12291a2cfbab014cc"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fdc5255eb4815babcdf236fa1a806ccb546724c8a9b129fd1ea4a5448a0bf07c"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fe3425dc6021f906c6325d3c415e048e7cdb955505a94f1eb774dafc779ba203"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4ca5f876bf41b24378ee67c41d688155f0e54cdc720de8ef9ad6544005899240"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9061a3e3c92b27fd8036dafa26f25d95695b6aa2e4514ab16a254f297e664f83"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abcea3b5f0dc44e1d01c27090bc32ce6ffb7aa665f884f1890710454113ea902"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:68c4eb92997dbaaf839ea13527be463178ac0ddd37a7ac636b8bc11a51af2428"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:149eccc85d48c8f06547534068c41d69a1a35322deaa4d69ba1561e2e9127e75"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08c0bcf932e47795c49f0406054824b9d45671362dfc4269e0bc6e4bff010704"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:39764c6167c82d68a2d8c97c33dba45ec0ad9172570860e12191416f4f8e6e1b"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3224c7baf34e923ffc78cb45e793925539d640d42c96646db62dbd61bbcfa131"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-win32.whl", hash = "sha256:c713c1c528284d636cd37723b0b4c35c11190da6f932794e145fc40f8210a14a"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:c381a252317f63ca0179d2c7918e83b99a4ff3101e1b24849b999a00f9cd4f86"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:3e33a968672be1394eded257ec10d4acbb9af2ae263ba05a99ff901bb863557e"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f9c96a29c6d65bd36a91f5634fef800212dff69dacdb44345c4c9783943ab0df"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2ec27a7a991d229213c8070d31e3ecf44d005d96a9edc30c78eaeafaa421c001"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:72c8b494bd20ae1c58528b97c4a67d5cfeafcb3845c73542875ecd43924296de"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:60ca149a446da255d56c2a7a813b51a80d9497a62250532598d249b3cdb1a926"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb5069074db19a534de3859c43eec78e962d6d119f637c41c8e028c5ab3f59dd"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac5d5329c9c942bbe6295f4251b135d860ed9f86acd912d418dce186de7c19ac"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e22539b676fafba17f0a90ac725f029a309eb6e483f364c86dcadee060429d46"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2376e8a9c889016f25472c452389e98bc6e54a19570b107e27cde9d47f387b64"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4234914b8c67238a3c4af2bba648dc716aa029ca44d01f3d51536d44ac16854f"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0b4101e2b3c6c352ff1f70b3a6fcc7c17c1ab1a91ccb7a33013cb0782af9820"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-win32.whl", hash = "sha256:305716afb19133762e8cf62745c46c4853ad6f9eeba54a593e373289e24ea237"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9245bd392572b9f799261c4c9e7216bafc9405537d0f4ce3ad93afe081a12dc9"},
|
||||||
|
{file = "coverage-7.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:9a1d577c20b4334e5e814c3d5fe07fa4a8c3ae42a601945e8d7940bab811d0bd"},
|
||||||
|
{file = "coverage-7.11.3-py3-none-any.whl", hash = "sha256:351511ae28e2509c8d8cae5311577ea7dd511ab8e746ffc8814a0896c3d33fbe"},
|
||||||
|
{file = "coverage-7.11.3.tar.gz", hash = "sha256:0f59387f5e6edbbffec2281affb71cdc85e0776c1745150a3ab9b6c1d016106b"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
requires_python = ">=3.10"
|
||||||
|
summary = "brain-dead simple config-ini parsing"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||||
|
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "25.0"
|
||||||
|
requires_python = ">=3.8"
|
||||||
|
summary = "Core utilities for Python packages"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
|
||||||
|
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
requires_python = ">=3.9"
|
||||||
|
summary = "plugin and hook calling mechanisms for python"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||||
|
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
requires_python = ">=3.8"
|
||||||
|
summary = "Pygments is a syntax highlighting package written in Python."
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
||||||
|
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.1"
|
||||||
|
requires_python = ">=3.10"
|
||||||
|
summary = "pytest: simple powerful testing with Python"
|
||||||
|
groups = ["dev"]
|
||||||
|
dependencies = [
|
||||||
|
"colorama>=0.4; sys_platform == \"win32\"",
|
||||||
|
"exceptiongroup>=1; python_version < \"3.11\"",
|
||||||
|
"iniconfig>=1.0.1",
|
||||||
|
"packaging>=22",
|
||||||
|
"pluggy<2,>=1.5",
|
||||||
|
"pygments>=2.7.2",
|
||||||
|
"tomli>=1; python_version < \"3.11\"",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"},
|
||||||
|
{file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.0.0"
|
||||||
|
requires_python = ">=3.9"
|
||||||
|
summary = "Pytest plugin for measuring coverage."
|
||||||
|
groups = ["dev"]
|
||||||
|
dependencies = [
|
||||||
|
"coverage[toml]>=7.10.6",
|
||||||
|
"pluggy>=1.2",
|
||||||
|
"pytest>=7",
|
||||||
|
]
|
||||||
|
files = [
|
||||||
|
{file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
|
||||||
|
{file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.14.5"
|
||||||
|
requires_python = ">=3.7"
|
||||||
|
summary = "An extremely fast Python linter and code formatter, written in Rust."
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621"},
|
||||||
|
{file = "ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4"},
|
||||||
|
{file = "ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1"},
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["pdm-backend"]
|
||||||
|
build-backend = "pdm.backend"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "audiobooksorter"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Command-line tool for organizing Audiobookshelf exports"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
authors = [{ name = "Project Contributors" }]
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[tool.pdm]
|
||||||
|
distribution = true
|
||||||
|
|
||||||
|
[tool.pdm.build]
|
||||||
|
package-dir = "src"
|
||||||
|
includes = ["src/audiobooksorter"]
|
||||||
|
|
||||||
|
[tool.pdm.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=4.1",
|
||||||
|
"ruff>=0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 100
|
||||||
|
extend-select = ["B", "I", "UP", "PT"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["audiobooksorter"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = "--cov=audiobooksorter --cov-report=term-missing --cov-fail-under=90"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
pythonpath = ["src"]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[
|
||||||
|
{"title": "The Final Empire", "series": "Mistborn", "seriesIndex": 1, "author": "Brandon Sanderson"},
|
||||||
|
{"title": "The Well of Ascension", "series": "Mistborn", "seriesIndex": 2, "author": "Brandon Sanderson"},
|
||||||
|
{"title": "Wayfarers", "series": null, "seriesIndex": null, "author": "Becky Chambers"},
|
||||||
|
{"title": "Words of Radiance", "series": "Stormlight Archive", "seriesIndex": 2, "author": "Brandon Sanderson"},
|
||||||
|
{"title": "The Way of Kings", "series": "Stormlight Archive", "seriesIndex": 1, "author": "Brandon Sanderson"}
|
||||||
|
]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Audiobook sorter package."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Command line entry point."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from audiobooksorter import __version__
|
||||||
|
from audiobooksorter.config import DEFAULT_OUTPUT, SorterConfig
|
||||||
|
from audiobooksorter.export import write_sorted
|
||||||
|
from audiobooksorter.ingest import AudiobookRecord, IngestError, load_library
|
||||||
|
from audiobooksorter.logging import configure_logging
|
||||||
|
from audiobooksorter.sorter import sort_by_series
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Sort an Audiobookshelf library into Author/Series/Title folders"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--library",
|
||||||
|
required=True,
|
||||||
|
type=Path,
|
||||||
|
help="Path to the Audiobookshelf library folder (or JSON export for tests)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_OUTPUT,
|
||||||
|
help="Path to write the sorted JSON export (ignored with --dry-run)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Preview the final order instead of writing files",
|
||||||
|
)
|
||||||
|
parser.add_argument("--version", action="version", version=f"audiobooksorter {__version__}")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
logger = configure_logging()
|
||||||
|
|
||||||
|
config = SorterConfig(library_path=args.library, output_path=args.output)
|
||||||
|
|
||||||
|
try:
|
||||||
|
records = load_library(config.library_path)
|
||||||
|
except IngestError as exc:
|
||||||
|
logger.error(str(exc))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sorted_records = sort_by_series(records)
|
||||||
|
if args.dry_run:
|
||||||
|
print(_format_dry_run(sorted_records))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
write_sorted(sorted_records, config.output_path)
|
||||||
|
|
||||||
|
logger.info("Sorted library written to %s", config.output_path)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _format_dry_run(records: Sequence[AudiobookRecord]) -> str:
|
||||||
|
from audiobooksorter.export import _format_title, _sanitize_component
|
||||||
|
|
||||||
|
lines = ["Dry run (no files written):"]
|
||||||
|
|
||||||
|
# Track which author/series combinations we've already shown
|
||||||
|
seen_series: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
author = _sanitize_component(record.author or "Unknown Author")
|
||||||
|
series = _sanitize_component(record.series) if record.series else None
|
||||||
|
|
||||||
|
# Show author/series header if this is the first book in the series
|
||||||
|
if series:
|
||||||
|
key = (author, series)
|
||||||
|
if key not in seen_series:
|
||||||
|
lines.append(f"{author} / {series}")
|
||||||
|
seen_series.add(key)
|
||||||
|
|
||||||
|
# Show the book within the series
|
||||||
|
title = _format_title(record)
|
||||||
|
lines.append(f"{author} / {series} / {title}")
|
||||||
|
else:
|
||||||
|
# Standalone book - show author and title
|
||||||
|
title = _format_title(record)
|
||||||
|
lines.append(f"{author} / {title}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Configuration defaults for audiobooksorter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DEFAULT_OUTPUT = Path("sorted_library")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SorterConfig:
|
||||||
|
library_path: Path
|
||||||
|
output_path: Path = field(default_factory=lambda: DEFAULT_OUTPUT)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Global constants for audiobooksorter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
PACKAGE_NAME = "audiobooksorter"
|
||||||
|
SUPPORTED_VERSION = "1"
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Write sorted libraries to disk."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from audiobooksorter.ingest import AudiobookRecord
|
||||||
|
|
||||||
|
|
||||||
|
def write_sorted(records: Iterable[AudiobookRecord], destination: Path) -> None:
|
||||||
|
"""Materialize the sorted library into author/series/title folders."""
|
||||||
|
destination.mkdir(parents=True, exist_ok=True)
|
||||||
|
for record in records:
|
||||||
|
author_dir = destination / _sanitize_component(record.author or "Unknown Author")
|
||||||
|
if record.series:
|
||||||
|
series_dir = author_dir / _sanitize_component(record.series)
|
||||||
|
else:
|
||||||
|
series_dir = author_dir
|
||||||
|
title_dir = series_dir / _format_title(record)
|
||||||
|
_copy_record(record, title_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_record(record: AudiobookRecord, target: Path) -> None:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if record.source_path and record.source_path.exists():
|
||||||
|
if record.source_path.is_file():
|
||||||
|
# Copy individual audio file
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest_file = target / record.source_path.name
|
||||||
|
shutil.copy2(record.source_path, dest_file)
|
||||||
|
else:
|
||||||
|
# Copy entire directory (for metadata.json based libraries)
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(target)
|
||||||
|
shutil.copytree(record.source_path, target)
|
||||||
|
else:
|
||||||
|
target.mkdir(exist_ok=True)
|
||||||
|
metadata = {
|
||||||
|
"title": record.title,
|
||||||
|
"series": record.series,
|
||||||
|
"seriesIndex": record.series_index,
|
||||||
|
"author": record.author,
|
||||||
|
}
|
||||||
|
(target / "metadata.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
_INVALID_CHARS = str.maketrans({char: "_" for char in '<>:"/\\|?*'})
|
||||||
|
_SPACE_COLLAPSE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_component(value: str) -> str:
|
||||||
|
normalized = _SPACE_COLLAPSE.sub(" ", value or "").strip()
|
||||||
|
sanitized = normalized.translate(_INVALID_CHARS)
|
||||||
|
return sanitized or "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_title(record: AudiobookRecord) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
if record.series_index is not None:
|
||||||
|
parts.append(_format_sequence(record.series_index))
|
||||||
|
parts.append(_sanitize_component(record.title or "Untitled"))
|
||||||
|
return " - ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_sequence(index: float) -> str:
|
||||||
|
if index.is_integer():
|
||||||
|
return f"Book {int(index)}"
|
||||||
|
return f"Book {index:g}"
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Read Audiobookshelf libraries."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, List
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AudiobookRecord:
|
||||||
|
"""Simplified audiobook representation used for sorting."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
series: str | None
|
||||||
|
series_index: float | None
|
||||||
|
author: str
|
||||||
|
source_path: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IngestError(RuntimeError):
|
||||||
|
"""Raised when loading the library fails."""
|
||||||
|
|
||||||
|
|
||||||
|
def load_library(path: Path) -> List[AudiobookRecord]:
|
||||||
|
"""Load audiobook records from a JSON export file or a library directory."""
|
||||||
|
if path.is_file():
|
||||||
|
return _load_from_json(path)
|
||||||
|
if path.is_dir():
|
||||||
|
return _load_from_directory(path)
|
||||||
|
raise IngestError(f"Library path not found: {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_from_json(path: Path) -> List[AudiobookRecord]:
|
||||||
|
"""Load records from a JSON export (used primarily for tests)."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
|
raw_items: Iterable[dict[str, object]] = json.load(handle)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise IngestError(f"Library file not found: {path}") from exc
|
||||||
|
|
||||||
|
records: List[AudiobookRecord] = []
|
||||||
|
for item in raw_items:
|
||||||
|
records.append(
|
||||||
|
AudiobookRecord(
|
||||||
|
title=str(item.get("title", "")),
|
||||||
|
series=_normalize_series(item.get("series")),
|
||||||
|
series_index=_parse_index(item.get("seriesIndex")),
|
||||||
|
author=str(item.get("author", "")),
|
||||||
|
source_path=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _load_from_directory(root: Path) -> List[AudiobookRecord]:
|
||||||
|
"""Walk a folder structure and read each book's metadata.json."""
|
||||||
|
metadata_files = sorted(root.rglob("metadata.json"))
|
||||||
|
if metadata_files:
|
||||||
|
return _load_from_metadata_files(metadata_files)
|
||||||
|
|
||||||
|
series_dirs = sorted(entry for entry in root.iterdir() if entry.is_dir())
|
||||||
|
if not series_dirs:
|
||||||
|
raise IngestError(f"No metadata.json files or audiobook folders found in {root}")
|
||||||
|
|
||||||
|
records: List[AudiobookRecord] = []
|
||||||
|
for series_dir in series_dirs:
|
||||||
|
series_name, author = _parse_series_dir(series_dir.name)
|
||||||
|
for audio_file in sorted(series_dir.glob("*.m4b")):
|
||||||
|
title, index = _parse_audio_filename(audio_file.name, series_name)
|
||||||
|
records.append(
|
||||||
|
AudiobookRecord(
|
||||||
|
title=title,
|
||||||
|
series=series_name,
|
||||||
|
series_index=index,
|
||||||
|
author=author,
|
||||||
|
source_path=audio_file,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
raise IngestError(f"No audiobooks found in {root}")
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _load_from_metadata_files(metadata_files: Iterable[Path]) -> List[AudiobookRecord]:
|
||||||
|
records: List[AudiobookRecord] = []
|
||||||
|
for metadata_file in metadata_files:
|
||||||
|
try:
|
||||||
|
raw_item = json.loads(metadata_file.read_text(encoding="utf-8"))
|
||||||
|
except OSError as exc:
|
||||||
|
raise IngestError(f"Failed to read {metadata_file}") from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise IngestError(f"Invalid metadata in {metadata_file}") from exc
|
||||||
|
|
||||||
|
records.append(
|
||||||
|
AudiobookRecord(
|
||||||
|
title=str(raw_item.get("title") or metadata_file.parent.name),
|
||||||
|
series=_normalize_series(raw_item.get("series")),
|
||||||
|
series_index=_parse_index(raw_item.get("seriesIndex")),
|
||||||
|
author=str(raw_item.get("author") or "Unknown Author"),
|
||||||
|
source_path=metadata_file.parent,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_series(raw_series: object | None) -> str | None:
|
||||||
|
series = (raw_series or "").strip()
|
||||||
|
return series or None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_index(raw_index: object | None) -> float | None:
|
||||||
|
if raw_index is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(raw_index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_audio_filename(filename: str, fallback_series: str) -> tuple[str, float | None]:
|
||||||
|
"""Extract title and index from an audiobook filename."""
|
||||||
|
parts = [segment.strip() for segment in Path(filename).stem.split("-") if segment.strip()]
|
||||||
|
book_index: float | None = None
|
||||||
|
title_parts: list[str] = []
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
match = re.fullmatch(r"(?i)book\s*(\d+(?:\.\d+)?)", part)
|
||||||
|
if match:
|
||||||
|
book_index = float(match.group(1))
|
||||||
|
continue
|
||||||
|
title_parts.append(part)
|
||||||
|
|
||||||
|
title = " - ".join(title_parts) if title_parts else fallback_series
|
||||||
|
return title, book_index
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_series_dir(dirname: str) -> tuple[str, str]:
|
||||||
|
"""Infer series and author from a directory name."""
|
||||||
|
parts = [section.strip() for section in dirname.split("-") if section.strip()]
|
||||||
|
series = parts[0] if parts else dirname
|
||||||
|
author = parts[1] if len(parts) > 1 else "Unknown Author"
|
||||||
|
return series, author
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Shared logging helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
_LOG_LEVEL: Final = "AUDIOBOOKSORTER_LOG_LEVEL"
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(name: str = "audiobooksorter") -> logging.Logger:
|
||||||
|
"""Return a configured logger using env override when provided."""
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
if logger.handlers:
|
||||||
|
return logger
|
||||||
|
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
logger.addHandler(handler)
|
||||||
|
|
||||||
|
level = logging.getLevelName(logging.getLogger().level)
|
||||||
|
logger.setLevel(level)
|
||||||
|
return logger
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"""Sorting utilities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .series_rules import sort_by_series
|
||||||
|
|
||||||
|
__all__ = ["sort_by_series"]
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Series-based sorting rules."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Iterable, List, Sequence
|
||||||
|
|
||||||
|
from audiobooksorter.ingest import AudiobookRecord
|
||||||
|
|
||||||
|
_AUTHOR_MULTI_SEPARATOR = re.compile(r"\s*(?:&|;| and )\s*", re.IGNORECASE)
|
||||||
|
_SPACE_COLLAPSE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def sort_by_series(records: Iterable[AudiobookRecord]) -> List[AudiobookRecord]:
|
||||||
|
"""Return records ordered by author, series, series index, and title."""
|
||||||
|
|
||||||
|
def sort_key(record: AudiobookRecord) -> tuple:
|
||||||
|
return (
|
||||||
|
_author_sort_key(record.author),
|
||||||
|
_series_sort_key(record.series),
|
||||||
|
_series_index_value(record.series_index),
|
||||||
|
_title_key(record.title),
|
||||||
|
)
|
||||||
|
|
||||||
|
return sorted(records, key=sort_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _author_sort_key(author: str) -> Sequence[str]:
|
||||||
|
names = _split_authors(author)
|
||||||
|
if not names:
|
||||||
|
return ("~unknown",)
|
||||||
|
return tuple(_normalize_name(name) for name in names)
|
||||||
|
|
||||||
|
|
||||||
|
def _series_sort_key(series: str | None) -> tuple[int, str]:
|
||||||
|
if series:
|
||||||
|
return (0, _normalize_name(series))
|
||||||
|
return (1, "")
|
||||||
|
|
||||||
|
|
||||||
|
def _series_index_value(index: float | None) -> float:
|
||||||
|
return index if index is not None else float("inf")
|
||||||
|
|
||||||
|
|
||||||
|
def _title_key(title: str) -> str:
|
||||||
|
return _normalize_name(title)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_authors(author_field: str) -> list[str]:
|
||||||
|
chunks = _AUTHOR_MULTI_SEPARATOR.split(author_field or "")
|
||||||
|
names: list[str] = []
|
||||||
|
for chunk in chunks:
|
||||||
|
normalized_chunk = chunk.strip()
|
||||||
|
if not normalized_chunk:
|
||||||
|
continue
|
||||||
|
names.extend(_split_last_first_chunk(normalized_chunk))
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _split_last_first_chunk(chunk: str) -> list[str]:
|
||||||
|
pieces = [piece.strip() for piece in chunk.split(",") if piece.strip()]
|
||||||
|
if len(pieces) <= 1:
|
||||||
|
return pieces
|
||||||
|
if len(pieces) % 2 == 0 and all(" " not in pieces[i] for i in range(0, len(pieces), 2)):
|
||||||
|
paired: list[str] = []
|
||||||
|
for i in range(0, len(pieces), 2):
|
||||||
|
last = pieces[i]
|
||||||
|
first = pieces[i + 1]
|
||||||
|
paired.append(f"{first} {last}".strip())
|
||||||
|
return paired
|
||||||
|
return pieces
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_name(value: str) -> str:
|
||||||
|
collapsed = _SPACE_COLLAPSE.sub(" ", value or "").strip()
|
||||||
|
return collapsed.casefold()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def samples_dir() -> Path:
|
||||||
|
return Path(__file__).parent.parent / "samples"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from audiobooksorter.export import write_sorted
|
||||||
|
from audiobooksorter.ingest import AudiobookRecord
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sorted_copies_source_directories(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "library"
|
||||||
|
book_dir = source / "Brandon" / "Mistborn1"
|
||||||
|
book_dir.mkdir(parents=True)
|
||||||
|
(book_dir / "chapter1.mp3").write_text("audio-data", encoding="utf-8")
|
||||||
|
|
||||||
|
destination = tmp_path / "sorted"
|
||||||
|
record = AudiobookRecord(
|
||||||
|
title="The Final Empire",
|
||||||
|
series="Mistborn",
|
||||||
|
series_index=1,
|
||||||
|
author="Brandon Sanderson",
|
||||||
|
source_path=book_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
write_sorted([record], destination)
|
||||||
|
|
||||||
|
expected = destination / "Brandon Sanderson" / "Mistborn" / "Book 1 - The Final Empire"
|
||||||
|
assert (expected / "chapter1.mp3").read_text(encoding="utf-8") == "audio-data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_sorted_creates_metadata_when_missing_source(tmp_path: Path) -> None:
|
||||||
|
destination = tmp_path / "sorted"
|
||||||
|
record = AudiobookRecord(
|
||||||
|
title="",
|
||||||
|
series=None,
|
||||||
|
series_index=None,
|
||||||
|
author="",
|
||||||
|
source_path=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
write_sorted([record], destination)
|
||||||
|
|
||||||
|
expected = destination / "Unknown Author" / "Untitled"
|
||||||
|
metadata = expected / "metadata.json"
|
||||||
|
assert metadata.exists()
|
||||||
|
assert '"title": ""' in metadata.read_text(encoding="utf-8")
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from audiobooksorter.ingest import IngestError, load_library
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_library_from_json(samples_dir: Path) -> None:
|
||||||
|
records = load_library(samples_dir / "library.json")
|
||||||
|
|
||||||
|
assert len(records) == 5
|
||||||
|
assert records[0].title == "The Final Empire"
|
||||||
|
assert records[0].source_path is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_library_from_directory(tmp_path: Path) -> None:
|
||||||
|
library = tmp_path / "library"
|
||||||
|
book_one = library / "Mistborn1"
|
||||||
|
book_two = library / "Wayfarers"
|
||||||
|
book_one.mkdir(parents=True)
|
||||||
|
book_two.mkdir(parents=True)
|
||||||
|
|
||||||
|
(book_one / "metadata.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"title": "The Final Empire", "series": "Mistborn", "seriesIndex": 1, "author": "Brandon"}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(book_two / "metadata.json").write_text(
|
||||||
|
json.dumps({"title": "Wayfarers", "author": "Becky Chambers"}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
records = load_library(library)
|
||||||
|
|
||||||
|
sources = {record.title: record.source_path for record in records}
|
||||||
|
assert sources["The Final Empire"] == book_one
|
||||||
|
assert sources["Wayfarers"] == book_two
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_library_raises(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(IngestError):
|
||||||
|
load_library(tmp_path / "missing")
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from audiobooksorter.ingest import AudiobookRecord
|
||||||
|
from audiobooksorter.sorter import sort_by_series
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sample(path: Path) -> list[AudiobookRecord]:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return [
|
||||||
|
AudiobookRecord(
|
||||||
|
title=item["title"],
|
||||||
|
series=item.get("series"),
|
||||||
|
series_index=item.get("seriesIndex"),
|
||||||
|
author=item["author"],
|
||||||
|
)
|
||||||
|
for item in data
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sorting_orders_by_author_then_series(samples_dir: Path) -> None:
|
||||||
|
records = _load_sample(samples_dir / "library.json")
|
||||||
|
|
||||||
|
sorted_records = sort_by_series(records)
|
||||||
|
|
||||||
|
titles = [record.title for record in sorted_records]
|
||||||
|
assert titles == [
|
||||||
|
"Wayfarers", # Becky Chambers
|
||||||
|
"The Final Empire",
|
||||||
|
"The Well of Ascension",
|
||||||
|
"The Way of Kings",
|
||||||
|
"Words of Radiance",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_parsing_handles_last_first_pairs() -> None:
|
||||||
|
records = [
|
||||||
|
AudiobookRecord(
|
||||||
|
title="A Solo Standard",
|
||||||
|
series=None,
|
||||||
|
series_index=None,
|
||||||
|
author="Ichiro Kishimi",
|
||||||
|
),
|
||||||
|
AudiobookRecord(
|
||||||
|
title="B Solo LastFirst",
|
||||||
|
series=None,
|
||||||
|
series_index=None,
|
||||||
|
author="Kishimi, Ichiro",
|
||||||
|
),
|
||||||
|
AudiobookRecord(
|
||||||
|
title="C Ampersand",
|
||||||
|
series=None,
|
||||||
|
series_index=None,
|
||||||
|
author="Ichiro Kishimi & Fumitake Koga",
|
||||||
|
),
|
||||||
|
AudiobookRecord(
|
||||||
|
title="D Comma List",
|
||||||
|
series=None,
|
||||||
|
series_index=None,
|
||||||
|
author="Kishimi, Ichiro, Koga, Fumitake",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
sorted_records = sort_by_series(records)
|
||||||
|
|
||||||
|
titles = [record.title for record in sorted_records]
|
||||||
|
assert titles == [
|
||||||
|
"A Solo Standard",
|
||||||
|
"B Solo LastFirst",
|
||||||
|
"C Ampersand",
|
||||||
|
"D Comma List",
|
||||||
|
]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from audiobooksorter.cli import main
|
||||||
|
|
||||||
|
|
||||||
|
def _make_book(folder: Path, title: str, author: str, series: str | None, index: int | None) -> None:
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
(folder / "metadata.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{"title": title, "author": author, "series": series, "seriesIndex": index},
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(folder / f"{title}.txt").write_text(title, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_sorts_library(tmp_path: Path) -> None:
|
||||||
|
library = tmp_path / "input"
|
||||||
|
_make_book(library / "BookA", "Wayfarers", "Becky Chambers", None, None)
|
||||||
|
_make_book(library / "BookB", "The Final Empire", "Brandon Sanderson", "Mistborn", 1)
|
||||||
|
|
||||||
|
output = tmp_path / "output"
|
||||||
|
|
||||||
|
exit_code = main(["--library", str(library), "--output", str(output)])
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
assert (output / "Becky Chambers" / "Wayfarers" / "Wayfarers.txt").read_text(encoding="utf-8") == "Wayfarers"
|
||||||
|
assert (
|
||||||
|
output / "Brandon Sanderson" / "Mistborn" / "Book 1 - The Final Empire" / "The Final Empire.txt"
|
||||||
|
).read_text(encoding="utf-8") == "The Final Empire"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_dry_run_shows_directory_structure(tmp_path: Path, capsys: object) -> None:
|
||||||
|
library = tmp_path / "input"
|
||||||
|
_make_book(library / "BookA", "All the Skills", "Honour Rae", "All the Skills", 1)
|
||||||
|
_make_book(library / "BookB", "All the Skills", "Honour Rae", "All the Skills", 2)
|
||||||
|
_make_book(library / "BookC", "The Wandering Inn", "PirateAba", "The Wandering Inn", 1)
|
||||||
|
_make_book(library / "BookD", "Fae and Fare", "PirateAba", "The Wandering Inn", 2)
|
||||||
|
_make_book(library / "BookE", "Domestication", "Seth Ring", "Battle Mage Farmer", 1)
|
||||||
|
|
||||||
|
exit_code = main(["--library", str(library), "--dry-run"])
|
||||||
|
|
||||||
|
assert exit_code == 0
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "Honour Rae / All the Skills" in captured.out
|
||||||
|
assert "Honour Rae / All the Skills / Book 1 - All the Skills" in captured.out
|
||||||
|
assert "Honour Rae / All the Skills / Book 2 - All the Skills" in captured.out
|
||||||
|
assert "PirateAba / The Wandering Inn" in captured.out
|
||||||
|
assert "PirateAba / The Wandering Inn / Book 1 - The Wandering Inn" in captured.out
|
||||||
|
assert "PirateAba / The Wandering Inn / Book 2 - Fae and Fare" in captured.out
|
||||||
|
assert "Seth Ring / Battle Mage Farmer" in captured.out
|
||||||
|
assert "Seth Ring / Battle Mage Farmer / Book 1 - Domestication" in captured.out
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from audiobooksorter import constants
|
||||||
|
|
||||||
|
|
||||||
|
def test_constants_exposed() -> None:
|
||||||
|
assert constants.PACKAGE_NAME == "audiobooksorter"
|
||||||
|
assert constants.SUPPORTED_VERSION == "1"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from audiobooksorter.cli import _format_dry_run
|
||||||
|
from audiobooksorter.ingest import load_library
|
||||||
|
from audiobooksorter.sorter import sort_by_series
|
||||||
|
|
||||||
|
|
||||||
|
def test_loads_directory_without_metadata_files() -> None:
|
||||||
|
records = load_library(Path("testdata"))
|
||||||
|
|
||||||
|
assert len(records) == 14
|
||||||
|
series_names = {record.series for record in records}
|
||||||
|
assert {"All the Skills", "Battle Mage Farmer", "The Wandering Inn"} <= series_names
|
||||||
|
|
||||||
|
battle_indexes = sorted(
|
||||||
|
record.series_index for record in records if record.series == "Battle Mage Farmer"
|
||||||
|
)
|
||||||
|
assert battle_indexes == [1.0, 2.0, 3.0, 4.0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dry_run_formats_preview() -> None:
|
||||||
|
records = sort_by_series(load_library(Path("testdata")))
|
||||||
|
|
||||||
|
preview = _format_dry_run(records)
|
||||||
|
|
||||||
|
assert "Dry run (no files written)" in preview
|
||||||
|
assert "Seth Ring / Battle Mage Farmer" in preview
|
||||||
|
assert "Seth Ring / Battle Mage Farmer / Book 1" in preview
|
||||||
|
assert "PirateAba / The Wandering Inn" in preview
|
||||||
|
assert "PirateAba / The Wandering Inn / Book 1" in preview
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from audiobooksorter.logging import configure_logging
|
||||||
|
|
||||||
|
|
||||||
|
def test_configure_logging_reuses_logger() -> None:
|
||||||
|
logger = configure_logging("audiobooksorter-tests")
|
||||||
|
logger.info("hello")
|
||||||
|
# calling again should not attach additional handlers
|
||||||
|
logger_again = configure_logging("audiobooksorter-tests")
|
||||||
|
assert logger is logger_again
|
||||||
|
assert len(logger.handlers) == 1
|
||||||
Reference in New Issue
Block a user