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

View File

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

158
README.md
View File

@@ -6,13 +6,18 @@
## Features ## 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. - Uses voucher files from `audible-cli` for decryption.
- Automatically infers voucher file if not specified. - 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). - Extensible output format system (easy to add new formats).
- Planned support for splitting output into chapters/segments.
- Progress indication and detailed logging. - Progress indication and detailed logging.
- Helpful error messages and validation. - 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 ### Basic Command
```sh ```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 ### CLI Options
| Option | Short | Type | Required | Description | | Option | Short | Type | Required | Description |
|-----------------------|-------|--------------|----------|-----------------------------------------------------------------------------| |-----------------------------|-------|--------------|----------|-----------------------------------------------------------------------------|
| `--aaxc-path` | `-a` | Path | Yes | Path to the input `.aaxc` file | | `--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. | | `--voucher-path` | `-v` | 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. | | `--output-path` | `-o` | Path | No | Output file or directory. Defaults to `<album>.<ext>` in current directory. |
| `--split` | `-s` | Flag | No | (Planned) Split output into chapters/segments. Currently not implemented. | | `--split` | `-s` | Flag | No | Split output into chapters/segments. Requires chapters.json file. |
| `--output-type` | | mp3/wav/flac | No | Output file type. Defaults to `mp3`. | | `--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 #### Example: Convert to FLAC with custom output path
```sh ```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 #### Example: Use default voucher and output
```sh ```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 ## Output Formats
- **MP3** (default): `--output-type mp3` - **MP3** (default): `-T mp3`
- **WAV**: `--output-type wav` - **WAV**: `-T wav`
- **FLAC**: `--output-type flac` - **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). 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**. ### Progress Tracking
- 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. 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"** - **"Failed to parse voucher file"**
The voucher file must be valid JSON generated by `audible-cli`. 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"** - **"Failed to start ffmpeg/ffprobe"**
Ensure `ffmpeg` and `ffprobe` are installed and available in your `PATH`. 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: - For verbose logs, set the `RUST_LOG` environment variable:
```sh ```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 ## License
This project is licensed under the MIT License. This project is licensed under the MIT License.

View File

@@ -1,26 +1,19 @@
use std::path::PathBuf; use std::path::PathBuf;
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use crate::models::ChapterNamingFormat;
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
name = "audible-util", 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\ about,
USAGE EXAMPLES:\n\ version,
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"
)] )]
pub struct Cli { pub struct Cli {
/// Path to the input .aaxc file to convert. /// Path to the input .aaxc file to convert.
/// ///
/// Example: -a mybook.aaxc /// 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, pub aaxc_path: PathBuf,
/// Path to the voucher file required for decryption. /// 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. /// 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 /// Example: --voucher_path my.voucher
/// TIP: The voucher file must match the account used to download the .aaxc file. /// 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>, pub voucher_path: Option<PathBuf>,
/// Path to the output audio file or directory. /// 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. /// 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 /// Example: --output_path output.mp3 or --output_path /path/to/output_dir
#[clap( #[clap(
short,
long, long,
value_name = "OUTPUT_PATH", 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>, pub output_path: Option<PathBuf>,
/// Split the output audio file by chapters. /// 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). /// 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). /// Requires a chapters.json file in the same directory as the .aaxc file.
#[clap(short, long, help = "Split the output audio file by chapters. Only supported for formats that support chapters (e.g., mp3, flac).")] #[clap(short, long, help = "Split output by chapters")]
pub split: bool, 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. /// Output file type/format.
/// ///
/// Supported values: mp3, wav, flac /// Supported values: mp3, wav, flac, ogg, m4a
/// Example: --output_type mp3 /// Example: --output_type mp3
/// If not specified, defaults to wav. #[clap(short = 'T', long, value_enum, value_name = "TYPE", default_value = "mp3", help = "Output format")]
#[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: OutputType,
pub output_type: Option<OutputType>,
} }
pub trait OutputFormat { pub trait OutputFormat {
@@ -68,6 +91,8 @@ pub trait OutputFormat {
pub struct Mp3Format; pub struct Mp3Format;
pub struct WavFormat; pub struct WavFormat;
pub struct FlacFormat; pub struct FlacFormat;
pub struct AacFormat;
pub struct OggFormat;
impl OutputFormat for Mp3Format { impl OutputFormat for Mp3Format {
fn codec(&self) -> &'static str { "mp3" } fn codec(&self) -> &'static str { "mp3" }
@@ -81,6 +106,14 @@ impl OutputFormat for FlacFormat {
fn codec(&self) -> &'static str { "flac" } fn codec(&self) -> &'static str { "flac" }
fn extension(&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)] #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum OutputType { pub enum OutputType {
@@ -90,6 +123,18 @@ pub enum OutputType {
Wav, Wav,
/// Free Lossless Audio Codec (.flac) /// Free Lossless Audio Codec (.flac)
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 { impl OutputType {
@@ -98,6 +143,40 @@ impl OutputType {
OutputType::Mp3 => Box::new(Mp3Format), OutputType::Mp3 => Box::new(Mp3Format),
OutputType::Wav => Box::new(WavFormat), OutputType::Wav => Box::new(WavFormat),
OutputType::Flac => Box::new(FlacFormat), 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 cli;
mod models; mod models;
use crate::models::FFProbeFormat; use crate::models::{FFProbeFormat, AudibleChapters, FlattenedChapter, MergedChapter, ChapterNamingFormat};
use crate::cli::SplitStructure;
use clap::Parser; use clap::Parser;
use inflector::Inflector; use inflector::Inflector;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -11,7 +12,7 @@ use std::{
process::{Command, Stdio}, process::{Command, Stdio},
}; };
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use log::{info, warn, error}; use log::{info, error, warn};
use indicatif::{ProgressBar, ProgressStyle}; use indicatif::{ProgressBar, ProgressStyle};
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -29,10 +30,10 @@ fn main() -> Result<()> {
} }
fn run() -> Result<()> { fn run() -> Result<()> {
let cli = cli::Cli::parse();
info!("Parsing CLI arguments"); info!("Parsing CLI arguments");
let cli = cli::Cli::parse();
// --- Early input validation --- // --- Early input validation ---
// Check input .aaxc file exists, is readable, and has correct extension // Check input .aaxc file exists, is readable, and has correct extension
@@ -41,7 +42,7 @@ fn run() -> Result<()> {
anyhow::bail!( anyhow::bail!(
"Input file does not exist: {}. Please provide a valid .aaxc file.", "Input file does not exist: {}. Please provide a valid .aaxc file.",
aaxc_file_path.display() aaxc_file_path.display()
); );
} }
if !aaxc_file_path.is_file() { if !aaxc_file_path.is_file() {
anyhow::bail!( anyhow::bail!(
@@ -121,8 +122,6 @@ fn run() -> Result<()> {
// If output path is provided, check parent directory exists and is writable // If output path is provided, check parent directory exists and is writable
if let Some(ref output_path) = cli.output_path { 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.exists() && output_path.is_dir() {
// If output_path is a directory, check if it's writable // If output_path is a directory, check if it's writable
@@ -135,14 +134,35 @@ fn run() -> Result<()> {
output_path.display() output_path.display()
); );
} }
} else if let Some(parent) = output_path.parent() { } else if !output_path.exists() {
// If output_path is a file path, check its parent directory // If output_path does not exist, try to create it as a directory
if !parent.exists() { if let Err(e) = std::fs::create_dir_all(output_path) {
anyhow::bail!( anyhow::bail!(
"Output directory does not exist: {}. Please create it or specify a different output path.", "Failed to create output directory '{}': {}. Please check permissions or specify a different output path.",
parent.display() 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) if std::fs::metadata(parent)
.map(|m| m.permissions().readonly()) .map(|m| m.permissions().readonly())
.unwrap_or(true) .unwrap_or(true)
@@ -192,35 +212,170 @@ fn run() -> Result<()> {
let duration = ffprobe_json.format.duration; let duration = ffprobe_json.format.duration;
// Determine output file extension and codec based on output_type (trait-based, extensible) // Determine output file extension and codec based on output_type (trait-based, extensible)
use crate::cli::{OutputFormat, Mp3Format}; use crate::cli::OutputFormat;
let output_format: Box<dyn OutputFormat> = match cli.output_type { let output_format: Box<dyn OutputFormat> = cli.output_type.get_format();
Some(ref t) => t.get_format(),
None => Box::new(Mp3Format),
};
let codec = output_format.codec(); let codec = output_format.codec();
let ext = output_format.extension(); let ext = output_format.extension();
// Determine output file name: use CLI override if provided // 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.exists() && output_path.is_dir() {
// If output_path is a directory, use default filename inside it output_path.join(&default_name).to_string_lossy().to_string()
let default_name = format!("{}.{}", album.to_snake_case(), ext); } else if !output_path.exists() {
output_path.join(default_name).to_string_lossy().to_string() // 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 { } 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() output_path.to_string_lossy().to_string()
} }
} else { } else {
format!("{}.{}", album.to_snake_case(), ext) format!("{}.{}", album.to_snake_case(), ext)
}; };
// Handle chapter splitting
if cli.split { if cli.split {
warn!("--split is not yet supported and will be ignored."); info!("Chapter splitting requested");
println!("Warning: The --split option is not yet implemented.
Splitting output into chapters or segments is planned for a future release. // Determine chapter file path (similar to voucher file inference)
To add support, implement logic to parse chapter metadata and invoke ffmpeg for each segment, let chapter_file_path = {
using the OutputFormat trait for extensibility. let aaxc_file_path_stem = aaxc_file_path
See the code comments for guidance on extending splitting functionality."); .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); info!("Title: {}", title);
@@ -287,6 +442,194 @@ fn ffprobe(aaxc_file_path: &Path) -> Result<FFProbeFormat> {
Ok(ffprobe_json) 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( fn ffmpeg(
aaxc_file_path: PathBuf, aaxc_file_path: PathBuf,
audible_key: String, audible_key: String,

View File

@@ -1,8 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Deserialize chapters information /// Deserialize chapters information with recursive structure to handle unlimited nesting levels
/// It goes 2 levels deep which works for Sandersons books which is all I want but there could be
/// books with more levels
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct AudibleChapters { pub struct AudibleChapters {
@@ -12,6 +11,16 @@ pub struct AudibleChapters {
pub response_groups: Vec<String>, 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)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ContentMetadata { pub struct ContentMetadata {
@@ -23,12 +32,23 @@ pub struct ContentMetadata {
pub last_position_heard: LastPositionHeard, 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)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct ChapterInfo { pub struct ChapterInfo {
#[serde(rename = "brandIntroDurationMs")]
pub brand_intro_duration_ms: i64, pub brand_intro_duration_ms: i64,
#[serde(rename = "brandOutroDurationMs")]
pub brand_outro_duration_ms: i64, pub brand_outro_duration_ms: i64,
pub chapters: Vec<Chapter>, pub chapters: Vec<ChapterNode>,
#[serde(rename = "is_accurate")] #[serde(rename = "is_accurate")]
pub is_accurate: bool, pub is_accurate: bool,
#[serde(rename = "runtime_length_ms")] #[serde(rename = "runtime_length_ms")]
@@ -37,9 +57,23 @@ pub struct ChapterInfo {
pub runtime_length_sec: i64, 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)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct Chapter { pub struct ChapterNode {
#[serde(rename = "length_ms")] #[serde(rename = "length_ms")]
pub length_ms: i64, pub length_ms: i64,
#[serde(rename = "start_offset_ms")] #[serde(rename = "start_offset_ms")]
@@ -48,19 +82,392 @@ pub struct Chapter {
pub start_offset_sec: i64, pub start_offset_sec: i64,
pub title: String, pub title: String,
#[serde(default)] #[serde(default)]
pub chapters: Vec<Chapter2>, pub chapters: Vec<ChapterNode>,
} }
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] impl ChapterNode {
#[serde(rename_all = "camelCase")] pub fn validate(&self) -> Result<(), String> {
pub struct Chapter2 { if self.title.trim().is_empty() { return Err("title is empty".to_string()); }
#[serde(rename = "length_ms")] if self.length_ms <= 0 { return Err("length_ms is not positive".to_string()); }
pub length_ms: i64, if self.start_offset_ms < 0 { return Err("start_offset_ms is negative".to_string()); }
#[serde(rename = "start_offset_ms")] if self.start_offset_sec < 0 { return Err("start_offset_sec is negative".to_string()); }
pub start_offset_ms: i64, for (i, chapter) in self.chapters.iter().enumerate() {
#[serde(rename = "start_offset_sec")] chapter.validate().map_err(|e| format!("chapters[{}]: {}", i, e))?;
pub start_offset_sec: i64, }
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 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)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -81,12 +488,41 @@ pub struct ContentReference {
pub version: String, 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)] #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct LastPositionHeard { pub struct LastPositionHeard {
#[serde(rename = "last_updated")] #[serde(rename = "last_updated")]
pub last_updated: String, pub last_updated: Option<String>,
#[serde(rename = "position_ms")] #[serde(rename = "position_ms")]
pub position_ms: i64, pub position_ms: Option<i64>,
pub status: String, 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 ffprobe_format;
mod voucher; mod voucher;
pub use chapters::*;
pub use ffprobe_format::*; pub use ffprobe_format::*;
pub use voucher::*; pub use voucher::*;

View File

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