feat: implement chapter splitting with hierarchical output and short CLI options

- Add comprehensive chapter splitting functionality
- Support hierarchical and flat output structures
- Add smart chapter merging to prevent audio gaps
- Implement flexible chapter naming formats
- Add duration filtering for chapters
- Add short CLI options for all flags (-v, -d, -f, -t, -m, -T)
- Update README with complete documentation
- Add support for OGG and M4A output formats
- Improve error handling and validation
This commit is contained in:
2025-09-18 20:49:28 +02:00
parent f8cca3e848
commit 9165c0e95d
8 changed files with 1245 additions and 164 deletions

231
Cargo.lock generated
View File

@@ -90,7 +90,7 @@ dependencies = [
[[package]]
name = "audible-util"
version = "0.1.0"
version = "0.3.0"
dependencies = [
"Inflector",
"anyhow",
@@ -115,9 +115,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
[[package]]
name = "bitflags"
version = "2.6.0"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "bstr"
@@ -190,28 +190,39 @@ checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "console"
version = "0.15.11"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
name = "convert_case"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "crossterm"
version = "0.28.1"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix 0.38.42",
"rustix",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -226,6 +237,27 @@ dependencies = [
"winapi",
]
[[package]]
name = "derive_more"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "difflib"
version = "0.4.0"
@@ -239,10 +271,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
[[package]]
name = "either"
version = "1.15.0"
name = "document-features"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d"
dependencies = [
"litrs",
]
[[package]]
name = "encode_unicode"
@@ -260,6 +295,12 @@ dependencies = [
"regex",
]
[[package]]
name = "env_home"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe"
[[package]]
name = "env_logger"
version = "0.11.8"
@@ -316,25 +357,16 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "home"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "indicatif"
version = "0.17.11"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
checksum = "70a646d946d06bedbbc4cac4c218acf4bbf2d87757a784857025f4d447e4e1cd"
dependencies = [
"console",
"number_prefix",
"portable-atomic",
"unicode-width",
"unit-prefix",
"web-time",
]
@@ -390,18 +422,18 @@ version = "0.2.169"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a"
[[package]]
name = "linux-raw-sys"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
[[package]]
name = "linux-raw-sys"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
[[package]]
name = "litrs"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5"
[[package]]
name = "lock_api"
version = "0.4.12"
@@ -451,12 +483,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -483,7 +509,7 @@ dependencies = [
"libc",
"redox_syscall",
"smallvec",
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -593,19 +619,6 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rustix"
version = "0.38.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.4.14",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.0.8"
@@ -615,7 +628,7 @@ dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.9.4",
"linux-raw-sys",
"windows-sys 0.59.0",
]
@@ -725,7 +738,7 @@ dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix 1.0.8",
"rustix",
"windows-sys 0.59.0",
]
@@ -741,12 +754,24 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]]
name = "unit-prefix"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323402cff2dd658f39ca17c789b502021b3f18707c91cdf22e3838e1b4023817"
[[package]]
name = "utf8parse"
version = "0.2.2"
@@ -846,13 +871,12 @@ dependencies = [
[[package]]
name = "which"
version = "6.0.3"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f"
checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d"
dependencies = [
"either",
"home",
"rustix 0.38.42",
"env_home",
"rustix",
"winsafe",
]
@@ -884,7 +908,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
@@ -893,7 +917,16 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.2",
]
[[package]]
@@ -902,14 +935,30 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
"windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
"windows_x86_64_msvc 0.52.6",
]
[[package]]
name = "windows-targets"
version = "0.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef"
dependencies = [
"windows_aarch64_gnullvm 0.53.0",
"windows_aarch64_msvc 0.53.0",
"windows_i686_gnu 0.53.0",
"windows_i686_gnullvm 0.53.0",
"windows_i686_msvc 0.53.0",
"windows_x86_64_gnu 0.53.0",
"windows_x86_64_gnullvm 0.53.0",
"windows_x86_64_msvc 0.53.0",
]
[[package]]
@@ -918,48 +967,96 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_aarch64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_i686_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnu"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "windows_x86_64_msvc"
version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winsafe"
version = "0.0.19"

View File

@@ -1,19 +1,20 @@
[package]
name = "audible-util"
version = "0.1.0"
version = "0.3.0"
edition = "2021"
description = "A utility for converting Audible .aaxc files to common audio formats (mp3, wav, flac), with optional splitting by chapters."
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
clap = { version = "4.5", features = ["derive"] }
crossterm = "0.28"
crossterm = "0.29"
Inflector = { version = "0.11", default-features = false }
anyhow = "1.0"
log = "0.4"
env_logger = "0.11"
indicatif = "0.17"
which = "6.0"
indicatif = "0.18"
which = "8.0"
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3.10"

158
README.md
View File

@@ -6,13 +6,18 @@
## Features
- Converts Audible `.aaxc` files to MP3, WAV, or FLAC.
- Converts Audible `.aaxc` files to MP3, WAV, FLAC, OGG, or M4A.
- Uses voucher files from `audible-cli` for decryption.
- Automatically infers voucher file if not specified.
- **Chapter splitting** - Split audiobooks into individual chapter files.
- **Hierarchical chapter organization** - Organize chapters into folders based on book structure.
- **Smart chapter merging** - Merge short chapters to prevent audio gaps.
- **Flexible naming formats** - Customize how chapter files are named.
- **Duration filtering** - Filter out chapters below a minimum duration.
- Extensible output format system (easy to add new formats).
- Planned support for splitting output into chapters/segments.
- Progress indication and detailed logging.
- Helpful error messages and validation.
- **Short CLI options** - Use `-s`, `-v`, `-o`, etc. for faster command entry.
---
@@ -60,31 +65,53 @@ If any of these checks fail, you will receive a clear, actionable error message.
### Basic Command
```sh
audible-util --aaxc-path /path/to/book.aaxc --voucher-path /path/to/book.voucher
audible-util -a /path/to/book.aaxc -v /path/to/book.voucher
```
If `--voucher-path` is omitted, the tool will look for a voucher file named `<book>.voucher` in the same directory as the `.aaxc` file.
If `-v` is omitted, the tool will look for a voucher file named `<book>.voucher` in the same directory as the `.aaxc` file.
### CLI Options
| Option | Short | Type | Required | Description |
|-----------------------|-------|--------------|----------|-----------------------------------------------------------------------------|
| `--aaxc-path` | `-a` | Path | Yes | Path to the input `.aaxc` file |
| `--voucher-path` | | Path | No | Path to the voucher file (from audible-cli). Inferred if not provided. |
| `--output-path` | | Path | No | Output file path. Defaults to `<album>.<ext>` in current directory. |
| `--split` | `-s` | Flag | No | (Planned) Split output into chapters/segments. Currently not implemented. |
| `--output-type` | | mp3/wav/flac | No | Output file type. Defaults to `mp3`. |
| Option | Short | Type | Required | Description |
|-----------------------------|-------|--------------|----------|-----------------------------------------------------------------------------|
| `--aaxc-path` | `-a` | Path | Yes | Path to the input `.aaxc` file |
| `--voucher-path` | `-v` | Path | No | Path to the voucher file (from audible-cli). Inferred if not provided. |
| `--output-path` | `-o` | Path | No | Output file or directory. Defaults to `<album>.<ext>` in current directory. |
| `--split` | `-s` | Flag | No | Split output into chapters/segments. Requires chapters.json file. |
| `--min-chapter-duration` | `-d` | Seconds | No | Minimum chapter duration in seconds. Default: 0 (no minimum). |
| `--chapter-naming-format` | `-f` | Format | No | Chapter naming format. Default: `chapter-number-title`. |
| `--split-structure` | `-t` | Structure | No | Output structure: `flat` or `hierarchical`. Default: `flat`. |
| `--merge-short-chapters` | `-m` | Flag | No | Merge short chapters with next chapter instead of filtering them out. |
| `--output-type` | `-T` | Format | No | Output file type. Default: `mp3`. Supports: mp3, wav, flac, ogg, m4a. |
#### Example: Convert to FLAC with custom output path
```sh
audible-util --aaxc-path book.aaxc --voucher-path book.voucher --output-type flac --output-path mybook.flac
audible-util -a book.aaxc -v book.voucher -T flac -o mybook.flac
```
#### Example: Use default voucher and output
```sh
audible-util --aaxc-path book.aaxc
audible-util -a book.aaxc
```
#### Example: Split into chapters with hierarchical structure
```sh
audible-util -a book.aaxc -v book.voucher -s -t hierarchical -o chapters/
```
#### Example: Split with short chapter merging and custom naming
```sh
audible-util -a book.aaxc -v book.voucher -s -d 10 -m -f number-title -o chapters/
```
#### Example: Full featured command
```sh
audible-util -a book.aaxc -v book.voucher -s -d 5 -f chapter-number-title -t hierarchical -m -T flac -o output_dir/
```
---
@@ -99,19 +126,61 @@ audible-util --aaxc-path book.aaxc
## Output Formats
- **MP3** (default): `--output-type mp3`
- **WAV**: `--output-type wav`
- **FLAC**: `--output-type flac`
- **MP3** (default): `-T mp3`
- **WAV**: `-T wav`
- **FLAC**: `-T flac`
- **OGG**: `-T ogg`
- **M4A**: `-T m4a`
The output format system is extensible. To add a new format, implement the `OutputFormat` trait in [`src/cli.rs`](src/cli.rs:30).
## Chapter Splitting
The tool can split audiobooks into individual chapter files using chapter metadata from a `chapters.json` file.
### Chapter File Requirements
- The chapter file must be named `<book>-chapters.json` and placed in the same directory as the `.aaxc` file.
- The file must contain valid JSON with chapter timing information.
- The tool will automatically infer the chapter file path if not explicitly provided.
### Chapter Naming Formats
- **`chapter-number-title`** (default): `Chapter01_Title.mp3`
- **`number-title`**: `01_Title.mp3`
- **`title-only`**: `Title.mp3`
### Output Structures
- **`flat`** (default): All chapters in a single directory
- **`hierarchical`**: Organize chapters into folders based on book structure (e.g., `Part_One/Chapter01.mp3`)
### Chapter Processing Options
- **Minimum Duration**: Filter out chapters shorter than specified duration (`-d` seconds)
- **Merge Short Chapters**: Merge short chapters with the next chapter to prevent audio gaps (`-m`)
- **Smart Filtering**: Automatically handles chapters with no content or very short durations
---
## Splitting Functionality
## Advanced Features
- The `--split` flag is present but **not yet implemented**.
- Planned: Split output into chapters or segments using metadata.
- To extend: Implement logic to parse chapter metadata and invoke ffmpeg for each segment, using the `OutputFormat` trait for extensibility. See code comments in [`src/main.rs`](src/main.rs:108) for guidance.
### Progress Tracking
The tool provides detailed progress information during conversion:
- Real-time ffmpeg progress for each chapter
- Chapter-by-chapter conversion status
- File counting and validation
- Detailed logging with `RUST_LOG=info`
### Error Handling
Comprehensive error handling with clear, actionable messages:
- Input file validation (existence, readability, format)
- Voucher file validation and parsing
- Chapter file validation and parsing
- Output directory creation and permission checks
- External tool availability checks
---
@@ -144,6 +213,15 @@ The output format system is extensible. To add a new format, implement the `Outp
- **"Failed to parse voucher file"**
The voucher file must be valid JSON generated by `audible-cli`.
- **"Chapter file does not exist"**
The tool requires a `chapters.json` file when using `-s`. Place it in the same directory as the `.aaxc` file.
- **"Failed to parse chapter file"**
The chapter file must be valid JSON with proper chapter timing information.
- **"No chapters found after filtering"**
All chapters were filtered out due to minimum duration requirements. Try reducing `-d` or using `-m` to merge short chapters.
- **"Failed to start ffmpeg/ffprobe"**
Ensure `ffmpeg` and `ffprobe` are installed and available in your `PATH`.
@@ -152,7 +230,7 @@ The output format system is extensible. To add a new format, implement the `Outp
- For verbose logs, set the `RUST_LOG` environment variable:
```sh
RUST_LOG=info audible-util --aaxc-path book.aaxc
RUST_LOG=info audible-util -a book.aaxc
```
---
@@ -176,6 +254,44 @@ For questions, issues, or suggestions, please open an issue on the [GitHub repos
---
## Quick Reference
### Most Common Commands
```sh
# Basic conversion
audible-util -a book.aaxc
# Convert with custom output
audible-util -a book.aaxc -v book.voucher -o output.mp3
# Split into chapters (flat structure)
audible-util -a book.aaxc -s -o chapters/
# Split with hierarchical structure
audible-util -a book.aaxc -s -t hierarchical -o chapters/
# Split with short chapter merging
audible-util -a book.aaxc -s -d 10 -m -o chapters/
# Full featured command
audible-util -a book.aaxc -v book.voucher -s -d 5 -f chapter-number-title -t hierarchical -m -T flac -o output_dir/
```
### Short Options Summary
- `-a` = `--aaxc-path` (required)
- `-v` = `--voucher-path`
- `-o` = `--output-path`
- `-s` = `--split`
- `-d` = `--min-chapter-duration`
- `-f` = `--chapter-naming-format`
- `-t` = `--split-structure`
- `-m` = `--merge-short-chapters`
- `-T` = `--output-type`
---
## License
This project is licensed under the MIT License.

View File

@@ -1,26 +1,19 @@
use std::path::PathBuf;
use clap::{Parser, ValueEnum};
use crate::models::ChapterNamingFormat;
#[derive(Parser)]
#[command(
name = "audible-util",
about = "A utility for converting Audible .aaxc files to common audio formats (mp3, wav, flac), with optional splitting by chapters.\n\n\
USAGE EXAMPLES:\n\
audible-util -a mybook.aaxc --voucher_path my.voucher --output_type mp3 --split\n\
audible-util -a mybook.aaxc --output_path output.wav\n\n\
TIPS:\n\
- You must provide a valid .aaxc file as input.\n\
- A voucher file is required for decryption. See --voucher_path for details.\n\
- Ensure ffmpeg is installed and available in your PATH.\n\
- Splitting is only supported for formats that support chapters (e.g., mp3, flac).\n\
- Output file type defaults to wav if not specified.\n"
about,
version,
)]
pub struct Cli {
/// Path to the input .aaxc file to convert.
///
/// Example: -a mybook.aaxc
#[clap(short = 'a', long = "aaxc_path", value_name = "AAXC_FILE", help = "Path to the input .aaxc file to convert. This file must be downloaded from Audible. Example: -a mybook.aaxc")]
#[clap(short = 'a', long = "aaxc_path", value_name = "AAXC_FILE", help = "Input .aaxc file")]
pub aaxc_path: PathBuf,
/// Path to the voucher file required for decryption.
@@ -28,7 +21,7 @@ pub struct Cli {
/// The voucher file is needed to decrypt the .aaxc file. You can obtain it using the Audible app or other tools.
/// Example: --voucher_path my.voucher
/// TIP: The voucher file must match the account used to download the .aaxc file.
#[clap(long, value_name = "VOUCHER_FILE", help = "Path to the voucher file required for decryption. Example: --voucher_path my.voucher. TIP: The voucher file must match the account used to download the .aaxc file.")]
#[clap(short = 'v', long, value_name = "VOUCHER_FILE", help = "Voucher file for decryption")]
pub voucher_path: Option<PathBuf>,
/// Path to the output audio file or directory.
@@ -38,26 +31,56 @@ pub struct Cli {
/// If not specified, the output file will be created in the current directory with the same base name as the input.
/// Example: --output_path output.mp3 or --output_path /path/to/output_dir
#[clap(
short,
long,
value_name = "OUTPUT_PATH",
help = "Path to the output audio file or directory. If a file path is provided, it will be used as the output file. If a directory is provided, the output file will be created inside that directory using the default naming scheme (e.g., <album>.<ext>). If not specified, the output will be placed in the current directory."
help = "Output file or directory (default: current dir)"
)]
pub output_path: Option<PathBuf>,
/// Split the output audio file by chapters.
///
/// If set, the output will be split into separate files for each chapter (if chapter information is available).
/// NOTE: Splitting is only supported for formats that support chapters (e.g., mp3, flac).
#[clap(short, long, help = "Split the output audio file by chapters. Only supported for formats that support chapters (e.g., mp3, flac).")]
/// Requires a chapters.json file in the same directory as the .aaxc file.
#[clap(short, long, help = "Split output by chapters")]
pub split: bool,
/// Minimum chapter duration in seconds.
///
/// Chapters shorter than this duration will be skipped when splitting.
/// Default: 0 (no minimum duration).
#[clap(short = 'd', long, value_name = "SECONDS", help = "Minimum chapter duration in seconds")]
pub min_chapter_duration: Option<u64>,
/// Chapter naming format.
///
/// Controls how chapter files are named when splitting.
/// Available formats: chapter-number-title, number-title, title-only, custom
#[clap(short = 'f', long, value_enum, value_name = "FORMAT", default_value = "chapter-number-title", help = "Chapter naming format")]
pub chapter_naming_format: ChapterNamingFormat,
/// Output structure for split chapters.
///
/// Controls how chapter files are organized when splitting.
/// - flat: All chapters in a single directory
/// - hierarchical: Create folders based on chapter hierarchy
#[clap(short = 't', long, value_enum, value_name = "STRUCTURE", default_value = "flat", help = "Output structure for split chapters")]
pub split_structure: SplitStructure,
/// Merge short chapters with the next chapter instead of filtering them out.
///
/// When enabled, chapters shorter than --min-chapter-duration will be merged
/// with the next chapter instead of being filtered out. This prevents gaps
/// in the audio timeline while still allowing filtering of very short content.
#[clap(short = 'm', long, help = "Merge short chapters with next chapter instead of filtering them out")]
pub merge_short_chapters: bool,
/// Output file type/format.
///
/// Supported values: mp3, wav, flac
/// Supported values: mp3, wav, flac, ogg, m4a
/// Example: --output_type mp3
/// If not specified, defaults to wav.
#[clap(long, value_enum, value_name = "TYPE", help = "Output file type/format. Supported: mp3, wav, flac. Example: --output_type mp3. Defaults to wav if not specified.")]
pub output_type: Option<OutputType>,
#[clap(short = 'T', long, value_enum, value_name = "TYPE", default_value = "mp3", help = "Output format")]
pub output_type: OutputType,
}
pub trait OutputFormat {
@@ -68,6 +91,8 @@ pub trait OutputFormat {
pub struct Mp3Format;
pub struct WavFormat;
pub struct FlacFormat;
pub struct AacFormat;
pub struct OggFormat;
impl OutputFormat for Mp3Format {
fn codec(&self) -> &'static str { "mp3" }
@@ -81,6 +106,14 @@ impl OutputFormat for FlacFormat {
fn codec(&self) -> &'static str { "flac" }
fn extension(&self) -> &'static str { "flac" }
}
impl OutputFormat for AacFormat {
fn codec(&self) -> &'static str { "aac" }
fn extension(&self) -> &'static str { "m4a" }
}
impl OutputFormat for OggFormat {
fn codec(&self) -> &'static str { "vorbis" }
fn extension(&self) -> &'static str { "ogg" }
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum OutputType {
@@ -90,6 +123,18 @@ pub enum OutputType {
Wav,
/// Free Lossless Audio Codec (.flac)
Flac,
/// Advanced Audio Coding (.m4a)
M4a,
/// Ogg Vorbis Audio (.ogg)
Ogg
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum SplitStructure {
/// All chapters in a single directory
Flat,
/// Create folders based on chapter hierarchy
Hierarchical,
}
impl OutputType {
@@ -98,6 +143,40 @@ impl OutputType {
OutputType::Mp3 => Box::new(Mp3Format),
OutputType::Wav => Box::new(WavFormat),
OutputType::Flac => Box::new(FlacFormat),
OutputType::M4a => Box::new(AacFormat),
OutputType::Ogg => Box::new(OggFormat),
}
}
}
impl ValueEnum for ChapterNamingFormat {
fn value_variants<'a>() -> &'a [Self] {
&[
ChapterNamingFormat::ChapterNumberTitle,
ChapterNamingFormat::NumberTitle,
ChapterNamingFormat::TitleOnly,
]
}
fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
match self {
ChapterNamingFormat::ChapterNumberTitle => Some(clap::builder::PossibleValue::new("chapter-number-title")),
ChapterNamingFormat::NumberTitle => Some(clap::builder::PossibleValue::new("number-title")),
ChapterNamingFormat::TitleOnly => Some(clap::builder::PossibleValue::new("title-only")),
ChapterNamingFormat::Custom(_) => None, // Custom formats are handled separately
}
}
fn from_str(input: &str, _ignore_case: bool) -> Result<Self, String> {
match input {
"chapter-number-title" => Ok(ChapterNamingFormat::ChapterNumberTitle),
"number-title" => Ok(ChapterNamingFormat::NumberTitle),
"title-only" => Ok(ChapterNamingFormat::TitleOnly),
custom if custom.starts_with("custom:") => {
let pattern = custom.strip_prefix("custom:").unwrap().to_string();
Ok(ChapterNamingFormat::Custom(pattern))
},
_ => Err(format!("Invalid chapter naming format: {}. Valid options: chapter-number-title, number-title, title-only, custom:pattern", input)),
}
}
}

View File

@@ -1,7 +1,8 @@
mod cli;
mod models;
use crate::models::FFProbeFormat;
use crate::models::{FFProbeFormat, AudibleChapters, FlattenedChapter, MergedChapter, ChapterNamingFormat};
use crate::cli::SplitStructure;
use clap::Parser;
use inflector::Inflector;
use std::path::{Path, PathBuf};
@@ -11,7 +12,7 @@ use std::{
process::{Command, Stdio},
};
use anyhow::{Context, Result};
use log::{info, warn, error};
use log::{info, error, warn};
use indicatif::{ProgressBar, ProgressStyle};
fn main() -> Result<()> {
@@ -29,10 +30,10 @@ fn main() -> Result<()> {
}
fn run() -> Result<()> {
let cli = cli::Cli::parse();
info!("Parsing CLI arguments");
let cli = cli::Cli::parse();
// --- Early input validation ---
// Check input .aaxc file exists, is readable, and has correct extension
@@ -41,7 +42,7 @@ fn run() -> Result<()> {
anyhow::bail!(
"Input file does not exist: {}. Please provide a valid .aaxc file.",
aaxc_file_path.display()
);
);
}
if !aaxc_file_path.is_file() {
anyhow::bail!(
@@ -121,8 +122,6 @@ fn run() -> Result<()> {
// If output path is provided, check parent directory exists and is writable
if let Some(ref output_path) = cli.output_path {
use std::fs;
use std::path::Path;
if output_path.exists() && output_path.is_dir() {
// If output_path is a directory, check if it's writable
@@ -135,14 +134,35 @@ fn run() -> Result<()> {
output_path.display()
);
}
} else if let Some(parent) = output_path.parent() {
// If output_path is a file path, check its parent directory
if !parent.exists() {
} else if !output_path.exists() {
// If output_path does not exist, try to create it as a directory
if let Err(e) = std::fs::create_dir_all(output_path) {
anyhow::bail!(
"Output directory does not exist: {}. Please create it or specify a different output path.",
parent.display()
"Failed to create output directory '{}': {}. Please check permissions or specify a different output path.",
output_path.display(),
e
);
}
if std::fs::metadata(output_path)
.map(|m| m.permissions().readonly())
.unwrap_or(true)
{
anyhow::bail!(
"Output directory is not writable: {}. Please check permissions or specify a different output path.",
output_path.display()
);
}
} else if let Some(parent) = output_path.parent() {
// If output_path is a file path, ensure its parent directory exists or create it
if !parent.exists() {
if let Err(e) = std::fs::create_dir_all(parent) {
anyhow::bail!(
"Failed to create output directory '{}': {}. Please check permissions or specify a different output path.",
parent.display(),
e
);
}
}
if std::fs::metadata(parent)
.map(|m| m.permissions().readonly())
.unwrap_or(true)
@@ -192,35 +212,170 @@ fn run() -> Result<()> {
let duration = ffprobe_json.format.duration;
// Determine output file extension and codec based on output_type (trait-based, extensible)
use crate::cli::{OutputFormat, Mp3Format};
let output_format: Box<dyn OutputFormat> = match cli.output_type {
Some(ref t) => t.get_format(),
None => Box::new(Mp3Format),
};
use crate::cli::OutputFormat;
let output_format: Box<dyn OutputFormat> = cli.output_type.get_format();
let codec = output_format.codec();
let ext = output_format.extension();
// Determine output file name: use CLI override if provided
let file_name = if let Some(output_path) = cli.output_path {
let file_name = if let Some(ref output_path) = cli.output_path {
let default_name = format!("{}.{}", album.to_snake_case(), ext);
// If the path exists and is a directory, or if it was just created as a directory, use default filename inside it
if output_path.exists() && output_path.is_dir() {
// If output_path is a directory, use default filename inside it
let default_name = format!("{}.{}", album.to_snake_case(), ext);
output_path.join(default_name).to_string_lossy().to_string()
output_path.join(&default_name).to_string_lossy().to_string()
} else if !output_path.exists() {
// If the path does not exist, check if it was intended as a directory (created above)
if std::fs::metadata(&output_path).map(|m| m.is_dir()).unwrap_or(false) {
output_path.join(&default_name).to_string_lossy().to_string()
} else {
output_path.to_string_lossy().to_string()
}
} else {
// If output_path is a file path (or does not exist), use as-is
// If output_path is a file path, use as-is
output_path.to_string_lossy().to_string()
}
} else {
format!("{}.{}", album.to_snake_case(), ext)
};
// Handle chapter splitting
if cli.split {
warn!("--split is not yet supported and will be ignored.");
println!("Warning: The --split option is not yet implemented.
Splitting output into chapters or segments is planned for a future release.
To add support, implement logic to parse chapter metadata and invoke ffmpeg for each segment,
using the OutputFormat trait for extensibility.
See the code comments for guidance on extending splitting functionality.");
info!("Chapter splitting requested");
// Determine chapter file path (similar to voucher file inference)
let chapter_file_path = {
let aaxc_file_path_stem = aaxc_file_path
.file_stem()
.context("Could not get file stem from the input file path for chapter file inference.")?;
// Try multiple naming patterns for chapter files
let base_name = aaxc_file_path_stem
.to_str()
.context("Failed to convert file stem to string for chapter file inference.")?;
// Remove AAX suffix if present (e.g., "Book-AAX_44_128" -> "Book")
let clean_name = if base_name.contains("-AAX_") {
base_name.split("-AAX_").next().unwrap_or(base_name)
} else {
base_name
};
aaxc_file_path.with_file_name(format!("{}-chapters.json", clean_name))
};
info!("Looking for chapter file: {}", chapter_file_path.display());
// Check if chapter file exists
if !chapter_file_path.exists() {
anyhow::bail!(
"Chapter file does not exist: {}. Please provide a chapters.json file or disable --split.",
chapter_file_path.display()
);
}
if !chapter_file_path.is_file() {
anyhow::bail!(
"Chapter path is not a file: {}. Please provide a valid chapters.json file.",
chapter_file_path.display()
);
}
if std::fs::File::open(&chapter_file_path).is_err() {
anyhow::bail!(
"Chapter file is not readable: {}. Please check file permissions.",
chapter_file_path.display()
);
}
// Parse chapter file
info!("Parsing chapter file: {}", chapter_file_path.display());
let chapter_file = std::fs::File::open(&chapter_file_path)
.with_context(|| format!(
"Failed to open chapter file: {}. Please ensure the file exists and is readable.",
chapter_file_path.display()
))?;
let chapters: AudibleChapters = serde_json::from_reader(chapter_file)
.with_context(|| format!(
"Failed to parse chapter file: {}. Please ensure it is a valid JSON file.",
chapter_file_path.display()
))?;
info!("Chapter file parsed successfully");
info!("Response groups: {:?}", chapters.response_groups);
info!("Chapter count: {}", chapters.content_metadata.chapter_info.chapters.len());
chapters.validate().map_err(|e| anyhow::anyhow!("Invalid chapter data: {e}"))?;
info!("Chapter data validated successfully");
// Flatten chapters with a single global counter
let mut flattened_chapters = Vec::new();
let mut chapter_counter = 1;
for chapter in &chapters.content_metadata.chapter_info.chapters {
chapter.flatten_recursive(&mut flattened_chapters, &mut chapter_counter, String::new(), 0);
}
info!("Found {} total chapters", flattened_chapters.len());
// Process chapters based on merging preference
let min_duration_ms = (cli.min_chapter_duration.unwrap_or(0) * 1000) as i64; // Convert seconds to milliseconds
let processed_chapters = if cli.merge_short_chapters {
// Merge short chapters with the next chapter
let merged_chapters = merge_short_chapters(&flattened_chapters, min_duration_ms);
info!("After merging short chapters (min duration: {}s): {} chapters",
min_duration_ms / 1000, merged_chapters.len());
merged_chapters
} else {
// Filter chapters based on minimum duration
let filtered_chapters: Vec<&FlattenedChapter> = flattened_chapters
.iter()
.filter(|chapter| chapter.should_include(min_duration_ms))
.collect();
info!("After filtering (min duration: {}s): {} chapters",
min_duration_ms / 1000, filtered_chapters.len());
if filtered_chapters.is_empty() {
anyhow::bail!("No chapters found after filtering. Try reducing --min-chapter-duration or check your chapter data.");
}
// Warn about filtered chapters and potential time gaps
let filtered_count = flattened_chapters.len() - filtered_chapters.len();
if filtered_count > 0 {
warn!("{} chapters were filtered out due to minimum duration requirement. This may create gaps in the audio timeline.", filtered_count);
warn!("Consider using --merge-short-chapters to merge them with the next chapter instead.");
}
// Convert to MergedChapter for consistency
filtered_chapters.into_iter().map(|ch| MergedChapter::from_flattened(ch)).collect()
};
if processed_chapters.is_empty() {
anyhow::bail!("No chapters found after processing. Try reducing --min-chapter-duration or check your chapter data.");
}
// Convert chapters to individual files
info!("Starting chapter splitting conversion");
let output_base_path = if let Some(output_path) = &cli.output_path {
output_path.clone()
} else {
PathBuf::from(".")
};
convert_chapters(
&aaxc_file_path,
&audible_key,
&audible_iv,
&processed_chapters,
&cli.chapter_naming_format,
&cli.split_structure,
&output_base_path,
&ext,
&codec,
)?;
info!("Chapter splitting completed successfully");
return Ok(());
}
info!("Title: {}", title);
@@ -287,6 +442,194 @@ fn ffprobe(aaxc_file_path: &Path) -> Result<FFProbeFormat> {
Ok(ffprobe_json)
}
/// Merge short chapters with the next chapter
fn merge_short_chapters(chapters: &[FlattenedChapter], min_duration_ms: i64) -> Vec<MergedChapter> {
let mut merged_chapters = Vec::new();
let mut i = 0;
while i < chapters.len() {
let current_chapter = &chapters[i];
if current_chapter.should_include(min_duration_ms) {
// This chapter is long enough, add it as-is
merged_chapters.push(MergedChapter::from_flattened(current_chapter));
i += 1;
} else if current_chapter.should_merge_with_next(min_duration_ms) {
// This chapter is short and should be merged with the next
if i + 1 < chapters.len() {
// There is a next chapter, merge with it
let mut merged = MergedChapter::from_flattened(&chapters[i + 1]);
merged.merge_with(current_chapter);
merged_chapters.push(merged);
i += 2; // Skip both the short chapter and the next one
} else {
// This is the last chapter and it's short, include it anyway
merged_chapters.push(MergedChapter::from_flattened(current_chapter));
i += 1;
}
} else {
// This chapter has no content (length_ms <= 0), skip it
i += 1;
}
}
merged_chapters
}
/// Convert multiple chapters to individual files
fn convert_chapters(
aaxc_file_path: &Path,
audible_key: &str,
audible_iv: &str,
chapters: &[MergedChapter],
naming_format: &ChapterNamingFormat,
split_structure: &SplitStructure,
output_base_path: &Path,
extension: &str,
codec: &str,
) -> Result<()> {
let total_chapters = chapters.len();
info!("Converting {} chapters", total_chapters);
for (index, chapter) in chapters.iter().enumerate() {
let chapter_number = index + 1;
info!("Converting chapter {}/{}: {}", chapter_number, total_chapters, chapter.title);
// Generate output path based on structure
let output_path = match split_structure {
SplitStructure::Flat => {
let filename = chapter.generate_filename(naming_format, extension);
output_base_path.join(filename)
},
SplitStructure::Hierarchical => {
chapter.get_hierarchical_output_path(output_base_path, naming_format, extension)
}
};
info!("Output file: {}", output_path.display());
// Convert time to ffmpeg format (HH:MM:SS.mmm)
let start_time = format_time_from_ms(chapter.start_offset_ms);
let duration_time = format_time_from_ms(chapter.length_ms);
info!("Chapter time range: {} to {} (duration: {})",
start_time,
format_time_from_ms(chapter.start_offset_ms + chapter.length_ms),
duration_time);
// Create parent directories if needed
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
// Run ffmpeg for this chapter
let mut cmd = ffmpeg_chapter(
aaxc_file_path.to_path_buf(),
audible_key.to_string(),
audible_iv.to_string(),
start_time,
duration_time,
output_path.to_string_lossy().to_string(),
codec,
)?;
let status = cmd.wait()
.with_context(|| format!("ffmpeg process failed for chapter: {}", chapter.title))?;
if status.success() {
info!("Chapter {}/{} completed: {}", chapter_number, total_chapters, output_path.display());
} else {
error!("ffmpeg conversion failed for chapter: {}", chapter.title);
anyhow::bail!(
"ffmpeg failed to convert chapter '{}'. Please check your input files and try again.",
chapter.title
);
}
}
info!("All {} chapters converted successfully", total_chapters);
Ok(())
}
/// Convert milliseconds to ffmpeg time format (HH:MM:SS.mmm)
fn format_time_from_ms(ms: i64) -> String {
let total_seconds = ms / 1000;
let milliseconds = ms % 1000;
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, milliseconds)
}
/// Run ffmpeg for a specific chapter with time range
fn ffmpeg_chapter(
aaxc_file_path: PathBuf,
audible_key: String,
audible_iv: String,
start_time: String,
duration: String,
file_name: String,
codec: &str,
) -> Result<Child> {
let mut cmd = Command::new("ffmpeg")
.args([
"-audible_key",
audible_key.as_str(),
"-audible_iv",
audible_iv.as_str(),
"-i",
aaxc_file_path
.to_str()
.context("Failed to convert input file path to string.")?,
"-ss",
start_time.as_str(),
"-t",
duration.as_str(),
"-progress",
"/dev/stdout",
"-y",
"-map_metadata",
"0",
"-vn",
"-codec:a",
codec,
file_name.as_str(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| "Failed to execute ffmpeg. Is ffmpeg installed and available in your PATH?")?;
{
let stdout = cmd.stdout.as_mut().context("Failed to capture ffmpeg stdout.")?;
let stdout_reader = std::io::BufReader::new(stdout);
// Progress bar setup
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template("{spinner} [{elapsed_precise}] {msg}")
.unwrap()
);
pb.enable_steady_tick(std::time::Duration::from_millis(100));
for line in stdout_reader.lines() {
let l = line.context("Failed to read line from ffmpeg output.")?;
if l.contains("time=") {
pb.set_message(format!("Progress: {} / {}", l, duration));
}
if l.contains("speed=") {
pb.set_message(format!("{} | {}", pb.message(), l));
}
}
pb.finish_with_message("Chapter conversion complete");
}
info!("ffmpeg process finished for chapter");
Ok(cmd)
}
fn ffmpeg(
aaxc_file_path: PathBuf,
audible_key: String,

View File

@@ -1,8 +1,7 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Deserialize chapters information
/// It goes 2 levels deep which works for Sandersons books which is all I want but there could be
/// books with more levels
/// Deserialize chapters information with recursive structure to handle unlimited nesting levels
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AudibleChapters {
@@ -12,6 +11,16 @@ pub struct AudibleChapters {
pub response_groups: Vec<String>,
}
impl AudibleChapters {
pub fn validate(&self) -> Result<(), String> {
self.content_metadata.validate().map_err(|e| format!("content_metadata: {}", e))?;
if self.response_groups.is_empty() {
return Err("response_groups is empty".to_string());
}
Ok(())
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentMetadata {
@@ -23,12 +32,23 @@ pub struct ContentMetadata {
pub last_position_heard: LastPositionHeard,
}
impl ContentMetadata {
pub fn validate(&self) -> Result<(), String> {
self.chapter_info.validate().map_err(|e| format!("chapter_info: {}", e))?;
self.content_reference.validate().map_err(|e| format!("content_reference: {}", e))?;
self.last_position_heard.validate().map_err(|e| format!("last_position_heard: {}", e))?;
Ok(())
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ChapterInfo {
#[serde(rename = "brandIntroDurationMs")]
pub brand_intro_duration_ms: i64,
#[serde(rename = "brandOutroDurationMs")]
pub brand_outro_duration_ms: i64,
pub chapters: Vec<Chapter>,
pub chapters: Vec<ChapterNode>,
#[serde(rename = "is_accurate")]
pub is_accurate: bool,
#[serde(rename = "runtime_length_ms")]
@@ -37,9 +57,23 @@ pub struct ChapterInfo {
pub runtime_length_sec: i64,
}
impl ChapterInfo {
pub fn validate(&self) -> Result<(), String> {
if self.brand_intro_duration_ms < 0 { return Err("brand_intro_duration_ms is negative".to_string()); }
if self.brand_outro_duration_ms < 0 { return Err("brand_outro_duration_ms is negative".to_string()); }
if self.runtime_length_ms <= 0 { return Err("runtime_length_ms is not positive".to_string()); }
if self.runtime_length_sec <= 0 { return Err("runtime_length_sec is not positive".to_string()); }
for (i, chapter) in self.chapters.iter().enumerate() {
chapter.validate().map_err(|e| format!("chapters[{}]: {}", i, e))?;
}
Ok(())
}
}
/// Recursive chapter structure that can handle unlimited nesting levels
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Chapter {
pub struct ChapterNode {
#[serde(rename = "length_ms")]
pub length_ms: i64,
#[serde(rename = "start_offset_ms")]
@@ -48,19 +82,392 @@ pub struct Chapter {
pub start_offset_sec: i64,
pub title: String,
#[serde(default)]
pub chapters: Vec<Chapter2>,
pub chapters: Vec<ChapterNode>,
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Chapter2 {
#[serde(rename = "length_ms")]
pub length_ms: i64,
#[serde(rename = "start_offset_ms")]
pub start_offset_ms: i64,
#[serde(rename = "start_offset_sec")]
pub start_offset_sec: i64,
impl ChapterNode {
pub fn validate(&self) -> Result<(), String> {
if self.title.trim().is_empty() { return Err("title is empty".to_string()); }
if self.length_ms <= 0 { return Err("length_ms is not positive".to_string()); }
if self.start_offset_ms < 0 { return Err("start_offset_ms is negative".to_string()); }
if self.start_offset_sec < 0 { return Err("start_offset_sec is negative".to_string()); }
for (i, chapter) in self.chapters.iter().enumerate() {
chapter.validate().map_err(|e| format!("chapters[{}]: {}", i, e))?;
}
Ok(())
}
/// Flatten the hierarchical chapter structure into a flat list
pub fn flatten(&self) -> Vec<FlattenedChapter> {
let mut result = Vec::new();
let mut chapter_counter = 1;
self.flatten_recursive(&mut result, &mut chapter_counter, String::new(), 0);
result
}
pub fn flatten_recursive(
&self,
result: &mut Vec<FlattenedChapter>,
counter: &mut usize,
parent_path: String,
level: usize
) {
// Build full path
let full_path = if parent_path.is_empty() {
self.title.clone()
} else {
format!("{} > {}", parent_path, self.title)
};
// Add this chapter if it's a leaf chapter (no children)
// Parent chapters with children are handled by their sub-chapters
if self.chapters.is_empty() {
// Create a hierarchical title that includes parent context
let hierarchical_title = if parent_path.is_empty() {
self.title.clone()
} else {
// Convert "Part One: Empire > Chapter 1" to "Part_One_Empire_Chapter_1"
let path_parts: Vec<&str> = full_path.split(" > ").collect();
path_parts.join("_")
.replace(":", "")
.replace(" ", "_")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect()
};
result.push(FlattenedChapter {
title: hierarchical_title,
full_path: full_path.clone(),
start_offset_ms: self.start_offset_ms,
length_ms: self.length_ms,
start_offset_sec: self.start_offset_sec,
level,
chapter_number: *counter,
});
*counter += 1;
} else if self.length_ms > 0 {
// This is a parent chapter with its own content - add it as a content chapter
// but don't include it in the hierarchical path for its children
// The parent chapter should be placed in its own directory
let hierarchical_title = if parent_path.is_empty() {
self.title.clone()
} else {
// Convert "Part One: Empire > Chapter 1" to "Part_One_Empire_Chapter_1"
let path_parts: Vec<&str> = full_path.split(" > ").collect();
path_parts.join("_")
.replace(":", "")
.replace(" ", "_")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect()
};
// For parent chapters with content, we need to create a special full_path
// that will place them in their own directory
// The parent chapter should be treated as if it has no parent path
// so it gets placed in its own directory
let parent_full_path = self.title.clone();
result.push(FlattenedChapter {
title: hierarchical_title,
full_path: parent_full_path,
start_offset_ms: self.start_offset_ms,
length_ms: self.length_ms,
start_offset_sec: self.start_offset_sec,
level,
chapter_number: *counter,
});
*counter += 1;
}
// Recursively process children
for child in &self.chapters {
child.flatten_recursive(result, counter, full_path.clone(), level + 1);
}
}
}
/// A flattened chapter with metadata for file generation
#[derive(Debug, Clone, PartialEq)]
pub struct FlattenedChapter {
pub title: String,
pub full_path: String, // e.g., "Part 1 > Chapter 01"
pub start_offset_ms: i64,
pub length_ms: i64,
pub start_offset_sec: i64,
pub level: usize, // How deep in the hierarchy
pub chapter_number: usize, // Sequential number for naming (starts from 1)
}
/// Represents a chapter that may have been merged with previous short chapters
#[derive(Debug, Clone, PartialEq)]
pub struct MergedChapter {
pub title: String,
pub full_path: String,
pub start_offset_ms: i64,
pub length_ms: i64,
pub start_offset_sec: i64,
pub level: usize,
pub chapter_number: usize,
pub merged_chapters: Vec<String>, // Titles of chapters that were merged into this one
}
impl MergedChapter {
/// Create a MergedChapter from a FlattenedChapter
pub fn from_flattened(chapter: &FlattenedChapter) -> Self {
Self {
title: chapter.title.clone(),
full_path: chapter.full_path.clone(),
start_offset_ms: chapter.start_offset_ms,
length_ms: chapter.length_ms,
start_offset_sec: chapter.start_offset_sec,
level: chapter.level,
chapter_number: chapter.chapter_number,
merged_chapters: vec![chapter.title.clone()],
}
}
/// Merge another chapter into this one
pub fn merge_with(&mut self, other: &FlattenedChapter) {
// Extend the length to include the other chapter
let other_end = other.start_offset_ms + other.length_ms;
self.length_ms = other_end - self.start_offset_ms;
self.length_ms = self.length_ms.max(other.length_ms); // Ensure we don't go backwards
// Add the other chapter's title to merged chapters
self.merged_chapters.push(other.title.clone());
// Update the title to indicate merging
if self.merged_chapters.len() > 1 {
self.title = format!("{} (includes: {})",
self.merged_chapters[0],
self.merged_chapters[1..].join(", "));
}
}
/// Generate filename based on format pattern
pub fn generate_filename(&self, format: &ChapterNamingFormat, extension: &str) -> String {
match format {
ChapterNamingFormat::ChapterNumberTitle => {
format!("Chapter{:02}_{}.{}",
self.chapter_number,
self.sanitize_title(&self.title),
extension)
},
ChapterNamingFormat::NumberTitle => {
format!("{:02}_{}.{}",
self.chapter_number,
self.sanitize_title(&self.title),
extension)
},
ChapterNamingFormat::TitleOnly => {
format!("{}.{}",
self.sanitize_title(&self.title),
extension)
},
ChapterNamingFormat::Custom(pattern) => {
pattern
.replace("{chapter:02}", &format!("{:02}", self.chapter_number))
.replace("{chapter}", &format!("{}", self.chapter_number))
.replace("{number:02}", &format!("{:02}", self.chapter_number))
.replace("{number}", &format!("{}", self.chapter_number))
.replace("{title}", &self.sanitize_title(&self.title))
.replace("{extension}", extension)
}
}
}
/// Sanitize title for use in filename
fn sanitize_title(&self, title: &str) -> String {
title
.replace(":", "")
.replace("/", "_")
.replace("\\", "_")
.replace("?", "")
.replace("*", "")
.replace("\"", "")
.replace("<", "")
.replace(">", "")
.replace("|", "")
.replace(" ", "_")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect()
}
/// Get hierarchical output path for this chapter
pub fn get_hierarchical_output_path(&self, base_path: &Path, format: &ChapterNamingFormat, extension: &str) -> PathBuf {
let filename = self.generate_filename(format, extension);
// Parse the full_path to create directory structure
// e.g., "Part One: Empire > Chapter 1" -> "Part_One_Empire/Chapter_1.mp3"
let path_parts: Vec<&str> = self.full_path.split(" > ").collect();
if path_parts.len() <= 1 {
// No hierarchy - check if this is a parent chapter that should be in its own directory
// If the title contains the chapter number pattern, it's a parent chapter
if filename.contains("Chapter") && !self.full_path.contains(" > ") {
// This is a parent chapter with content - place it in its own directory
let dir_name = self.full_path
.replace(":", "")
.replace(" ", "_")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect::<String>();
base_path.join(dir_name).join(filename)
} else {
// Regular top-level chapter
base_path.join(filename)
}
} else {
// Create directory structure from parent parts
let parent_parts = &path_parts[..path_parts.len() - 1];
let mut path = base_path.to_path_buf();
for part in parent_parts {
let dir_name = part
.replace(":", "")
.replace(" ", "_")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect::<String>();
path.push(dir_name);
}
// Add the filename
path.push(filename);
path
}
}
}
impl FlattenedChapter {
/// Check if this chapter should be included based on minimum duration
pub fn should_include(&self, min_duration_ms: i64) -> bool {
self.length_ms >= min_duration_ms
}
/// Check if this chapter should be merged with the next chapter
pub fn should_merge_with_next(&self, min_duration_ms: i64) -> bool {
self.length_ms < min_duration_ms && self.length_ms > 0
}
/// Generate filename based on format pattern
pub fn generate_filename(&self, format: &ChapterNamingFormat, extension: &str) -> String {
match format {
ChapterNamingFormat::ChapterNumberTitle => {
format!("Chapter{:02}_{}.{}",
self.chapter_number,
self.sanitize_title(&self.title),
extension
)
},
ChapterNamingFormat::NumberTitle => {
format!("{:02}_{}.{}",
self.chapter_number,
self.sanitize_title(&self.title),
extension
)
},
ChapterNamingFormat::TitleOnly => {
format!("{}.{}",
self.sanitize_title(&self.title),
extension
)
},
ChapterNamingFormat::Custom(pattern) => {
pattern
.replace("{number:02}", &format!("{:02}", self.chapter_number))
.replace("{number}", &format!("{}", self.chapter_number))
.replace("{title}", &self.sanitize_title(&self.title))
.replace("{extension}", extension)
}
}
}
/// Sanitize title for use in filename
fn sanitize_title(&self, title: &str) -> String {
title
.replace(":", "")
.replace("/", "_")
.replace("\\", "_")
.replace("?", "")
.replace("*", "")
.replace("\"", "")
.replace("<", "")
.replace(">", "")
.replace("|", "")
.replace(" ", "_")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect()
}
/// Get output path for this chapter
pub fn get_output_path(&self, base_path: &PathBuf, format: &ChapterNamingFormat, extension: &str) -> PathBuf {
let filename = self.generate_filename(format, extension);
base_path.join(filename)
}
/// Get hierarchical output path for this chapter
pub fn get_hierarchical_output_path(&self, base_path: &Path, format: &ChapterNamingFormat, extension: &str) -> PathBuf {
let filename = self.generate_filename(format, extension);
// Parse the full_path to create directory structure
// e.g., "Part One: Empire > Chapter 1" -> "Part_One_Empire/Chapter_1.mp3"
let path_parts: Vec<&str> = self.full_path.split(" > ").collect();
if path_parts.len() <= 1 {
// No hierarchy - check if this is a parent chapter that should be in its own directory
// If the title contains the chapter number pattern, it's a parent chapter
if filename.contains("Chapter") && !self.full_path.contains(" > ") {
// This is a parent chapter with content - place it in its own directory
let dir_name = self.full_path
.replace(":", "")
.replace(" ", "_")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect::<String>();
base_path.join(dir_name).join(filename)
} else {
// Regular top-level chapter
base_path.join(filename)
}
} else {
// Create directory structure from parent parts
let parent_parts = &path_parts[..path_parts.len() - 1];
let mut path = base_path.to_path_buf();
for part in parent_parts {
let dir_name = part
.replace(":", "")
.replace(" ", "_")
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect::<String>();
path.push(dir_name);
}
// Add the filename
path.push(filename);
path
}
}
}
/// Chapter naming format options
#[derive(Debug, Clone, PartialEq)]
pub enum ChapterNamingFormat {
/// Chapter01_Title.ext
ChapterNumberTitle,
/// 01_Title.ext
NumberTitle,
/// Title.ext
TitleOnly,
/// Custom pattern with placeholders: {number:02}, {number}, {title}, {extension}
Custom(String),
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -81,12 +488,41 @@ pub struct ContentReference {
pub version: String,
}
impl ContentReference {
pub fn validate(&self) -> Result<(), String> {
if self.acr.trim().is_empty() { return Err("acr is empty".to_string()); }
if self.asin.trim().is_empty() { return Err("asin is empty".to_string()); }
if self.codec.trim().is_empty() { return Err("codec is empty".to_string()); }
if self.content_format.trim().is_empty() { return Err("content_format is empty".to_string()); }
if self.content_size_in_bytes <= 0 { return Err("content_size_in_bytes is not positive".to_string()); }
if self.file_version.trim().is_empty() { return Err("file_version is empty".to_string()); }
if self.marketplace.trim().is_empty() { return Err("marketplace is empty".to_string()); }
if self.sku.trim().is_empty() { return Err("sku is empty".to_string()); }
if self.tempo.trim().is_empty() { return Err("tempo is empty".to_string()); }
if self.version.trim().is_empty() { return Err("version is empty".to_string()); }
Ok(())
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LastPositionHeard {
#[serde(rename = "last_updated")]
pub last_updated: String,
pub last_updated: Option<String>,
#[serde(rename = "position_ms")]
pub position_ms: i64,
pub position_ms: Option<i64>,
pub status: String,
}
impl LastPositionHeard {
pub fn validate(&self) -> Result<(), String> {
if let Some(ref last_updated) = self.last_updated {
if last_updated.trim().is_empty() { return Err("last_updated is empty".to_string()); }
}
if let Some(position_ms) = self.position_ms {
if position_ms < 0 { return Err("position_ms is negative".to_string()); }
}
if self.status.trim().is_empty() { return Err("status is empty".to_string()); }
Ok(())
}
}

View File

@@ -2,5 +2,6 @@ mod chapters;
mod ffprobe_format;
mod voucher;
pub use chapters::*;
pub use ffprobe_format::*;
pub use voucher::*;

View File

@@ -148,16 +148,20 @@ impl ContentUrl {
#[serde(rename_all = "camelCase")]
pub struct LastPositionHeard {
#[serde(rename = "last_updated")]
pub last_updated: String,
pub last_updated: Option<String>,
#[serde(rename = "position_ms")]
pub position_ms: i64,
pub position_ms: Option<i64>,
pub status: String,
}
impl LastPositionHeard {
pub fn validate(&self) -> Result<(), String> {
if self.last_updated.trim().is_empty() { return Err("last_updated is empty".to_string()); }
if self.position_ms < 0 { return Err("position_ms is negative".to_string()); }
if let Some(ref last_updated) = self.last_updated {
if last_updated.trim().is_empty() { return Err("last_updated is empty".to_string()); }
}
if let Some(position_ms) = self.position_ms {
if position_ms < 0 { return Err("position_ms is negative".to_string()); }
}
if self.status.trim().is_empty() { return Err("status is empty".to_string()); }
Ok(())
}
@@ -238,16 +242,20 @@ impl PlaybackInfo {
#[serde(rename_all = "camelCase")]
pub struct LastPositionHeard2 {
#[serde(rename = "last_updated")]
pub last_updated: String,
pub last_updated: Option<String>,
#[serde(rename = "position_ms")]
pub position_ms: i64,
pub position_ms: Option<i64>,
pub status: String,
}
impl LastPositionHeard2 {
pub fn validate(&self) -> Result<(), String> {
if self.last_updated.trim().is_empty() { return Err("last_updated is empty".to_string()); }
if self.position_ms < 0 { return Err("position_ms is negative".to_string()); }
if let Some(ref last_updated) = self.last_updated {
if last_updated.trim().is_empty() { return Err("last_updated is empty".to_string()); }
}
if let Some(position_ms) = self.position_ms {
if position_ms < 0 { return Err("position_ms is negative".to_string()); }
}
if self.status.trim().is_empty() { return Err("status is empty".to_string()); }
Ok(())
}