mirror of
https://github.com/PeacefulBeastGames/audible-util.git
synced 2026-02-03 23:39:05 +00:00
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:
231
Cargo.lock
generated
231
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
154
README.md
154
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 `<book>.voucher` in the same directory as the `.aaxc` file.
|
||||
If `-v` is omitted, the tool will look for a voucher file named `<book>.voucher` in the same directory as the `.aaxc` file.
|
||||
|
||||
### CLI Options
|
||||
|
||||
| Option | Short | Type | Required | Description |
|
||||
|-----------------------|-------|--------------|----------|-----------------------------------------------------------------------------|
|
||||
|-----------------------------|-------|--------------|----------|-----------------------------------------------------------------------------|
|
||||
| `--aaxc-path` | `-a` | Path | Yes | Path to the input `.aaxc` file |
|
||||
| `--voucher-path` | | Path | No | Path to the voucher file (from audible-cli). Inferred if not provided. |
|
||||
| `--output-path` | | Path | No | Output file path. Defaults to `<album>.<ext>` in current directory. |
|
||||
| `--split` | `-s` | Flag | No | (Planned) Split output into chapters/segments. Currently not implemented. |
|
||||
| `--output-type` | | mp3/wav/flac | No | Output file type. Defaults to `mp3`. |
|
||||
| `--voucher-path` | `-v` | Path | No | Path to the voucher file (from audible-cli). Inferred if not provided. |
|
||||
| `--output-path` | `-o` | Path | No | Output file or directory. Defaults to `<album>.<ext>` in current directory. |
|
||||
| `--split` | `-s` | Flag | No | Split output into chapters/segments. Requires chapters.json file. |
|
||||
| `--min-chapter-duration` | `-d` | Seconds | No | Minimum chapter duration in seconds. Default: 0 (no minimum). |
|
||||
| `--chapter-naming-format` | `-f` | Format | No | Chapter naming format. Default: `chapter-number-title`. |
|
||||
| `--split-structure` | `-t` | Structure | No | Output structure: `flat` or `hierarchical`. Default: `flat`. |
|
||||
| `--merge-short-chapters` | `-m` | Flag | No | Merge short chapters with next chapter instead of filtering them out. |
|
||||
| `--output-type` | `-T` | Format | No | Output file type. Default: `mp3`. Supports: mp3, wav, flac, ogg, m4a. |
|
||||
|
||||
#### Example: Convert to FLAC with custom output path
|
||||
|
||||
```sh
|
||||
audible-util --aaxc-path book.aaxc --voucher-path book.voucher --output-type flac --output-path mybook.flac
|
||||
audible-util -a book.aaxc -v book.voucher -T flac -o mybook.flac
|
||||
```
|
||||
|
||||
#### Example: Use default voucher and output
|
||||
|
||||
```sh
|
||||
audible-util --aaxc-path book.aaxc
|
||||
audible-util -a book.aaxc
|
||||
```
|
||||
|
||||
#### Example: Split into chapters with hierarchical structure
|
||||
|
||||
```sh
|
||||
audible-util -a book.aaxc -v book.voucher -s -t hierarchical -o chapters/
|
||||
```
|
||||
|
||||
#### Example: Split with short chapter merging and custom naming
|
||||
|
||||
```sh
|
||||
audible-util -a book.aaxc -v book.voucher -s -d 10 -m -f number-title -o chapters/
|
||||
```
|
||||
|
||||
#### Example: Full featured command
|
||||
|
||||
```sh
|
||||
audible-util -a book.aaxc -v book.voucher -s -d 5 -f chapter-number-title -t hierarchical -m -T flac -o output_dir/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -99,19 +126,61 @@ audible-util --aaxc-path book.aaxc
|
||||
|
||||
## Output Formats
|
||||
|
||||
- **MP3** (default): `--output-type mp3`
|
||||
- **WAV**: `--output-type wav`
|
||||
- **FLAC**: `--output-type flac`
|
||||
- **MP3** (default): `-T mp3`
|
||||
- **WAV**: `-T wav`
|
||||
- **FLAC**: `-T flac`
|
||||
- **OGG**: `-T ogg`
|
||||
- **M4A**: `-T m4a`
|
||||
|
||||
The output format system is extensible. To add a new format, implement the `OutputFormat` trait in [`src/cli.rs`](src/cli.rs:30).
|
||||
|
||||
## Chapter Splitting
|
||||
|
||||
The tool can split audiobooks into individual chapter files using chapter metadata from a `chapters.json` file.
|
||||
|
||||
### Chapter File Requirements
|
||||
|
||||
- The chapter file must be named `<book>-chapters.json` and placed in the same directory as the `.aaxc` file.
|
||||
- The file must contain valid JSON with chapter timing information.
|
||||
- The tool will automatically infer the chapter file path if not explicitly provided.
|
||||
|
||||
### Chapter Naming Formats
|
||||
|
||||
- **`chapter-number-title`** (default): `Chapter01_Title.mp3`
|
||||
- **`number-title`**: `01_Title.mp3`
|
||||
- **`title-only`**: `Title.mp3`
|
||||
|
||||
### Output Structures
|
||||
|
||||
- **`flat`** (default): All chapters in a single directory
|
||||
- **`hierarchical`**: Organize chapters into folders based on book structure (e.g., `Part_One/Chapter01.mp3`)
|
||||
|
||||
### Chapter Processing Options
|
||||
|
||||
- **Minimum Duration**: Filter out chapters shorter than specified duration (`-d` seconds)
|
||||
- **Merge Short Chapters**: Merge short chapters with the next chapter to prevent audio gaps (`-m`)
|
||||
- **Smart Filtering**: Automatically handles chapters with no content or very short durations
|
||||
|
||||
---
|
||||
|
||||
## Splitting Functionality
|
||||
## Advanced Features
|
||||
|
||||
- The `--split` flag is present but **not yet implemented**.
|
||||
- Planned: Split output into chapters or segments using metadata.
|
||||
- To extend: Implement logic to parse chapter metadata and invoke ffmpeg for each segment, using the `OutputFormat` trait for extensibility. See code comments in [`src/main.rs`](src/main.rs:108) for guidance.
|
||||
### Progress Tracking
|
||||
|
||||
The tool provides detailed progress information during conversion:
|
||||
- Real-time ffmpeg progress for each chapter
|
||||
- Chapter-by-chapter conversion status
|
||||
- File counting and validation
|
||||
- Detailed logging with `RUST_LOG=info`
|
||||
|
||||
### Error Handling
|
||||
|
||||
Comprehensive error handling with clear, actionable messages:
|
||||
- Input file validation (existence, readability, format)
|
||||
- Voucher file validation and parsing
|
||||
- Chapter file validation and parsing
|
||||
- Output directory creation and permission checks
|
||||
- External tool availability checks
|
||||
|
||||
---
|
||||
|
||||
@@ -144,6 +213,15 @@ The output format system is extensible. To add a new format, implement the `Outp
|
||||
- **"Failed to parse voucher file"**
|
||||
The voucher file must be valid JSON generated by `audible-cli`.
|
||||
|
||||
- **"Chapter file does not exist"**
|
||||
The tool requires a `chapters.json` file when using `-s`. Place it in the same directory as the `.aaxc` file.
|
||||
|
||||
- **"Failed to parse chapter file"**
|
||||
The chapter file must be valid JSON with proper chapter timing information.
|
||||
|
||||
- **"No chapters found after filtering"**
|
||||
All chapters were filtered out due to minimum duration requirements. Try reducing `-d` or using `-m` to merge short chapters.
|
||||
|
||||
- **"Failed to start ffmpeg/ffprobe"**
|
||||
Ensure `ffmpeg` and `ffprobe` are installed and available in your `PATH`.
|
||||
|
||||
@@ -152,7 +230,7 @@ The output format system is extensible. To add a new format, implement the `Outp
|
||||
|
||||
- For verbose logs, set the `RUST_LOG` environment variable:
|
||||
```sh
|
||||
RUST_LOG=info audible-util --aaxc-path book.aaxc
|
||||
RUST_LOG=info audible-util -a book.aaxc
|
||||
```
|
||||
|
||||
---
|
||||
@@ -176,6 +254,44 @@ For questions, issues, or suggestions, please open an issue on the [GitHub repos
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Most Common Commands
|
||||
|
||||
```sh
|
||||
# Basic conversion
|
||||
audible-util -a book.aaxc
|
||||
|
||||
# Convert with custom output
|
||||
audible-util -a book.aaxc -v book.voucher -o output.mp3
|
||||
|
||||
# Split into chapters (flat structure)
|
||||
audible-util -a book.aaxc -s -o chapters/
|
||||
|
||||
# Split with hierarchical structure
|
||||
audible-util -a book.aaxc -s -t hierarchical -o chapters/
|
||||
|
||||
# Split with short chapter merging
|
||||
audible-util -a book.aaxc -s -d 10 -m -o chapters/
|
||||
|
||||
# Full featured command
|
||||
audible-util -a book.aaxc -v book.voucher -s -d 5 -f chapter-number-title -t hierarchical -m -T flac -o output_dir/
|
||||
```
|
||||
|
||||
### Short Options Summary
|
||||
|
||||
- `-a` = `--aaxc-path` (required)
|
||||
- `-v` = `--voucher-path`
|
||||
- `-o` = `--output-path`
|
||||
- `-s` = `--split`
|
||||
- `-d` = `--min-chapter-duration`
|
||||
- `-f` = `--chapter-naming-format`
|
||||
- `-t` = `--split-structure`
|
||||
- `-m` = `--merge-short-chapters`
|
||||
- `-T` = `--output-type`
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
117
src/cli.rs
117
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<PathBuf>,
|
||||
|
||||
/// Path to the output audio file or directory.
|
||||
@@ -38,26 +31,56 @@ pub struct Cli {
|
||||
/// If not specified, the output file will be created in the current directory with the same base name as the input.
|
||||
/// Example: --output_path output.mp3 or --output_path /path/to/output_dir
|
||||
#[clap(
|
||||
short,
|
||||
long,
|
||||
value_name = "OUTPUT_PATH",
|
||||
help = "Path to the output audio file or directory. If a file path is provided, it will be used as the output file. If a directory is provided, the output file will be created inside that directory using the default naming scheme (e.g., <album>.<ext>). If not specified, the output will be placed in the current directory."
|
||||
help = "Output file or directory (default: current dir)"
|
||||
)]
|
||||
pub output_path: Option<PathBuf>,
|
||||
|
||||
/// Split the output audio file by chapters.
|
||||
///
|
||||
/// If set, the output will be split into separate files for each chapter (if chapter information is available).
|
||||
/// NOTE: Splitting is only supported for formats that support chapters (e.g., mp3, flac).
|
||||
#[clap(short, long, help = "Split the output audio file by chapters. Only supported for formats that support chapters (e.g., mp3, flac).")]
|
||||
/// Requires a chapters.json file in the same directory as the .aaxc file.
|
||||
#[clap(short, long, help = "Split output by chapters")]
|
||||
pub split: bool,
|
||||
|
||||
/// Minimum chapter duration in seconds.
|
||||
///
|
||||
/// Chapters shorter than this duration will be skipped when splitting.
|
||||
/// Default: 0 (no minimum duration).
|
||||
#[clap(short = 'd', long, value_name = "SECONDS", help = "Minimum chapter duration in seconds")]
|
||||
pub min_chapter_duration: Option<u64>,
|
||||
|
||||
/// Chapter naming format.
|
||||
///
|
||||
/// Controls how chapter files are named when splitting.
|
||||
/// Available formats: chapter-number-title, number-title, title-only, custom
|
||||
#[clap(short = 'f', long, value_enum, value_name = "FORMAT", default_value = "chapter-number-title", help = "Chapter naming format")]
|
||||
pub chapter_naming_format: ChapterNamingFormat,
|
||||
|
||||
/// Output structure for split chapters.
|
||||
///
|
||||
/// Controls how chapter files are organized when splitting.
|
||||
/// - flat: All chapters in a single directory
|
||||
/// - hierarchical: Create folders based on chapter hierarchy
|
||||
#[clap(short = 't', long, value_enum, value_name = "STRUCTURE", default_value = "flat", help = "Output structure for split chapters")]
|
||||
pub split_structure: SplitStructure,
|
||||
|
||||
/// Merge short chapters with the next chapter instead of filtering them out.
|
||||
///
|
||||
/// When enabled, chapters shorter than --min-chapter-duration will be merged
|
||||
/// with the next chapter instead of being filtered out. This prevents gaps
|
||||
/// in the audio timeline while still allowing filtering of very short content.
|
||||
#[clap(short = 'm', long, help = "Merge short chapters with next chapter instead of filtering them out")]
|
||||
pub merge_short_chapters: bool,
|
||||
|
||||
/// Output file type/format.
|
||||
///
|
||||
/// Supported values: mp3, wav, flac
|
||||
/// Supported values: mp3, wav, flac, ogg, m4a
|
||||
/// Example: --output_type mp3
|
||||
/// If not specified, defaults to wav.
|
||||
#[clap(long, value_enum, value_name = "TYPE", help = "Output file type/format. Supported: mp3, wav, flac. Example: --output_type mp3. Defaults to wav if not specified.")]
|
||||
pub output_type: Option<OutputType>,
|
||||
#[clap(short = 'T', long, value_enum, value_name = "TYPE", default_value = "mp3", help = "Output format")]
|
||||
pub output_type: OutputType,
|
||||
}
|
||||
|
||||
pub trait OutputFormat {
|
||||
@@ -68,6 +91,8 @@ pub trait OutputFormat {
|
||||
pub struct Mp3Format;
|
||||
pub struct WavFormat;
|
||||
pub struct FlacFormat;
|
||||
pub struct AacFormat;
|
||||
pub struct OggFormat;
|
||||
|
||||
impl OutputFormat for Mp3Format {
|
||||
fn codec(&self) -> &'static str { "mp3" }
|
||||
@@ -81,6 +106,14 @@ impl OutputFormat for FlacFormat {
|
||||
fn codec(&self) -> &'static str { "flac" }
|
||||
fn extension(&self) -> &'static str { "flac" }
|
||||
}
|
||||
impl OutputFormat for AacFormat {
|
||||
fn codec(&self) -> &'static str { "aac" }
|
||||
fn extension(&self) -> &'static str { "m4a" }
|
||||
}
|
||||
impl OutputFormat for OggFormat {
|
||||
fn codec(&self) -> &'static str { "vorbis" }
|
||||
fn extension(&self) -> &'static str { "ogg" }
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
|
||||
pub enum OutputType {
|
||||
@@ -90,6 +123,18 @@ pub enum OutputType {
|
||||
Wav,
|
||||
/// Free Lossless Audio Codec (.flac)
|
||||
Flac,
|
||||
/// Advanced Audio Coding (.m4a)
|
||||
M4a,
|
||||
/// Ogg Vorbis Audio (.ogg)
|
||||
Ogg
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
|
||||
pub enum SplitStructure {
|
||||
/// All chapters in a single directory
|
||||
Flat,
|
||||
/// Create folders based on chapter hierarchy
|
||||
Hierarchical,
|
||||
}
|
||||
|
||||
impl OutputType {
|
||||
@@ -98,6 +143,40 @@ impl OutputType {
|
||||
OutputType::Mp3 => Box::new(Mp3Format),
|
||||
OutputType::Wav => Box::new(WavFormat),
|
||||
OutputType::Flac => Box::new(FlacFormat),
|
||||
OutputType::M4a => Box::new(AacFormat),
|
||||
OutputType::Ogg => Box::new(OggFormat),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ValueEnum for ChapterNamingFormat {
|
||||
fn value_variants<'a>() -> &'a [Self] {
|
||||
&[
|
||||
ChapterNamingFormat::ChapterNumberTitle,
|
||||
ChapterNamingFormat::NumberTitle,
|
||||
ChapterNamingFormat::TitleOnly,
|
||||
]
|
||||
}
|
||||
|
||||
fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
|
||||
match self {
|
||||
ChapterNamingFormat::ChapterNumberTitle => Some(clap::builder::PossibleValue::new("chapter-number-title")),
|
||||
ChapterNamingFormat::NumberTitle => Some(clap::builder::PossibleValue::new("number-title")),
|
||||
ChapterNamingFormat::TitleOnly => Some(clap::builder::PossibleValue::new("title-only")),
|
||||
ChapterNamingFormat::Custom(_) => None, // Custom formats are handled separately
|
||||
}
|
||||
}
|
||||
|
||||
fn from_str(input: &str, _ignore_case: bool) -> Result<Self, String> {
|
||||
match input {
|
||||
"chapter-number-title" => Ok(ChapterNamingFormat::ChapterNumberTitle),
|
||||
"number-title" => Ok(ChapterNamingFormat::NumberTitle),
|
||||
"title-only" => Ok(ChapterNamingFormat::TitleOnly),
|
||||
custom if custom.starts_with("custom:") => {
|
||||
let pattern = custom.strip_prefix("custom:").unwrap().to_string();
|
||||
Ok(ChapterNamingFormat::Custom(pattern))
|
||||
},
|
||||
_ => Err(format!("Invalid chapter naming format: {}. Valid options: chapter-number-title, number-title, title-only, custom:pattern", input)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
397
src/main.rs
397
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
|
||||
@@ -121,8 +122,6 @@ fn run() -> Result<()> {
|
||||
|
||||
// If output path is provided, check parent directory exists and is writable
|
||||
if let Some(ref output_path) = cli.output_path {
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
if output_path.exists() && output_path.is_dir() {
|
||||
// If output_path is a directory, check if it's writable
|
||||
@@ -135,14 +134,35 @@ fn run() -> Result<()> {
|
||||
output_path.display()
|
||||
);
|
||||
}
|
||||
} else if let Some(parent) = output_path.parent() {
|
||||
// If output_path is a file path, check its parent directory
|
||||
if !parent.exists() {
|
||||
} else if !output_path.exists() {
|
||||
// If output_path does not exist, try to create it as a directory
|
||||
if let Err(e) = std::fs::create_dir_all(output_path) {
|
||||
anyhow::bail!(
|
||||
"Output directory does not exist: {}. Please create it or specify a different output path.",
|
||||
parent.display()
|
||||
"Failed to create output directory '{}': {}. Please check permissions or specify a different output path.",
|
||||
output_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
if std::fs::metadata(output_path)
|
||||
.map(|m| m.permissions().readonly())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"Output directory is not writable: {}. Please check permissions or specify a different output path.",
|
||||
output_path.display()
|
||||
);
|
||||
}
|
||||
} else if let Some(parent) = output_path.parent() {
|
||||
// If output_path is a file path, ensure its parent directory exists or create it
|
||||
if !parent.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
anyhow::bail!(
|
||||
"Failed to create output directory '{}': {}. Please check permissions or specify a different output path.",
|
||||
parent.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
if std::fs::metadata(parent)
|
||||
.map(|m| m.permissions().readonly())
|
||||
.unwrap_or(true)
|
||||
@@ -192,35 +212,170 @@ fn run() -> Result<()> {
|
||||
let duration = ffprobe_json.format.duration;
|
||||
|
||||
// Determine output file extension and codec based on output_type (trait-based, extensible)
|
||||
use crate::cli::{OutputFormat, Mp3Format};
|
||||
let output_format: Box<dyn OutputFormat> = match cli.output_type {
|
||||
Some(ref t) => t.get_format(),
|
||||
None => Box::new(Mp3Format),
|
||||
};
|
||||
use crate::cli::OutputFormat;
|
||||
let output_format: Box<dyn OutputFormat> = cli.output_type.get_format();
|
||||
let codec = output_format.codec();
|
||||
let ext = output_format.extension();
|
||||
|
||||
// Determine output file name: use CLI override if provided
|
||||
let file_name = if let Some(output_path) = cli.output_path {
|
||||
if output_path.exists() && output_path.is_dir() {
|
||||
// If output_path is a directory, use default filename inside it
|
||||
let file_name = if let Some(ref output_path) = cli.output_path {
|
||||
let default_name = format!("{}.{}", album.to_snake_case(), ext);
|
||||
output_path.join(default_name).to_string_lossy().to_string()
|
||||
// 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() {
|
||||
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 {
|
||||
// If output_path is a file path (or does not exist), use as-is
|
||||
output_path.to_string_lossy().to_string()
|
||||
}
|
||||
} else {
|
||||
// If output_path is a file path, use as-is
|
||||
output_path.to_string_lossy().to_string()
|
||||
}
|
||||
} else {
|
||||
format!("{}.{}", album.to_snake_case(), ext)
|
||||
};
|
||||
|
||||
// Handle chapter splitting
|
||||
if cli.split {
|
||||
warn!("--split is not yet supported and will be ignored.");
|
||||
println!("Warning: The --split option is not yet implemented.
|
||||
Splitting output into chapters or segments is planned for a future release.
|
||||
To add support, implement logic to parse chapter metadata and invoke ffmpeg for each segment,
|
||||
using the OutputFormat trait for extensibility.
|
||||
See the code comments for guidance on extending splitting functionality.");
|
||||
info!("Chapter splitting requested");
|
||||
|
||||
// Determine chapter file path (similar to voucher file inference)
|
||||
let chapter_file_path = {
|
||||
let aaxc_file_path_stem = aaxc_file_path
|
||||
.file_stem()
|
||||
.context("Could not get file stem from the input file path for chapter file inference.")?;
|
||||
|
||||
// Try multiple naming patterns for chapter files
|
||||
let base_name = aaxc_file_path_stem
|
||||
.to_str()
|
||||
.context("Failed to convert file stem to string for chapter file inference.")?;
|
||||
|
||||
// Remove AAX suffix if present (e.g., "Book-AAX_44_128" -> "Book")
|
||||
let clean_name = if base_name.contains("-AAX_") {
|
||||
base_name.split("-AAX_").next().unwrap_or(base_name)
|
||||
} else {
|
||||
base_name
|
||||
};
|
||||
|
||||
aaxc_file_path.with_file_name(format!("{}-chapters.json", clean_name))
|
||||
};
|
||||
|
||||
info!("Looking for chapter file: {}", chapter_file_path.display());
|
||||
|
||||
// Check if chapter file exists
|
||||
if !chapter_file_path.exists() {
|
||||
anyhow::bail!(
|
||||
"Chapter file does not exist: {}. Please provide a chapters.json file or disable --split.",
|
||||
chapter_file_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
if !chapter_file_path.is_file() {
|
||||
anyhow::bail!(
|
||||
"Chapter path is not a file: {}. Please provide a valid chapters.json file.",
|
||||
chapter_file_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
if std::fs::File::open(&chapter_file_path).is_err() {
|
||||
anyhow::bail!(
|
||||
"Chapter file is not readable: {}. Please check file permissions.",
|
||||
chapter_file_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Parse chapter file
|
||||
info!("Parsing chapter file: {}", chapter_file_path.display());
|
||||
let chapter_file = std::fs::File::open(&chapter_file_path)
|
||||
.with_context(|| format!(
|
||||
"Failed to open chapter file: {}. Please ensure the file exists and is readable.",
|
||||
chapter_file_path.display()
|
||||
))?;
|
||||
|
||||
let chapters: AudibleChapters = serde_json::from_reader(chapter_file)
|
||||
.with_context(|| format!(
|
||||
"Failed to parse chapter file: {}. Please ensure it is a valid JSON file.",
|
||||
chapter_file_path.display()
|
||||
))?;
|
||||
|
||||
info!("Chapter file parsed successfully");
|
||||
info!("Response groups: {:?}", chapters.response_groups);
|
||||
info!("Chapter count: {}", chapters.content_metadata.chapter_info.chapters.len());
|
||||
|
||||
chapters.validate().map_err(|e| anyhow::anyhow!("Invalid chapter data: {e}"))?;
|
||||
info!("Chapter data validated successfully");
|
||||
|
||||
// Flatten chapters with a single global counter
|
||||
let mut flattened_chapters = Vec::new();
|
||||
let mut chapter_counter = 1;
|
||||
|
||||
for chapter in &chapters.content_metadata.chapter_info.chapters {
|
||||
chapter.flatten_recursive(&mut flattened_chapters, &mut chapter_counter, String::new(), 0);
|
||||
}
|
||||
|
||||
info!("Found {} total chapters", flattened_chapters.len());
|
||||
|
||||
// Process chapters based on merging preference
|
||||
let min_duration_ms = (cli.min_chapter_duration.unwrap_or(0) * 1000) as i64; // Convert seconds to milliseconds
|
||||
let processed_chapters = if cli.merge_short_chapters {
|
||||
// Merge short chapters with the next chapter
|
||||
let merged_chapters = merge_short_chapters(&flattened_chapters, min_duration_ms);
|
||||
info!("After merging short chapters (min duration: {}s): {} chapters",
|
||||
min_duration_ms / 1000, merged_chapters.len());
|
||||
merged_chapters
|
||||
} else {
|
||||
// Filter chapters based on minimum duration
|
||||
let filtered_chapters: Vec<&FlattenedChapter> = flattened_chapters
|
||||
.iter()
|
||||
.filter(|chapter| chapter.should_include(min_duration_ms))
|
||||
.collect();
|
||||
|
||||
info!("After filtering (min duration: {}s): {} chapters",
|
||||
min_duration_ms / 1000, filtered_chapters.len());
|
||||
|
||||
if filtered_chapters.is_empty() {
|
||||
anyhow::bail!("No chapters found after filtering. Try reducing --min-chapter-duration or check your chapter data.");
|
||||
}
|
||||
|
||||
// Warn about filtered chapters and potential time gaps
|
||||
let filtered_count = flattened_chapters.len() - filtered_chapters.len();
|
||||
if filtered_count > 0 {
|
||||
warn!("{} chapters were filtered out due to minimum duration requirement. This may create gaps in the audio timeline.", filtered_count);
|
||||
warn!("Consider using --merge-short-chapters to merge them with the next chapter instead.");
|
||||
}
|
||||
|
||||
// Convert to MergedChapter for consistency
|
||||
filtered_chapters.into_iter().map(|ch| MergedChapter::from_flattened(ch)).collect()
|
||||
};
|
||||
|
||||
if processed_chapters.is_empty() {
|
||||
anyhow::bail!("No chapters found after processing. Try reducing --min-chapter-duration or check your chapter data.");
|
||||
}
|
||||
|
||||
// Convert chapters to individual files
|
||||
info!("Starting chapter splitting conversion");
|
||||
let output_base_path = if let Some(output_path) = &cli.output_path {
|
||||
output_path.clone()
|
||||
} else {
|
||||
PathBuf::from(".")
|
||||
};
|
||||
convert_chapters(
|
||||
&aaxc_file_path,
|
||||
&audible_key,
|
||||
&audible_iv,
|
||||
&processed_chapters,
|
||||
&cli.chapter_naming_format,
|
||||
&cli.split_structure,
|
||||
&output_base_path,
|
||||
&ext,
|
||||
&codec,
|
||||
)?;
|
||||
|
||||
info!("Chapter splitting completed successfully");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Title: {}", title);
|
||||
@@ -287,6 +442,194 @@ fn ffprobe(aaxc_file_path: &Path) -> Result<FFProbeFormat> {
|
||||
Ok(ffprobe_json)
|
||||
}
|
||||
|
||||
/// Merge short chapters with the next chapter
|
||||
fn merge_short_chapters(chapters: &[FlattenedChapter], min_duration_ms: i64) -> Vec<MergedChapter> {
|
||||
let mut merged_chapters = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < chapters.len() {
|
||||
let current_chapter = &chapters[i];
|
||||
|
||||
if current_chapter.should_include(min_duration_ms) {
|
||||
// This chapter is long enough, add it as-is
|
||||
merged_chapters.push(MergedChapter::from_flattened(current_chapter));
|
||||
i += 1;
|
||||
} else if current_chapter.should_merge_with_next(min_duration_ms) {
|
||||
// This chapter is short and should be merged with the next
|
||||
if i + 1 < chapters.len() {
|
||||
// There is a next chapter, merge with it
|
||||
let mut merged = MergedChapter::from_flattened(&chapters[i + 1]);
|
||||
merged.merge_with(current_chapter);
|
||||
merged_chapters.push(merged);
|
||||
i += 2; // Skip both the short chapter and the next one
|
||||
} else {
|
||||
// This is the last chapter and it's short, include it anyway
|
||||
merged_chapters.push(MergedChapter::from_flattened(current_chapter));
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
// This chapter has no content (length_ms <= 0), skip it
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
merged_chapters
|
||||
}
|
||||
|
||||
/// Convert multiple chapters to individual files
|
||||
fn convert_chapters(
|
||||
aaxc_file_path: &Path,
|
||||
audible_key: &str,
|
||||
audible_iv: &str,
|
||||
chapters: &[MergedChapter],
|
||||
naming_format: &ChapterNamingFormat,
|
||||
split_structure: &SplitStructure,
|
||||
output_base_path: &Path,
|
||||
extension: &str,
|
||||
codec: &str,
|
||||
) -> Result<()> {
|
||||
let total_chapters = chapters.len();
|
||||
info!("Converting {} chapters", total_chapters);
|
||||
|
||||
for (index, chapter) in chapters.iter().enumerate() {
|
||||
let chapter_number = index + 1;
|
||||
info!("Converting chapter {}/{}: {}", chapter_number, total_chapters, chapter.title);
|
||||
|
||||
// Generate output path based on structure
|
||||
let output_path = match split_structure {
|
||||
SplitStructure::Flat => {
|
||||
let filename = chapter.generate_filename(naming_format, extension);
|
||||
output_base_path.join(filename)
|
||||
},
|
||||
SplitStructure::Hierarchical => {
|
||||
chapter.get_hierarchical_output_path(output_base_path, naming_format, extension)
|
||||
}
|
||||
};
|
||||
|
||||
info!("Output file: {}", output_path.display());
|
||||
|
||||
// Convert time to ffmpeg format (HH:MM:SS.mmm)
|
||||
let start_time = format_time_from_ms(chapter.start_offset_ms);
|
||||
let duration_time = format_time_from_ms(chapter.length_ms);
|
||||
|
||||
info!("Chapter time range: {} to {} (duration: {})",
|
||||
start_time,
|
||||
format_time_from_ms(chapter.start_offset_ms + chapter.length_ms),
|
||||
duration_time);
|
||||
|
||||
// Create parent directories if needed
|
||||
if let Some(parent) = output_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
|
||||
}
|
||||
|
||||
// Run ffmpeg for this chapter
|
||||
let mut cmd = ffmpeg_chapter(
|
||||
aaxc_file_path.to_path_buf(),
|
||||
audible_key.to_string(),
|
||||
audible_iv.to_string(),
|
||||
start_time,
|
||||
duration_time,
|
||||
output_path.to_string_lossy().to_string(),
|
||||
codec,
|
||||
)?;
|
||||
|
||||
let status = cmd.wait()
|
||||
.with_context(|| format!("ffmpeg process failed for chapter: {}", chapter.title))?;
|
||||
|
||||
if status.success() {
|
||||
info!("Chapter {}/{} completed: {}", chapter_number, total_chapters, output_path.display());
|
||||
} else {
|
||||
error!("ffmpeg conversion failed for chapter: {}", chapter.title);
|
||||
anyhow::bail!(
|
||||
"ffmpeg failed to convert chapter '{}'. Please check your input files and try again.",
|
||||
chapter.title
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
info!("All {} chapters converted successfully", total_chapters);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert milliseconds to ffmpeg time format (HH:MM:SS.mmm)
|
||||
fn format_time_from_ms(ms: i64) -> String {
|
||||
let total_seconds = ms / 1000;
|
||||
let milliseconds = ms % 1000;
|
||||
let hours = total_seconds / 3600;
|
||||
let minutes = (total_seconds % 3600) / 60;
|
||||
let seconds = total_seconds % 60;
|
||||
|
||||
format!("{:02}:{:02}:{:02}.{:03}", hours, minutes, seconds, milliseconds)
|
||||
}
|
||||
|
||||
/// Run ffmpeg for a specific chapter with time range
|
||||
fn ffmpeg_chapter(
|
||||
aaxc_file_path: PathBuf,
|
||||
audible_key: String,
|
||||
audible_iv: String,
|
||||
start_time: String,
|
||||
duration: String,
|
||||
file_name: String,
|
||||
codec: &str,
|
||||
) -> Result<Child> {
|
||||
let mut cmd = Command::new("ffmpeg")
|
||||
.args([
|
||||
"-audible_key",
|
||||
audible_key.as_str(),
|
||||
"-audible_iv",
|
||||
audible_iv.as_str(),
|
||||
"-i",
|
||||
aaxc_file_path
|
||||
.to_str()
|
||||
.context("Failed to convert input file path to string.")?,
|
||||
"-ss",
|
||||
start_time.as_str(),
|
||||
"-t",
|
||||
duration.as_str(),
|
||||
"-progress",
|
||||
"/dev/stdout",
|
||||
"-y",
|
||||
"-map_metadata",
|
||||
"0",
|
||||
"-vn",
|
||||
"-codec:a",
|
||||
codec,
|
||||
file_name.as_str(),
|
||||
])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.with_context(|| "Failed to execute ffmpeg. Is ffmpeg installed and available in your PATH?")?;
|
||||
|
||||
{
|
||||
let stdout = cmd.stdout.as_mut().context("Failed to capture ffmpeg stdout.")?;
|
||||
let stdout_reader = std::io::BufReader::new(stdout);
|
||||
|
||||
// Progress bar setup
|
||||
let pb = ProgressBar::new_spinner();
|
||||
pb.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template("{spinner} [{elapsed_precise}] {msg}")
|
||||
.unwrap()
|
||||
);
|
||||
pb.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
|
||||
for line in stdout_reader.lines() {
|
||||
let l = line.context("Failed to read line from ffmpeg output.")?;
|
||||
if l.contains("time=") {
|
||||
pb.set_message(format!("Progress: {} / {}", l, duration));
|
||||
}
|
||||
if l.contains("speed=") {
|
||||
pb.set_message(format!("{} | {}", pb.message(), l));
|
||||
}
|
||||
}
|
||||
pb.finish_with_message("Chapter conversion complete");
|
||||
}
|
||||
info!("ffmpeg process finished for chapter");
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
fn ffmpeg(
|
||||
aaxc_file_path: PathBuf,
|
||||
audible_key: String,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Deserialize chapters information
|
||||
/// It goes 2 levels deep which works for Sandersons books which is all I want but there could be
|
||||
/// books with more levels
|
||||
/// Deserialize chapters information with recursive structure to handle unlimited nesting levels
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AudibleChapters {
|
||||
@@ -12,6 +11,16 @@ pub struct AudibleChapters {
|
||||
pub response_groups: Vec<String>,
|
||||
}
|
||||
|
||||
impl AudibleChapters {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.content_metadata.validate().map_err(|e| format!("content_metadata: {}", e))?;
|
||||
if self.response_groups.is_empty() {
|
||||
return Err("response_groups is empty".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ContentMetadata {
|
||||
@@ -23,12 +32,23 @@ pub struct ContentMetadata {
|
||||
pub last_position_heard: LastPositionHeard,
|
||||
}
|
||||
|
||||
impl ContentMetadata {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.chapter_info.validate().map_err(|e| format!("chapter_info: {}", e))?;
|
||||
self.content_reference.validate().map_err(|e| format!("content_reference: {}", e))?;
|
||||
self.last_position_heard.validate().map_err(|e| format!("last_position_heard: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ChapterInfo {
|
||||
#[serde(rename = "brandIntroDurationMs")]
|
||||
pub brand_intro_duration_ms: i64,
|
||||
#[serde(rename = "brandOutroDurationMs")]
|
||||
pub brand_outro_duration_ms: i64,
|
||||
pub chapters: Vec<Chapter>,
|
||||
pub chapters: Vec<ChapterNode>,
|
||||
#[serde(rename = "is_accurate")]
|
||||
pub is_accurate: bool,
|
||||
#[serde(rename = "runtime_length_ms")]
|
||||
@@ -37,9 +57,23 @@ pub struct ChapterInfo {
|
||||
pub runtime_length_sec: i64,
|
||||
}
|
||||
|
||||
impl ChapterInfo {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.brand_intro_duration_ms < 0 { return Err("brand_intro_duration_ms is negative".to_string()); }
|
||||
if self.brand_outro_duration_ms < 0 { return Err("brand_outro_duration_ms is negative".to_string()); }
|
||||
if self.runtime_length_ms <= 0 { return Err("runtime_length_ms is not positive".to_string()); }
|
||||
if self.runtime_length_sec <= 0 { return Err("runtime_length_sec is not positive".to_string()); }
|
||||
for (i, chapter) in self.chapters.iter().enumerate() {
|
||||
chapter.validate().map_err(|e| format!("chapters[{}]: {}", i, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursive chapter structure that can handle unlimited nesting levels
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Chapter {
|
||||
pub struct ChapterNode {
|
||||
#[serde(rename = "length_ms")]
|
||||
pub length_ms: i64,
|
||||
#[serde(rename = "start_offset_ms")]
|
||||
@@ -48,19 +82,392 @@ pub struct Chapter {
|
||||
pub start_offset_sec: i64,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub chapters: Vec<Chapter2>,
|
||||
pub chapters: Vec<ChapterNode>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Chapter2 {
|
||||
#[serde(rename = "length_ms")]
|
||||
pub length_ms: i64,
|
||||
#[serde(rename = "start_offset_ms")]
|
||||
pub start_offset_ms: i64,
|
||||
#[serde(rename = "start_offset_sec")]
|
||||
pub start_offset_sec: i64,
|
||||
impl ChapterNode {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.title.trim().is_empty() { return Err("title is empty".to_string()); }
|
||||
if self.length_ms <= 0 { return Err("length_ms is not positive".to_string()); }
|
||||
if self.start_offset_ms < 0 { return Err("start_offset_ms is negative".to_string()); }
|
||||
if self.start_offset_sec < 0 { return Err("start_offset_sec is negative".to_string()); }
|
||||
for (i, chapter) in self.chapters.iter().enumerate() {
|
||||
chapter.validate().map_err(|e| format!("chapters[{}]: {}", i, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flatten the hierarchical chapter structure into a flat list
|
||||
pub fn flatten(&self) -> Vec<FlattenedChapter> {
|
||||
let mut result = Vec::new();
|
||||
let mut chapter_counter = 1;
|
||||
|
||||
self.flatten_recursive(&mut result, &mut chapter_counter, String::new(), 0);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn flatten_recursive(
|
||||
&self,
|
||||
result: &mut Vec<FlattenedChapter>,
|
||||
counter: &mut usize,
|
||||
parent_path: String,
|
||||
level: usize
|
||||
) {
|
||||
// Build full path
|
||||
let full_path = if parent_path.is_empty() {
|
||||
self.title.clone()
|
||||
} else {
|
||||
format!("{} > {}", parent_path, self.title)
|
||||
};
|
||||
|
||||
// Add this chapter if it's a leaf chapter (no children)
|
||||
// Parent chapters with children are handled by their sub-chapters
|
||||
if self.chapters.is_empty() {
|
||||
// Create a hierarchical title that includes parent context
|
||||
let hierarchical_title = if parent_path.is_empty() {
|
||||
self.title.clone()
|
||||
} else {
|
||||
// Convert "Part One: Empire > Chapter 1" to "Part_One_Empire_Chapter_1"
|
||||
let path_parts: Vec<&str> = full_path.split(" > ").collect();
|
||||
path_parts.join("_")
|
||||
.replace(":", "")
|
||||
.replace(" ", "_")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect()
|
||||
};
|
||||
|
||||
result.push(FlattenedChapter {
|
||||
title: hierarchical_title,
|
||||
full_path: full_path.clone(),
|
||||
start_offset_ms: self.start_offset_ms,
|
||||
length_ms: self.length_ms,
|
||||
start_offset_sec: self.start_offset_sec,
|
||||
level,
|
||||
chapter_number: *counter,
|
||||
});
|
||||
*counter += 1;
|
||||
} else if self.length_ms > 0 {
|
||||
// This is a parent chapter with its own content - add it as a content chapter
|
||||
// but don't include it in the hierarchical path for its children
|
||||
// The parent chapter should be placed in its own directory
|
||||
let hierarchical_title = if parent_path.is_empty() {
|
||||
self.title.clone()
|
||||
} else {
|
||||
// Convert "Part One: Empire > Chapter 1" to "Part_One_Empire_Chapter_1"
|
||||
let path_parts: Vec<&str> = full_path.split(" > ").collect();
|
||||
path_parts.join("_")
|
||||
.replace(":", "")
|
||||
.replace(" ", "_")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect()
|
||||
};
|
||||
|
||||
// For parent chapters with content, we need to create a special full_path
|
||||
// that will place them in their own directory
|
||||
// The parent chapter should be treated as if it has no parent path
|
||||
// so it gets placed in its own directory
|
||||
let parent_full_path = self.title.clone();
|
||||
|
||||
result.push(FlattenedChapter {
|
||||
title: hierarchical_title,
|
||||
full_path: parent_full_path,
|
||||
start_offset_ms: self.start_offset_ms,
|
||||
length_ms: self.length_ms,
|
||||
start_offset_sec: self.start_offset_sec,
|
||||
level,
|
||||
chapter_number: *counter,
|
||||
});
|
||||
*counter += 1;
|
||||
}
|
||||
|
||||
// Recursively process children
|
||||
for child in &self.chapters {
|
||||
child.flatten_recursive(result, counter, full_path.clone(), level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A flattened chapter with metadata for file generation
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FlattenedChapter {
|
||||
pub title: String,
|
||||
pub full_path: String, // e.g., "Part 1 > Chapter 01"
|
||||
pub start_offset_ms: i64,
|
||||
pub length_ms: i64,
|
||||
pub start_offset_sec: i64,
|
||||
pub level: usize, // How deep in the hierarchy
|
||||
pub chapter_number: usize, // Sequential number for naming (starts from 1)
|
||||
}
|
||||
|
||||
/// Represents a chapter that may have been merged with previous short chapters
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MergedChapter {
|
||||
pub title: String,
|
||||
pub full_path: String,
|
||||
pub start_offset_ms: i64,
|
||||
pub length_ms: i64,
|
||||
pub start_offset_sec: i64,
|
||||
pub level: usize,
|
||||
pub chapter_number: usize,
|
||||
pub merged_chapters: Vec<String>, // Titles of chapters that were merged into this one
|
||||
}
|
||||
|
||||
impl MergedChapter {
|
||||
/// Create a MergedChapter from a FlattenedChapter
|
||||
pub fn from_flattened(chapter: &FlattenedChapter) -> Self {
|
||||
Self {
|
||||
title: chapter.title.clone(),
|
||||
full_path: chapter.full_path.clone(),
|
||||
start_offset_ms: chapter.start_offset_ms,
|
||||
length_ms: chapter.length_ms,
|
||||
start_offset_sec: chapter.start_offset_sec,
|
||||
level: chapter.level,
|
||||
chapter_number: chapter.chapter_number,
|
||||
merged_chapters: vec![chapter.title.clone()],
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge another chapter into this one
|
||||
pub fn merge_with(&mut self, other: &FlattenedChapter) {
|
||||
// Extend the length to include the other chapter
|
||||
let other_end = other.start_offset_ms + other.length_ms;
|
||||
self.length_ms = other_end - self.start_offset_ms;
|
||||
self.length_ms = self.length_ms.max(other.length_ms); // Ensure we don't go backwards
|
||||
|
||||
// Add the other chapter's title to merged chapters
|
||||
self.merged_chapters.push(other.title.clone());
|
||||
|
||||
// Update the title to indicate merging
|
||||
if self.merged_chapters.len() > 1 {
|
||||
self.title = format!("{} (includes: {})",
|
||||
self.merged_chapters[0],
|
||||
self.merged_chapters[1..].join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate filename based on format pattern
|
||||
pub fn generate_filename(&self, format: &ChapterNamingFormat, extension: &str) -> String {
|
||||
match format {
|
||||
ChapterNamingFormat::ChapterNumberTitle => {
|
||||
format!("Chapter{:02}_{}.{}",
|
||||
self.chapter_number,
|
||||
self.sanitize_title(&self.title),
|
||||
extension)
|
||||
},
|
||||
ChapterNamingFormat::NumberTitle => {
|
||||
format!("{:02}_{}.{}",
|
||||
self.chapter_number,
|
||||
self.sanitize_title(&self.title),
|
||||
extension)
|
||||
},
|
||||
ChapterNamingFormat::TitleOnly => {
|
||||
format!("{}.{}",
|
||||
self.sanitize_title(&self.title),
|
||||
extension)
|
||||
},
|
||||
ChapterNamingFormat::Custom(pattern) => {
|
||||
pattern
|
||||
.replace("{chapter:02}", &format!("{:02}", self.chapter_number))
|
||||
.replace("{chapter}", &format!("{}", self.chapter_number))
|
||||
.replace("{number:02}", &format!("{:02}", self.chapter_number))
|
||||
.replace("{number}", &format!("{}", self.chapter_number))
|
||||
.replace("{title}", &self.sanitize_title(&self.title))
|
||||
.replace("{extension}", extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize title for use in filename
|
||||
fn sanitize_title(&self, title: &str) -> String {
|
||||
title
|
||||
.replace(":", "")
|
||||
.replace("/", "_")
|
||||
.replace("\\", "_")
|
||||
.replace("?", "")
|
||||
.replace("*", "")
|
||||
.replace("\"", "")
|
||||
.replace("<", "")
|
||||
.replace(">", "")
|
||||
.replace("|", "")
|
||||
.replace(" ", "_")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get hierarchical output path for this chapter
|
||||
pub fn get_hierarchical_output_path(&self, base_path: &Path, format: &ChapterNamingFormat, extension: &str) -> PathBuf {
|
||||
let filename = self.generate_filename(format, extension);
|
||||
|
||||
// Parse the full_path to create directory structure
|
||||
// e.g., "Part One: Empire > Chapter 1" -> "Part_One_Empire/Chapter_1.mp3"
|
||||
let path_parts: Vec<&str> = self.full_path.split(" > ").collect();
|
||||
|
||||
if path_parts.len() <= 1 {
|
||||
// No hierarchy - check if this is a parent chapter that should be in its own directory
|
||||
// If the title contains the chapter number pattern, it's a parent chapter
|
||||
if filename.contains("Chapter") && !self.full_path.contains(" > ") {
|
||||
// This is a parent chapter with content - place it in its own directory
|
||||
let dir_name = self.full_path
|
||||
.replace(":", "")
|
||||
.replace(" ", "_")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect::<String>();
|
||||
base_path.join(dir_name).join(filename)
|
||||
} else {
|
||||
// Regular top-level chapter
|
||||
base_path.join(filename)
|
||||
}
|
||||
} else {
|
||||
// Create directory structure from parent parts
|
||||
let parent_parts = &path_parts[..path_parts.len() - 1];
|
||||
let mut path = base_path.to_path_buf();
|
||||
|
||||
for part in parent_parts {
|
||||
let dir_name = part
|
||||
.replace(":", "")
|
||||
.replace(" ", "_")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect::<String>();
|
||||
path.push(dir_name);
|
||||
}
|
||||
|
||||
// Add the filename
|
||||
path.push(filename);
|
||||
path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FlattenedChapter {
|
||||
/// Check if this chapter should be included based on minimum duration
|
||||
pub fn should_include(&self, min_duration_ms: i64) -> bool {
|
||||
self.length_ms >= min_duration_ms
|
||||
}
|
||||
|
||||
/// Check if this chapter should be merged with the next chapter
|
||||
pub fn should_merge_with_next(&self, min_duration_ms: i64) -> bool {
|
||||
self.length_ms < min_duration_ms && self.length_ms > 0
|
||||
}
|
||||
|
||||
/// Generate filename based on format pattern
|
||||
pub fn generate_filename(&self, format: &ChapterNamingFormat, extension: &str) -> String {
|
||||
match format {
|
||||
ChapterNamingFormat::ChapterNumberTitle => {
|
||||
format!("Chapter{:02}_{}.{}",
|
||||
self.chapter_number,
|
||||
self.sanitize_title(&self.title),
|
||||
extension
|
||||
)
|
||||
},
|
||||
ChapterNamingFormat::NumberTitle => {
|
||||
format!("{:02}_{}.{}",
|
||||
self.chapter_number,
|
||||
self.sanitize_title(&self.title),
|
||||
extension
|
||||
)
|
||||
},
|
||||
ChapterNamingFormat::TitleOnly => {
|
||||
format!("{}.{}",
|
||||
self.sanitize_title(&self.title),
|
||||
extension
|
||||
)
|
||||
},
|
||||
ChapterNamingFormat::Custom(pattern) => {
|
||||
pattern
|
||||
.replace("{number:02}", &format!("{:02}", self.chapter_number))
|
||||
.replace("{number}", &format!("{}", self.chapter_number))
|
||||
.replace("{title}", &self.sanitize_title(&self.title))
|
||||
.replace("{extension}", extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitize title for use in filename
|
||||
fn sanitize_title(&self, title: &str) -> String {
|
||||
title
|
||||
.replace(":", "")
|
||||
.replace("/", "_")
|
||||
.replace("\\", "_")
|
||||
.replace("?", "")
|
||||
.replace("*", "")
|
||||
.replace("\"", "")
|
||||
.replace("<", "")
|
||||
.replace(">", "")
|
||||
.replace("|", "")
|
||||
.replace(" ", "_")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get output path for this chapter
|
||||
pub fn get_output_path(&self, base_path: &PathBuf, format: &ChapterNamingFormat, extension: &str) -> PathBuf {
|
||||
let filename = self.generate_filename(format, extension);
|
||||
base_path.join(filename)
|
||||
}
|
||||
|
||||
/// Get hierarchical output path for this chapter
|
||||
pub fn get_hierarchical_output_path(&self, base_path: &Path, format: &ChapterNamingFormat, extension: &str) -> PathBuf {
|
||||
let filename = self.generate_filename(format, extension);
|
||||
|
||||
// Parse the full_path to create directory structure
|
||||
// e.g., "Part One: Empire > Chapter 1" -> "Part_One_Empire/Chapter_1.mp3"
|
||||
let path_parts: Vec<&str> = self.full_path.split(" > ").collect();
|
||||
|
||||
if path_parts.len() <= 1 {
|
||||
// No hierarchy - check if this is a parent chapter that should be in its own directory
|
||||
// If the title contains the chapter number pattern, it's a parent chapter
|
||||
if filename.contains("Chapter") && !self.full_path.contains(" > ") {
|
||||
// This is a parent chapter with content - place it in its own directory
|
||||
let dir_name = self.full_path
|
||||
.replace(":", "")
|
||||
.replace(" ", "_")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect::<String>();
|
||||
base_path.join(dir_name).join(filename)
|
||||
} else {
|
||||
// Regular top-level chapter
|
||||
base_path.join(filename)
|
||||
}
|
||||
} else {
|
||||
// Create directory structure from parent parts
|
||||
let parent_parts = &path_parts[..path_parts.len() - 1];
|
||||
let mut path = base_path.to_path_buf();
|
||||
|
||||
for part in parent_parts {
|
||||
let dir_name = part
|
||||
.replace(":", "")
|
||||
.replace(" ", "_")
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
|
||||
.collect::<String>();
|
||||
path.push(dir_name);
|
||||
}
|
||||
|
||||
// Add the filename
|
||||
path.push(filename);
|
||||
path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Chapter naming format options
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ChapterNamingFormat {
|
||||
/// Chapter01_Title.ext
|
||||
ChapterNumberTitle,
|
||||
/// 01_Title.ext
|
||||
NumberTitle,
|
||||
/// Title.ext
|
||||
TitleOnly,
|
||||
/// Custom pattern with placeholders: {number:02}, {number}, {title}, {extension}
|
||||
Custom(String),
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -81,12 +488,41 @@ pub struct ContentReference {
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
impl ContentReference {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.acr.trim().is_empty() { return Err("acr is empty".to_string()); }
|
||||
if self.asin.trim().is_empty() { return Err("asin is empty".to_string()); }
|
||||
if self.codec.trim().is_empty() { return Err("codec is empty".to_string()); }
|
||||
if self.content_format.trim().is_empty() { return Err("content_format is empty".to_string()); }
|
||||
if self.content_size_in_bytes <= 0 { return Err("content_size_in_bytes is not positive".to_string()); }
|
||||
if self.file_version.trim().is_empty() { return Err("file_version is empty".to_string()); }
|
||||
if self.marketplace.trim().is_empty() { return Err("marketplace is empty".to_string()); }
|
||||
if self.sku.trim().is_empty() { return Err("sku is empty".to_string()); }
|
||||
if self.tempo.trim().is_empty() { return Err("tempo is empty".to_string()); }
|
||||
if self.version.trim().is_empty() { return Err("version is empty".to_string()); }
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LastPositionHeard {
|
||||
#[serde(rename = "last_updated")]
|
||||
pub last_updated: String,
|
||||
pub last_updated: Option<String>,
|
||||
#[serde(rename = "position_ms")]
|
||||
pub position_ms: i64,
|
||||
pub position_ms: Option<i64>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl LastPositionHeard {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if let Some(ref last_updated) = self.last_updated {
|
||||
if last_updated.trim().is_empty() { return Err("last_updated is empty".to_string()); }
|
||||
}
|
||||
if let Some(position_ms) = self.position_ms {
|
||||
if position_ms < 0 { return Err("position_ms is negative".to_string()); }
|
||||
}
|
||||
if self.status.trim().is_empty() { return Err("status is empty".to_string()); }
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,6 @@ mod chapters;
|
||||
mod ffprobe_format;
|
||||
mod voucher;
|
||||
|
||||
pub use chapters::*;
|
||||
pub use ffprobe_format::*;
|
||||
pub use voucher::*;
|
||||
|
||||
@@ -148,16 +148,20 @@ impl ContentUrl {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LastPositionHeard {
|
||||
#[serde(rename = "last_updated")]
|
||||
pub last_updated: String,
|
||||
pub last_updated: Option<String>,
|
||||
#[serde(rename = "position_ms")]
|
||||
pub position_ms: i64,
|
||||
pub position_ms: Option<i64>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl LastPositionHeard {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.last_updated.trim().is_empty() { return Err("last_updated is empty".to_string()); }
|
||||
if self.position_ms < 0 { return Err("position_ms is negative".to_string()); }
|
||||
if let Some(ref last_updated) = self.last_updated {
|
||||
if last_updated.trim().is_empty() { return Err("last_updated is empty".to_string()); }
|
||||
}
|
||||
if let Some(position_ms) = self.position_ms {
|
||||
if position_ms < 0 { return Err("position_ms is negative".to_string()); }
|
||||
}
|
||||
if self.status.trim().is_empty() { return Err("status is empty".to_string()); }
|
||||
Ok(())
|
||||
}
|
||||
@@ -238,16 +242,20 @@ impl PlaybackInfo {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LastPositionHeard2 {
|
||||
#[serde(rename = "last_updated")]
|
||||
pub last_updated: String,
|
||||
pub last_updated: Option<String>,
|
||||
#[serde(rename = "position_ms")]
|
||||
pub position_ms: i64,
|
||||
pub position_ms: Option<i64>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl LastPositionHeard2 {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.last_updated.trim().is_empty() { return Err("last_updated is empty".to_string()); }
|
||||
if self.position_ms < 0 { return Err("position_ms is negative".to_string()); }
|
||||
if let Some(ref last_updated) = self.last_updated {
|
||||
if last_updated.trim().is_empty() { return Err("last_updated is empty".to_string()); }
|
||||
}
|
||||
if let Some(position_ms) = self.position_ms {
|
||||
if position_ms < 0 { return Err("position_ms is negative".to_string()); }
|
||||
}
|
||||
if self.status.trim().is_empty() { return Err("status is empty".to_string()); }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user