feat: improve output-path handling and error messages

- If --output-path is a directory, output file uses default naming in that directory
- If --output-path is a file, use as-is
- Improved directory existence/writability checks and error messages
- Updated CLI help and documentation to describe new behavior
- Cleaned up unused imports and resolved all warnings
- Ensured ffmpeg failures are clearly reported to the user
This commit is contained in:
2025-07-18 22:37:29 +02:00
parent bf575e501f
commit f8cca3e848
14 changed files with 1662 additions and 71 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

11
.idea/audible-util.iml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="EMPTY_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/target" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/audible-util.iml" filepath="$PROJECT_DIR$/.idea/audible-util.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

35
.roomodes Normal file
View File

@@ -0,0 +1,35 @@
customModes:
- slug: project-research
name: 🔍 Project Research
roleDefinition: |
You are a detailed-oriented research assistant specializing in examining and understanding codebases. Your primary responsibility is to analyze the file structure, content, and dependencies of a given project to provide comprehensive context relevant to specific user queries.
whenToUse: |
Use this mode when you need to thoroughly investigate and understand a codebase structure, analyze project architecture, or gather comprehensive context about existing implementations. Ideal for onboarding to new projects, understanding complex codebases, or researching how specific features are implemented across the project.
description: Investigate and analyze codebase structure
groups:
- read
customInstructions: |
Your role is to deeply investigate and summarize the structure and implementation details of the project codebase. To achieve this effectively, you must:
1. Start by carefully examining the file structure of the entire project, with a particular emphasis on files located within the "docs" folder. These files typically contain crucial context, architectural explanations, and usage guidelines.
2. When given a specific query, systematically identify and gather all relevant context from:
- Documentation files in the "docs" folder that provide background information, specifications, or architectural insights.
- Relevant type definitions and interfaces, explicitly citing their exact location (file path and line number) within the source code.
- Implementations directly related to the query, clearly noting their file locations and providing concise yet comprehensive summaries of how they function.
- Important dependencies, libraries, or modules involved in the implementation, including their usage context and significance to the query.
3. Deliver a structured, detailed report that clearly outlines:
- An overview of relevant documentation insights.
- Specific type definitions and their exact locations.
- Relevant implementations, including file paths, functions or methods involved, and a brief explanation of their roles.
- Critical dependencies and their roles in relation to the query.
4. Always cite precise file paths, function names, and line numbers to enhance clarity and ease of navigation.
5. Organize your findings in logical sections, making it straightforward for the user to understand the project's structure and implementation status relevant to their request.
6. Ensure your response directly addresses the user's query and helps them fully grasp the relevant aspects of the project's current state.
These specific instructions supersede any conflicting general instructions you might otherwise follow. Your detailed report should enable effective decision-making and next steps within the overall workflow.
source: global

484
Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "Inflector"
@@ -8,6 +8,15 @@ version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
version = "0.6.18"
@@ -57,15 +66,45 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "anyhow"
version = "1.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
[[package]]
name = "assert_cmd"
version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bd389a4b2970a01282ee455294913c0a43724daedcd1a24c3eb0ec1c1320b66"
dependencies = [
"anstyle",
"bstr",
"doc-comment",
"libc",
"predicates",
"predicates-core",
"predicates-tree",
"wait-timeout",
]
[[package]]
name = "audible-util"
version = "0.1.0"
dependencies = [
"Inflector",
"anyhow",
"assert_cmd",
"clap",
"crossterm",
"env_logger",
"indicatif",
"log",
"predicates",
"serde",
"serde_json",
"tempfile",
"which",
]
[[package]]
@@ -80,6 +119,23 @@ version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
[[package]]
name = "bstr"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4"
dependencies = [
"memchr",
"regex-automata",
"serde",
]
[[package]]
name = "bumpalo"
version = "3.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
[[package]]
name = "cfg-if"
version = "1.0.0"
@@ -132,6 +188,19 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys 0.59.0",
]
[[package]]
name = "crossterm"
version = "0.28.1"
@@ -142,7 +211,7 @@ dependencies = [
"crossterm_winapi",
"mio",
"parking_lot",
"rustix",
"rustix 0.38.42",
"signal-hook",
"signal-hook-mio",
"winapi",
@@ -157,6 +226,53 @@ dependencies = [
"winapi",
]
[[package]]
name = "difflib"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
[[package]]
name = "doc-comment"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "env_filter"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "errno"
version = "0.3.10"
@@ -167,12 +283,61 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "float-cmp"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
dependencies = [
"num-traits",
]
[[package]]
name = "getrandom"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasi 0.14.2+wasi-0.2.4",
]
[[package]]
name = "heck"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
"console",
"number_prefix",
"portable-atomic",
"unicode-width",
"web-time",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@@ -185,6 +350,40 @@ version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
[[package]]
name = "jiff"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde",
]
[[package]]
name = "jiff-static"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "js-sys"
version = "0.3.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.169"
@@ -197,6 +396,12 @@ 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 = "lock_api"
version = "0.4.12"
@@ -227,10 +432,37 @@ checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd"
dependencies = [
"libc",
"log",
"wasi",
"wasi 0.11.0+wasi-snapshot-preview1",
"windows-sys 0.52.0",
]
[[package]]
name = "normalize-line-endings"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "parking_lot"
version = "0.12.3"
@@ -255,23 +487,74 @@ dependencies = [
]
[[package]]
name = "proc-macro2"
version = "1.0.92"
name = "portable-atomic"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]]
name = "predicates"
version = "3.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573"
dependencies = [
"anstyle",
"difflib",
"float-cmp",
"normalize-line-endings",
"predicates-core",
"regex",
]
[[package]]
name = "predicates-core"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa"
[[package]]
name = "predicates-tree"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c"
dependencies = [
"predicates-core",
"termtree",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.37"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "redox_syscall"
version = "0.5.8"
@@ -281,6 +564,35 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "rustix"
version = "0.38.42"
@@ -290,7 +602,20 @@ dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"linux-raw-sys 0.4.14",
"windows-sys 0.59.0",
]
[[package]]
name = "rustix"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys 0.9.4",
"windows-sys 0.59.0",
]
@@ -382,33 +707,155 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.91"
version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d53cbcb5a243bd33b7858b1d7f4aca2153490815872d86d955d6ea29f743c035"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix 1.0.8",
"windows-sys 0.59.0",
]
[[package]]
name = "termtree"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
[[package]]
name = "unicode-ident"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83"
[[package]]
name = "unicode-width"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wait-timeout"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
dependencies = [
"libc",
]
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "wasi"
version = "0.14.2+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
dependencies = [
"wit-bindgen-rt",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
dependencies = [
"cfg-if",
"once_cell",
"wasm-bindgen-macro",
]
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
dependencies = [
"bumpalo",
"log",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "which"
version = "6.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f"
dependencies = [
"either",
"home",
"rustix 0.38.42",
"winsafe",
]
[[package]]
name = "winapi"
version = "0.3.9"
@@ -512,3 +959,18 @@ name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winsafe"
version = "0.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
[[package]]
name = "wit-bindgen-rt"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
dependencies = [
"bitflags",
]

View File

@@ -8,5 +8,13 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
clap = { version = "4.5", features = ["derive"] }
crossterm = "0.28"
Inflector = { version = "0.11", default-features = false }
anyhow = "1.0"
log = "0.4"
env_logger = "0.11"
indicatif = "0.17"
which = "6.0"
[dev-dependencies]
assert_cmd = "2.0"
tempfile = "3.10"
predicates = "3.1"

181
README.md Normal file
View File

@@ -0,0 +1,181 @@
# audible-util
**audible-util** is a command-line utility for converting Audible `.aaxc` audiobook files into standard audio formats (MP3, WAV, FLAC) using a voucher file generated by [audible-cli](https://github.com/audible-tools/audible-cli). It leverages `ffmpeg` and `ffprobe` for decoding and metadata extraction, providing a robust and extensible tool for audiobook enthusiasts and archivists.
---
## Features
- Converts Audible `.aaxc` files to MP3, WAV, or FLAC.
- Uses voucher files from `audible-cli` for decryption.
- Automatically infers voucher file if not specified.
- 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.
---
## Installation
### Prerequisites
- **Rust** (edition 2021 or later): [Install Rust](https://www.rust-lang.org/tools/install)
- **ffmpeg** and **ffprobe**: Must be installed and available in your `PATH`.
- The tool checks for these dependencies before any processing and will provide a clear error if they are missing.
- On Ubuntu/Debian:
```sh
sudo apt-get install ffmpeg
```
- On macOS (Homebrew):
```sh
brew install ffmpeg
```
- On Windows:
Download from [ffmpeg.org](https://ffmpeg.org/download.html) and add to your `PATH`.
### Build from Source
Clone the repository and build with Cargo:
```sh
git clone https://github.com/yourusername/audible-util.git
cd audible-util
cargo build --release
```
The binary will be in `target/release/audible-util`.
---
## Usage
All user-provided arguments and files are validated before any processing begins. The tool will check:
- That the input `.aaxc` file exists, is readable, and has the correct extension.
- That the voucher file exists and is readable (if provided or inferred).
- That the output directory exists and is writable (if a custom output path is provided).
- That `ffmpeg` and `ffprobe` are installed and available in your `PATH`.
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
```
If `--voucher-path` 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`. |
#### 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
```
#### Example: Use default voucher and output
```sh
audible-util --aaxc-path book.aaxc
```
---
## Voucher File Requirements
- The voucher file must be a JSON file generated by [audible-cli](https://github.com/audible-tools/audible-cli).
- The file is validated for required fields and structure.
- If invalid or missing, the tool will display a detailed error message.
---
## Output Formats
- **MP3** (default): `--output-type mp3`
- **WAV**: `--output-type wav`
- **FLAC**: `--output-type flac`
The output format system is extensible. To add a new format, implement the `OutputFormat` trait in [`src/cli.rs`](src/cli.rs:30).
---
## Splitting Functionality
- 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.
---
## Troubleshooting & Common Errors
- **"Input file does not exist"**
The path provided to `--aaxc-path` does not point to a file. Check the file path and try again.
- **"Input file does not have a .aaxc extension"**
The input file must have a `.aaxc` extension.
- **"Input file is not readable"**
Check file permissions for the input file.
- **"Voucher file does not exist" / "Voucher file is not readable"**
Check the voucher file path and permissions.
- **"Output directory does not exist" / "Output directory is not writable"**
Ensure the output directory exists and is writable, or specify a different output path.
- **"Required external tool 'ffmpeg'/'ffprobe' is not installed or not found in your PATH"**
Install the missing tool and ensure it is available in your system `PATH`. See Prerequisites above.
- **"Could not get file stem from the input file path"**
Ensure the `--aaxc-path` points to a valid `.aaxc` file.
- **"Failed to open voucher file"**
Check that the voucher file exists and is readable.
- **"Failed to parse voucher file"**
The voucher file must be valid JSON generated by `audible-cli`.
- **"Failed to start ffmpeg/ffprobe"**
Ensure `ffmpeg` and `ffprobe` are installed and available in your `PATH`.
- **"ffmpeg/ffprobe failed with error"**
The input file may be corrupt or not a valid Audible AAXC file.
- For verbose logs, set the `RUST_LOG` environment variable:
```sh
RUST_LOG=info audible-util --aaxc-path book.aaxc
```
---
## Contribution Guidelines
Contributions are welcome! To contribute:
1. Fork the repository.
2. Create a new branch for your feature or bugfix.
3. Write clear, well-documented code and update/add tests if applicable.
4. Submit a pull request with a detailed description.
For major changes or questions, please open an issue first to discuss your proposal.
---
## Contact
For questions, issues, or suggestions, please open an issue on the [GitHub repository](https://github.com/yourusername/audible-util) or contact the maintainer at `your.email@example.com`.
---
## License
This project is licensed under the MIT License.

View File

@@ -3,33 +3,101 @@ use std::path::PathBuf;
use clap::{Parser, ValueEnum};
#[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"
)]
pub struct Cli {
/// Path to aaxc file
#[clap(short, long)]
/// 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")]
pub aaxc_path: PathBuf,
/// voucher file
#[clap(long)]
/// Path to the voucher file required for decryption.
///
/// 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.")]
pub voucher_path: Option<PathBuf>,
// Ideal interface
// I need to get a path to the audio file which can be either aaxc or aax
// Optionally I can get the output path
// Also I need to a flag to determine whether to split the final file or not
// I might want to add an option to choose the output file type like mp3, flac etc...
#[clap(long)]
pub output_path: Option<PathBuf>,
#[clap(short, long)]
/// 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 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(
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."
)]
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).")]
pub split: bool,
/// Output file type enum
#[clap(long, value_enum)]
/// Output file type/format.
///
/// Supported values: mp3, wav, flac
/// 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>,
}
pub trait OutputFormat {
fn codec(&self) -> &'static str;
fn extension(&self) -> &'static str;
}
pub struct Mp3Format;
pub struct WavFormat;
pub struct FlacFormat;
impl OutputFormat for Mp3Format {
fn codec(&self) -> &'static str { "mp3" }
fn extension(&self) -> &'static str { "mp3" }
}
impl OutputFormat for WavFormat {
fn codec(&self) -> &'static str { "pcm_s16le" }
fn extension(&self) -> &'static str { "wav" }
}
impl OutputFormat for FlacFormat {
fn codec(&self) -> &'static str { "flac" }
fn extension(&self) -> &'static str { "flac" }
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum OutputType {
/// MPEG Layer 3 Audio (.mp3)
Mp3,
/// Waveform Audio File Format (.wav)
Wav,
/// Free Lossless Audio Codec (.flac)
Flac,
}
impl OutputType {
pub fn get_format(&self) -> Box<dyn OutputFormat> {
match self {
OutputType::Mp3 => Box::new(Mp3Format),
OutputType::Wav => Box::new(WavFormat),
OutputType::Flac => Box::new(FlacFormat),
}
}
}

View File

@@ -3,66 +3,265 @@ mod models;
use crate::models::FFProbeFormat;
use clap::Parser;
use crossterm::cursor::{MoveTo, MoveUp, RestorePosition, SavePosition};
use crossterm::{cursor, execute, ExecutableCommand};
use inflector::Inflector;
use std::io::{self, Cursor};
use std::path::{Path, PathBuf};
use std::process::Child;
use std::{
io::BufRead,
process::{Command, Stdio},
};
use anyhow::{Context, Result};
use log::{info, warn, error};
use indicatif::{ProgressBar, ProgressStyle};
// ffmpeg command:
// ffmpeg -audible_key 92f0173bcfb2fc144897da04603d07c0
// -audible_iv 33b00a91f553a54ee6c9e3556e021fab
// -i The_Way_of_Kings_The_Stormlight_Archive_Book_1-AAX_22_64.aaxc
// -map_metadata 0 -vn -codec:a mp3 the_way_of_kings.mp3
fn main() -> Result<()> {
// Initialize logger
env_logger::init();
// ffprobe -i The_Way_of_Kings_The_Stormlight_Archive_Book_1-AAX_22_64.aaxc -print_format json -show_format
info!("Starting audible-util");
if let Err(e) = run() {
error!("Fatal error: {e}");
eprintln!("Error: {e}");
std::process::exit(1);
}
info!("audible-util finished successfully");
Ok(())
}
fn main() {
fn run() -> Result<()> {
let cli = cli::Cli::parse();
let aaxc_file_path = cli.aaxc_path;
let aaxc_file_path_stem = aaxc_file_path
.file_stem()
.expect("Could not get file stem, is the path a file?");
info!("Parsing CLI arguments");
// Take the aaxc file stem and add .voucher to it to get the voucher file path
let voucher_file_path =
aaxc_file_path.with_file_name(format!("{}.voucher", aaxc_file_path_stem.to_str().unwrap()));
// --- Early input validation ---
// Check input .aaxc file exists, is readable, and has correct extension
let aaxc_file_path = cli.aaxc_path;
if !aaxc_file_path.exists() {
anyhow::bail!(
"Input file does not exist: {}. Please provide a valid .aaxc file.",
aaxc_file_path.display()
);
}
if !aaxc_file_path.is_file() {
anyhow::bail!(
"Input path is not a file: {}. Please provide a valid .aaxc file.",
aaxc_file_path.display()
);
}
if aaxc_file_path.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase()) != Some("aaxc".to_string()) {
anyhow::bail!(
"Input file does not have a .aaxc extension: {}. Please provide a valid Audible .aaxc file.",
aaxc_file_path.display()
);
}
if std::fs::File::open(&aaxc_file_path).is_err() {
anyhow::bail!(
"Input file is not readable: {}. Please check file permissions.",
aaxc_file_path.display()
);
}
// Determine voucher file path: use CLI override if provided
let voucher_file_path = if let Some(voucher_path) = cli.voucher_path {
info!("Using voucher file from CLI: {}", voucher_path.display());
// Check voucher file exists and is readable
if !voucher_path.exists() {
anyhow::bail!(
"Voucher file does not exist: {}. Please provide a valid voucher file.",
voucher_path.display()
);
}
if !voucher_path.is_file() {
anyhow::bail!(
"Voucher path is not a file: {}. Please provide a valid voucher file.",
voucher_path.display()
);
}
if std::fs::File::open(&voucher_path).is_err() {
anyhow::bail!(
"Voucher file is not readable: {}. Please check file permissions.",
voucher_path.display()
);
}
voucher_path
} else {
let aaxc_file_path_stem = aaxc_file_path
.file_stem()
.context("Could not get file stem from the input file path. Please provide a valid .aaxc file.")?;
let path = aaxc_file_path.with_file_name(
format!(
"{}.voucher",
aaxc_file_path_stem
.to_str()
.context("Failed to convert file stem to string. Please check your input file name.")?
),
);
info!("Using inferred voucher file: {}", path.display());
if !path.exists() {
anyhow::bail!(
"Inferred voucher file does not exist: {}. Please provide a valid voucher file or use --voucher-path.",
path.display()
);
}
if !path.is_file() {
anyhow::bail!(
"Inferred voucher path is not a file: {}. Please provide a valid voucher file or use --voucher-path.",
path.display()
);
}
if std::fs::File::open(&path).is_err() {
anyhow::bail!(
"Inferred voucher file is not readable: {}. Please check file permissions or use --voucher-path.",
path.display()
);
}
path
};
// 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
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, check its parent directory
if !parent.exists() {
anyhow::bail!(
"Output directory does not exist: {}. Please create it or specify a different output path.",
parent.display()
);
}
if std::fs::metadata(parent)
.map(|m| m.permissions().readonly())
.unwrap_or(true)
{
anyhow::bail!(
"Output directory is not writable: {}. Please check permissions or specify a different output path.",
parent.display()
);
}
}
}
// --- Pre-flight checks for ffmpeg and ffprobe ---
check_external_tool("ffmpeg")?;
check_external_tool("ffprobe")?;
// Use serde to deserialize voucher file into `AudibleCliVoucher`
let voucher: models::AudibleCliVoucher = serde_json::from_reader(
std::fs::File::open(&voucher_file_path).expect("Failed to open voucher file"),
)
.expect("Failed to deserialize voucher file");
info!("Opening voucher file: {}", voucher_file_path.display());
let voucher_file = std::fs::File::open(&voucher_file_path)
.with_context(|| format!(
"Failed to open voucher file: {}. Please ensure the file exists and is readable.",
voucher_file_path.display()
))?;
info!("Parsing voucher file");
let voucher: models::AudibleCliVoucher = serde_json::from_reader(voucher_file)
.with_context(|| format!(
"Failed to parse voucher file: {}. Please ensure it is a valid JSON file generated by audible-cli.",
voucher_file_path.display()
))?;
voucher.validate().map_err(|e| anyhow::anyhow!("Invalid voucher: {e}"))?;
info!("Voucher validated successfully");
let audible_key = voucher.content_license.license_response.key;
let audible_iv = voucher.content_license.license_response.iv;
let ffprobe_json = ffprobe(&aaxc_file_path);
info!("Running ffprobe on input file: {}", aaxc_file_path.display());
let ffprobe_json = ffprobe(&aaxc_file_path)
.with_context(|| format!(
"Failed to probe input file: {}. Please ensure ffprobe is installed and the file is a valid Audible AAXC file.",
aaxc_file_path.display()
))?;
ffprobe_json.validate().map_err(|e| anyhow::anyhow!("Invalid ffprobe data: {e}"))?;
info!("ffprobe completed and validated");
let title = ffprobe_json.format.tags.title;
let album = ffprobe_json.format.tags.album;
let duration = ffprobe_json.format.duration;
let file_name = format!("{}.mp3", album.to_snake_case());
// 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),
};
let codec = output_format.codec();
let ext = output_format.extension();
println!("Title: {}", title);
println!("File name: {}", file_name);
// 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 default_name = format!("{}.{}", album.to_snake_case(), ext);
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 {
format!("{}.{}", album.to_snake_case(), ext)
};
let mut cmd = ffmpeg(aaxc_file_path, audible_key, audible_iv, duration, file_name);
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.");
}
cmd.wait().unwrap();
info!("Title: {}", title);
info!("Output file name: {}", file_name);
info!("Starting ffmpeg conversion");
let mut cmd = ffmpeg(
aaxc_file_path,
audible_key,
audible_iv,
duration,
file_name.clone(),
codec,
)
.with_context(|| {
"Failed to start ffmpeg. Please ensure ffmpeg is installed and available in your PATH."
})?;
let status = cmd.wait()
.with_context(|| "ffmpeg process failed to complete. Please check your input files and try again.")?;
if status.success() {
info!("ffmpeg conversion completed successfully");
} else {
error!("ffmpeg conversion failed with status: {:?}", status);
anyhow::bail!(
"ffmpeg failed to convert the file. Please check your input files and try again. \
If the problem persists, ensure that ffmpeg is installed and supports the required codecs."
);
}
Ok(())
}
fn ffprobe(aaxc_file_path: &Path) -> FFProbeFormat {
fn ffprobe(aaxc_file_path: &Path) -> Result<FFProbeFormat> {
let ffprobe_cmd = Command::new("ffprobe")
.args([
"-i",
aaxc_file_path.to_str().unwrap(),
aaxc_file_path
.to_str()
.context("Failed to convert input file path to string.")?,
"-print_format",
"json",
"-show_format",
@@ -71,11 +270,21 @@ fn ffprobe(aaxc_file_path: &Path) -> FFProbeFormat {
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.expect("failed to execute process");
.with_context(|| "Failed to execute ffprobe. Is ffprobe installed and available in your PATH?")?;
let ffprobe_output = std::str::from_utf8(&ffprobe_cmd.stdout).unwrap();
let ffprobe_json: models::FFProbeFormat = serde_json::from_str(ffprobe_output).unwrap();
ffprobe_json
if !ffprobe_cmd.status.success() {
let stderr = String::from_utf8_lossy(&ffprobe_cmd.stderr);
anyhow::bail!(
"ffprobe failed with error:\n{}\nPlease ensure the input file is a valid Audible AAXC file.",
stderr
);
}
let ffprobe_output = std::str::from_utf8(&ffprobe_cmd.stdout)
.context("Failed to parse ffprobe output as UTF-8.")?;
let ffprobe_json: models::FFProbeFormat = serde_json::from_str(ffprobe_output)
.context("Failed to parse ffprobe output as JSON. The file may not be a valid Audible AAXC file.")?;
Ok(ffprobe_json)
}
fn ffmpeg(
@@ -84,7 +293,8 @@ fn ffmpeg(
audible_iv: String,
duration: String,
file_name: String,
) -> Child {
codec: &str,
) -> Result<Child> {
let mut cmd = Command::new("ffmpeg")
.args([
"-audible_key",
@@ -92,7 +302,9 @@ fn ffmpeg(
"-audible_iv",
audible_iv.as_str(),
"-i",
aaxc_file_path.to_str().unwrap(),
aaxc_file_path
.to_str()
.context("Failed to convert input file path to string.")?,
"-progress",
"/dev/stdout",
"-y",
@@ -100,28 +312,51 @@ fn ffmpeg(
"0",
"-vn",
"-codec:a",
"mp3",
codec,
file_name.as_str(),
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to execute process");
.with_context(|| "Failed to execute ffmpeg. Is ffmpeg installed and available in your PATH?")?;
{
let stdout = cmd.stdout.as_mut().unwrap();
let stdout = cmd.stdout.as_mut().context("Failed to capture ffmpeg stdout.")?;
let stdout_reader = std::io::BufReader::new(stdout);
let stdout_lines = stdout_reader.lines();
for line in stdout_lines {
let l = line.unwrap();
// 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=") {
println!("{} / {}", l, duration);
pb.set_message(format!("Progress: {} / {}", l, duration));
}
if l.contains("speed=") {
println!("{}", l);
pb.set_message(format!("{} | {}", pb.message(), l));
}
}
pb.finish_with_message("Conversion complete");
}
cmd
info!("ffmpeg process finished");
Ok(cmd)
}
/// Checks if an external tool is available in PATH, returns error with guidance if not.
fn check_external_tool(tool: &str) -> Result<()> {
if which::which(tool).is_err() {
anyhow::bail!(
"Required external tool '{}' is not installed or not found in your PATH.\n\
Please install '{}' and ensure it is available in your system PATH.\n\
See the README for installation instructions.",
tool, tool
);
}
Ok(())
}

View File

@@ -6,6 +6,13 @@ pub struct FFProbeFormat {
pub format: Format,
}
impl FFProbeFormat {
pub fn validate(&self) -> Result<(), String> {
self.format.validate().map_err(|e| format!("format: {}", e))
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Format {
@@ -31,6 +38,23 @@ pub struct Format {
pub tags: Tags,
}
impl Format {
pub fn validate(&self) -> Result<(), String> {
if self.filename.trim().is_empty() { return Err("filename is empty".to_string()); }
if self.nb_streams <= 0 { return Err("nb_streams is not positive".to_string()); }
if self.format_name.trim().is_empty() { return Err("format_name is empty".to_string()); }
if self.format_long_name.trim().is_empty() { return Err("format_long_name is empty".to_string()); }
if self.start_time.trim().is_empty() { return Err("start_time is empty".to_string()); }
if self.duration.trim().is_empty() { return Err("duration is empty".to_string()); }
if self.size.trim().is_empty() { return Err("size is empty".to_string()); }
if self.bit_rate.trim().is_empty() { return Err("bit_rate is empty".to_string()); }
if self.probe_score < 0 { return Err("probe_score is negative".to_string()); }
self.tags.validate().map_err(|e| format!("tags: {}", e))?;
Ok(())
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Tags {
@@ -52,3 +76,22 @@ pub struct Tags {
pub copyright: String,
pub date: String,
}
impl Tags {
pub fn validate(&self) -> Result<(), String> {
if self.major_brand.trim().is_empty() { return Err("major_brand is empty".to_string()); }
if self.minor_version.trim().is_empty() { return Err("minor_version is empty".to_string()); }
if self.compatible_brands.trim().is_empty() { return Err("compatible_brands is empty".to_string()); }
if self.creation_time.trim().is_empty() { return Err("creation_time is empty".to_string()); }
if self.genre.trim().is_empty() { return Err("genre is empty".to_string()); }
if self.title.trim().is_empty() { return Err("title is empty".to_string()); }
if self.artist.trim().is_empty() { return Err("artist is empty".to_string()); }
if self.album_artist.trim().is_empty() { return Err("album_artist is empty".to_string()); }
if self.album.trim().is_empty() { return Err("album is empty".to_string()); }
if self.comment.trim().is_empty() { return Err("comment is empty".to_string()); }
if self.copyright.trim().is_empty() { return Err("copyright is empty".to_string()); }
if self.date.trim().is_empty() { return Err("date is empty".to_string()); }
Ok(())
}
}

View File

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

View File

@@ -11,6 +11,17 @@ pub struct AudibleCliVoucher {
pub response_groups: Vec<String>,
}
impl AudibleCliVoucher {
pub fn validate(&self) -> Result<(), String> {
self.content_license.validate().map_err(|e| format!("content_license: {}", 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 ContentLicense {
@@ -42,6 +53,26 @@ pub struct ContentLicense {
pub voucher_id: String,
}
impl ContentLicense {
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()); }
self.content_metadata.validate().map_err(|e| format!("content_metadata: {}", e))?;
if self.drm_type.trim().is_empty() { return Err("drm_type is empty".to_string()); }
if self.granted_right.trim().is_empty() { return Err("granted_right is empty".to_string()); }
if self.license_id.trim().is_empty() { return Err("license_id is empty".to_string()); }
self.license_response.validate().map_err(|e| format!("license_response: {}", e))?;
if self.license_response_type.trim().is_empty() { return Err("license_response_type is empty".to_string()); }
if self.message.trim().is_empty() { return Err("message is empty".to_string()); }
self.playback_info.validate().map_err(|e| format!("playback_info: {}", e))?;
if self.request_id.trim().is_empty() { return Err("request_id is empty".to_string()); }
if self.status_code.trim().is_empty() { return Err("status_code is empty".to_string()); }
if self.voucher_id.trim().is_empty() { return Err("voucher_id is empty".to_string()); }
Ok(())
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ContentMetadata {
@@ -53,6 +84,16 @@ pub struct ContentMetadata {
pub last_position_heard: LastPositionHeard,
}
impl ContentMetadata {
pub fn validate(&self) -> Result<(), String> {
self.content_reference.validate().map_err(|e| format!("content_reference: {}", e))?;
self.content_url.validate().map_err(|e| format!("content_url: {}", 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 ContentReference {
@@ -71,6 +112,23 @@ 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 ContentUrl {
@@ -78,6 +136,14 @@ pub struct ContentUrl {
pub offline_url: String,
}
impl ContentUrl {
pub fn validate(&self) -> Result<(), String> {
if self.offline_url.trim().is_empty() { return Err("offline_url is empty".to_string()); }
Ok(())
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LastPositionHeard {
@@ -88,6 +154,16 @@ pub struct LastPositionHeard {
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 self.status.trim().is_empty() { return Err("status is empty".to_string()); }
Ok(())
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LicenseResponse {
@@ -96,6 +172,18 @@ pub struct LicenseResponse {
pub rules: Vec<Rule>,
}
impl LicenseResponse {
pub fn validate(&self) -> Result<(), String> {
if self.key.trim().is_empty() { return Err("key is empty".to_string()); }
if self.iv.trim().is_empty() { return Err("iv is empty".to_string()); }
for (i, rule) in self.rules.iter().enumerate() {
rule.validate().map_err(|e| format!("rules[{}]: {}", i, e))?;
}
Ok(())
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Rule {
@@ -103,6 +191,17 @@ pub struct Rule {
pub name: String,
}
impl Rule {
pub fn validate(&self) -> Result<(), String> {
if self.name.trim().is_empty() { return Err("name is empty".to_string()); }
for (i, param) in self.parameters.iter().enumerate() {
param.validate().map_err(|e| format!("parameters[{}]: {}", i, e))?;
}
Ok(())
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Parameter {
@@ -111,6 +210,15 @@ pub struct Parameter {
pub type_field: String,
}
impl Parameter {
pub fn validate(&self) -> Result<(), String> {
if self.expire_date.trim().is_empty() { return Err("expire_date is empty".to_string()); }
if self.type_field.trim().is_empty() { return Err("type is empty".to_string()); }
Ok(())
}
}
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaybackInfo {
@@ -118,6 +226,14 @@ pub struct PlaybackInfo {
pub last_position_heard: LastPositionHeard2,
}
impl PlaybackInfo {
pub fn validate(&self) -> Result<(), String> {
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 LastPositionHeard2 {
@@ -127,3 +243,13 @@ pub struct LastPositionHeard2 {
pub position_ms: 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 self.status.trim().is_empty() { return Err("status is empty".to_string()); }
Ok(())
}
}

401
tests/integration.rs Normal file
View File

@@ -0,0 +1,401 @@
use assert_cmd::Command;
use predicates::prelude::*;
use tempfile::NamedTempFile;
use std::fs::File;
use std::io::Write;
use std::env;
use std::os::unix::fs::PermissionsExt;
// Sample valid voucher JSON
const VALID_VOUCHER: &str = r#"{
"content_license": {
"acr": "acr",
"asin": "asin",
"content_metadata": {
"content_reference": {
"acr": "acr",
"asin": "asin",
"codec": "codec",
"content_format": "format",
"content_size_in_bytes": 123,
"file_version": "1",
"marketplace": "market",
"sku": "sku",
"tempo": "tempo",
"version": "v1"
},
"content_url": { "offline_url": "url" },
"last_position_heard": {
"last_updated": "now",
"position_ms": 0,
"status": "ok"
}
},
"drm_type": "drm",
"granted_right": "right",
"license_id": "id",
"license_response": {
"key": "key",
"iv": "iv",
"rules": [{
"parameters": [{
"expire_date": "date",
"type": "type"
}],
"name": "rule"
}]
},
"license_response_type": "type",
"message": "msg",
"playback_info": {
"last_position_heard": {
"last_updated": "now",
"position_ms": 0,
"status": "ok"
}
},
"preview": false,
"request_id": "req",
"requires_ad_supported_playback": false,
"status_code": "ok",
"voucher_id": "vid"
},
"response_groups": ["group"]
}"#;
// Sample valid ffprobe JSON
const VALID_FFPROBE: &str = r#"{
"format": {
"filename": "file.aaxc",
"nb_streams": 1,
"nb_programs": 0,
"nb_stream_groups": 0,
"format_name": "aax",
"format_long_name": "Audible AAX",
"start_time": "0",
"duration": "100",
"size": "1000",
"bit_rate": "128000",
"probe_score": 100,
"tags": {
"major_brand": "brand",
"minor_version": "1",
"compatible_brands": "brand",
"creation_time": "now",
"genre": "genre",
"title": "title",
"artist": "artist",
"album_artist": "album_artist",
"album": "album",
"comment": "comment",
"copyright": "copyright",
"date": "2020"
}
}
}"#;
fn write_temp_file(contents: &str, suffix: &str) -> NamedTempFile {
use tempfile::Builder;
let mut file = Builder::new()
.suffix(suffix)
.tempfile()
.expect("temp file");
file.write_all(contents.as_bytes()).expect("write temp");
file
}
#[test]
fn test_missing_required_argument() {
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.assert()
.failure()
.stderr(predicate::str::contains("USAGE"));
}
#[test]
fn test_invalid_input_file() {
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg("nonexistent.aaxc");
cmd.arg("--voucher-path").arg("nonexistent.voucher");
cmd.assert()
.failure()
.stderr(predicate::str::contains("Could not get file stem"));
}
#[test]
fn test_input_file_missing() {
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg("doesnotexist.aaxc");
cmd.arg("--voucher-path").arg("doesnotexist.voucher");
cmd.assert()
.failure()
.stderr(predicate::str::contains("Input file does not exist"));
}
#[test]
fn test_input_file_wrong_extension() {
let file = write_temp_file("", ".mp3");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Input file does not have a .aaxc extension"));
}
#[test]
fn test_input_file_not_readable() {
let file = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
// Set file to 000 permissions (unreadable)
std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Input file is not readable"));
// Restore permissions for cleanup
std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
}
#[test]
fn test_voucher_file_missing() {
let file = write_temp_file("", ".aaxc");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg("doesnotexist.voucher");
cmd.assert()
.failure()
.stderr(predicate::str::contains("Voucher file does not exist"));
}
#[test]
fn test_voucher_file_not_readable() {
let file = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
std::fs::set_permissions(voucher.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Voucher file is not readable"));
std::fs::set_permissions(voucher.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
}
#[test]
fn test_output_directory_missing() {
let file = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
let out_path = std::path::Path::new("nonexistent_dir/output.mp3");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.arg("--output-path").arg(out_path);
cmd.assert()
.failure()
.stderr(predicate::str::contains("Output directory does not exist"));
}
#[test]
fn test_output_directory_not_writable() {
let file = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
let tempdir = tempfile::tempdir().unwrap();
let unwritable_dir = tempdir.path().join("unwritable");
std::fs::create_dir(&unwritable_dir).unwrap();
std::fs::set_permissions(&unwritable_dir, std::fs::Permissions::from_mode(0o555)).unwrap();
let out_path = unwritable_dir.join("output.mp3");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(file.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.arg("--output-path").arg(&out_path);
cmd.assert()
.failure()
.stderr(predicate::str::contains("Output directory is not writable"));
std::fs::set_permissions(&unwritable_dir, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn test_missing_ffmpeg() {
let aaxc = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
// Remove ffmpeg from PATH
let orig_path = env::var("PATH").unwrap();
env::set_var("PATH", "");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(aaxc.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Required external tool 'ffmpeg' is not installed"));
env::set_var("PATH", orig_path);
}
#[test]
fn test_invalid_voucher_file() {
let aaxc = write_temp_file("", ".aaxc");
let voucher = write_temp_file("not json", ".voucher");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(aaxc.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Failed to parse voucher file"));
}
#[test]
fn test_output_format_selection() {
let aaxc = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
// Patch ffprobe to echo valid JSON
let ffprobe = write_temp_file(
"#!/bin/sh\necho '$VALID_FFPROBE'",
".sh"
);
let ffprobe_path = ffprobe.path();
std::fs::set_permissions(ffprobe_path, std::fs::Permissions::from_mode(0o755)).unwrap();
let orig_path = env::var("PATH").unwrap();
let new_path = format!("{}:{}", ffprobe_path.parent().unwrap().display(), orig_path);
env::set_var("PATH", new_path);
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(aaxc.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.arg("--output-type").arg("flac");
// Should succeed up to ffmpeg (which will fail, but output format logic is exercised)
let assert = cmd.assert();
assert.stderr(predicate::str::contains("ffmpeg"));
// Restore PATH
env::set_var("PATH", orig_path);
}
#[test]
fn test_missing_ffprobe() {
let aaxc = write_temp_file("", ".aaxc");
let voucher = write_temp_file(VALID_VOUCHER, ".voucher");
// Remove ffprobe from PATH
let orig_path = env::var("PATH").unwrap();
env::set_var("PATH", "");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(aaxc.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Failed to execute ffprobe"));
env::set_var("PATH", orig_path);
}
#[test]
fn test_voucher_data_validation() {
let aaxc = write_temp_file("", ".aaxc");
// Voucher missing response_groups
let invalid_voucher = r#"{
"content_license": {
"acr": "acr",
"asin": "asin",
"content_metadata": {
"content_reference": {
"acr": "acr",
"asin": "asin",
"codec": "codec",
"content_format": "format",
"content_size_in_bytes": 123,
"file_version": "1",
"marketplace": "market",
"sku": "sku",
"tempo": "tempo",
"version": "v1"
},
"content_url": { "offline_url": "url" },
"last_position_heard": {
"last_updated": "now",
"position_ms": 0,
"status": "ok"
}
},
"drm_type": "drm",
"granted_right": "right",
"license_id": "id",
"license_response": {
"key": "key",
"iv": "iv",
"rules": [{
"parameters": [{
"expire_date": "date",
"type": "type"
}],
"name": "rule"
}]
},
"license_response_type": "type",
"message": "msg",
"playback_info": {
"last_position_heard": {
"last_updated": "now",
"position_ms": 0,
"status": "ok"
}
},
"preview": false,
"request_id": "req",
"requires_ad_supported_playback": false,
"status_code": "ok",
"voucher_id": "vid"
},
"response_groups": []
}"#;
let voucher = write_temp_file(invalid_voucher, ".voucher");
let mut cmd = Command::cargo_bin("audible-util").unwrap();
cmd.arg("--aaxc_path").arg(aaxc.path());
cmd.arg("--voucher-path").arg(voucher.path());
cmd.assert()
.failure()
.stderr(predicate::str::contains("Invalid voucher"));
}
#[test]
fn test_real_conversion_aborted() {
use std::process::{Command as StdCommand, Stdio};
use std::{thread, time};
// Path to real aaxc and voucher files
let aaxc_path = "/home/ondra/Music/Oathbringer_The_Stormlight_Archive_Book_3-AAX_22_64.aaxc";
let voucher_path = "/home/ondra/Music/Oathbringer_The_Stormlight_Archive_Book_3-AAX_22_64.voucher";
// Output file in temp dir
let out_file = tempfile::NamedTempFile::new().unwrap();
let out_path = out_file.path().to_owned();
// Spawn the process
let mut child = StdCommand::new("cargo")
.arg("run")
.arg("--bin")
.arg("audible-util")
.arg("--")
.arg("--aaxc_path").arg(aaxc_path)
.arg("--voucher-path").arg(voucher_path)
.arg("--output-path").arg(&out_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("Failed to spawn audible-util");
// Let it run for a short time, then abort
thread::sleep(time::Duration::from_secs(2));
let _ = child.kill();
// Wait for process to exit and collect output
let output = child.wait_with_output().expect("Failed to wait on child");
// Assert process was killed and output contains expected abort/error message
assert!(!output.status.success(), "Process should not succeed when killed early");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("Aborted") || stderr.contains("killed") || stderr.contains("signal") || !output.status.success(),
"Expected abort/killed message in stderr, got: {}",
stderr
);
}