From 9165c0e95d502de1a8729509259e002f3977a5a0 Mon Sep 17 00:00:00 2001 From: Ondra-Kavan Date: Thu, 18 Sep 2025 20:49:28 +0200 Subject: [PATCH] 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 --- Cargo.lock | 231 ++++++++++++++------ Cargo.toml | 9 +- README.md | 158 ++++++++++++-- src/cli.rs | 117 ++++++++-- src/main.rs | 399 +++++++++++++++++++++++++++++++--- src/models/chapters.rs | 470 +++++++++++++++++++++++++++++++++++++++-- src/models/mod.rs | 1 + src/models/voucher.rs | 24 ++- 8 files changed, 1245 insertions(+), 164 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c61bcbd..51cf6d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 5b75253..bc68ae1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 4fe2d29..afcd86e 100644 --- a/README.md +++ b/README.md @@ -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 `.voucher` in the same directory as the `.aaxc` file. +If `-v` is omitted, the tool will look for a voucher file named `.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 `.` 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 `.` 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 `-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. \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index 9f6c15c..67d24e9 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -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, /// 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., .). If not specified, the output will be placed in the current directory." + help = "Output file or directory (default: current dir)" )] pub output_path: Option, /// 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, + + /// 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, + #[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 { + 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 { + 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)), } } } diff --git a/src/main.rs b/src/main.rs index ca63ce4..65ddf34 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 = match cli.output_type { - Some(ref t) => t.get_format(), - None => Box::new(Mp3Format), - }; + use crate::cli::OutputFormat; + let output_format: Box = 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 { Ok(ffprobe_json) } +/// Merge short chapters with the next chapter +fn merge_short_chapters(chapters: &[FlattenedChapter], min_duration_ms: i64) -> Vec { + 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 { + 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, diff --git a/src/models/chapters.rs b/src/models/chapters.rs index e317675..5733e07 100644 --- a/src/models/chapters.rs +++ b/src/models/chapters.rs @@ -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, } +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, + pub chapters: Vec, #[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, + pub chapters: Vec, } -#[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 { + 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, + 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, // 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::(); + 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::(); + 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::(); + 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::(); + 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, #[serde(rename = "position_ms")] - pub position_ms: i64, + pub position_ms: Option, 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(()) + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 214d56d..c5a7dd2 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -2,5 +2,6 @@ mod chapters; mod ffprobe_format; mod voucher; +pub use chapters::*; pub use ffprobe_format::*; pub use voucher::*; diff --git a/src/models/voucher.rs b/src/models/voucher.rs index 1c00c2c..c1bb7e6 100644 --- a/src/models/voucher.rs +++ b/src/models/voucher.rs @@ -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, #[serde(rename = "position_ms")] - pub position_ms: i64, + pub position_ms: Option, 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, #[serde(rename = "position_ms")] - pub position_ms: i64, + pub position_ms: Option, 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(()) }