diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..69fddc8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,17 @@ +name: Rust +on: [push, pull_request] +jobs: + check: + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --check + - run: cargo clippy --all-targets --locked -- -D warnings + - run: cargo test --all-targets --locked diff --git a/.gitignore b/.gitignore index f109b48..6bff3f2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,8 @@ /Packages /.swiftpm /*.xcodeproj +/target/ +/.local/ +/dist/ +/runtime/ +*.opusab-journal diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..66a43cb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1204 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix", + "windows-sys 0.61.2", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "natord" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "308d96db8debc727c3fd9744aac51751243420e46edf401010908da7f8d5e57c" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opusab" +version = "0.2.0" +dependencies = [ + "anyhow", + "base64", + "clap", + "clap_complete", + "crossterm", + "ctrlc", + "fs2", + "image", + "libc", + "natord", + "ratatui", + "serde", + "serde_json", + "sha2", + "tempfile", + "unicode-width 0.2.0", + "walkdir", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +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", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c36404b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "opusab" +version = "0.2.0" +edition = "2024" +description = "Prepare, convert, and edit audiobooks from a terminal" +license = "MIT" + +[dependencies] +anyhow = "1" +base64 = "0.22" +clap = { version = "4.5", features = ["derive", "env"] } +clap_complete = "4.5" +crossterm = "0.28" +ctrlc = "3.4" +fs2 = "0.4" +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } +libc = "0.2" +natord = "1" +ratatui = "0.29" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tempfile = "3" +unicode-width = "0.2" +walkdir = "2" + +[profile.release] +lto = "thin" +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3d98963 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 opusab contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 24bbbc4..569f35f 100644 --- a/Makefile +++ b/Makefile @@ -1,16 +1,26 @@ -prefix ?= /usr/local -bindir = $(prefix)/bin +CARGO ?= cargo +prefix ?= $(HOME)/.local build: - swift build -c release --disable-sandbox + $(CARGO) build --release --locked -install: build - install ".build/release/opusab" "$(bindir)" +check: + $(CARGO) fmt --check + $(CARGO) clippy --all-targets --locked -- -D warnings + $(CARGO) test --all-targets --locked + +runtime: + scripts/build-ffmpeg.sh -uninstall: - rm -rf "$(bindir)/opusab" +package: + scripts/package-macos.sh + +# Installs the CLI only; use install.sh in the portable package for a bundled install. +install: build + install -d "$(prefix)/bin" + install target/release/opusab "$(prefix)/bin/opusab" clean: - rm -rf .build + $(CARGO) clean -.PHONY: build install uninstall clean +.PHONY: build check runtime package install clean diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index aa17b6f..0000000 --- a/Package.resolved +++ /dev/null @@ -1,43 +0,0 @@ -{ - "object": { - "pins": [ - { - "package": "CLInterface", - "repositoryURL": "https://github.com/nikstar/CLInterface.git", - "state": { - "branch": null, - "revision": "eaa70221f5a42f43fccf1ce05eab5492a4938d4c", - "version": "1.0.3" - } - }, - { - "package": "Files", - "repositoryURL": "https://github.com/johnsundell/files.git", - "state": { - "branch": null, - "revision": "a84615f4558151fab52ac38df697ce2442991f93", - "version": "2.3.0" - } - }, - { - "package": "Proc", - "repositoryURL": "https://github.com/nikstar/Proc.git", - "state": { - "branch": "master", - "revision": "1e4370ec9d5a3312f9c05f44f1b3c6757e3ca339", - "version": null - } - }, - { - "package": "SPMArgumentParser", - "repositoryURL": "https://github.com/nikstar/spm-argument-parser.git", - "state": { - "branch": null, - "revision": "e3c4895261730adf46ab012af05a3729871a15a1", - "version": "0.5.0" - } - } - ] - }, - "version": 1 -} diff --git a/Package.swift b/Package.swift deleted file mode 100644 index b8ebcb6..0000000 --- a/Package.swift +++ /dev/null @@ -1,31 +0,0 @@ -// swift-tools-version:5.1 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -import PackageDescription - -let package = Package( - name: "Opusab", - platforms: [ - .macOS(.v10_13) - ], - products: [ - .executable(name: "opusab", targets: ["Opusab"]) - ], - dependencies: [ - .package(url: "https://github.com/johnsundell/files.git", from: "2.0.0"), - .package(url: "https://github.com/nikstar/CLInterface.git", from: "1.0.3"), - .package(url: "https://github.com/nikstar/Proc.git", .branch("master")), - ], - targets: [ - .target( - name: "Opusab", - dependencies: ["OpusabCore"]), - .target( - name: "OpusabCore", - dependencies: ["Files", "CLInterface", "Proc"]), - .target( - name: "OpusabTest", - dependencies: ["OpusabCore"]), - ] -) - diff --git a/README.md b/README.md index 4bae0d5..8ac9a23 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,194 @@ -# Opusab +# opusab -Create Opus audiobooks from a folder of mp3 files. Opus offers ~4x smaller file sizes compared to mp3 without losing in quality. +Prepare an audiobook, convert it to one Opus file, and edit its metadata from a +terminal. Written in Rust, with a Ratatui editor and a scriptable JSON CLI. +The encoder uses fre:ac's **SuperFast** Opus driver to parallelize a single input, +including a long M4B. The application supplies the audiobook workflow: source +ordering, chapters, metadata, bitrate suggestions, and fast updates afterward. + +## Start here + +From the portable macOS package: + +```sh +./opusab '/path/to/book.m4b' +./opusab '/path/to/folder of MP3s' +./opusab '/path/to/finished-book.opus' ``` -OVERVIEW: Create Opus audiobooks from a list of mp3 files -USAGE: opusab files... +A source opens the terminal editor. Review the **Book**, **Chapters**, **Files**, +and **Encoding** tabs, then press **F5** or **Ctrl+S** to save/convert. An existing +Opus file keeps its audio by default. Changing its bitrate, channels, or preset +explicitly requests a new encode into a separate `-converted.opus` file. + +| Key | Action | +| --- | --- | +| Tab / Shift+Tab, or 1–4 | Switch tabs | +| ↑ / ↓, or j / k | Select a row | +| Enter | Edit the selected value | +| Ctrl+U / Esc | Clear an input / cancel editing | +| F5 / Ctrl+S | Save metadata or convert | +| t / a / d / r in Chapters | Edit start time / add / delete / regenerate | +| Shift+J / Shift+K in Files | Reorder inputs and regenerate chapters | +| Ctrl+C while working | Cancel and clean up the unfinished output | +| q | Exit; unsaved edits prompt before being discarded | -OPTIONS: - --bitrate, -b bitrate in kbits (default 32) - --cover path to the cover file - --dry-run, -n print command but do not execute it - --output, -o name of the output file - --verbose, -v verbose output - --help Display available options +A terminal of at least 54 × 16 is required; 100 × 30 is more comfortable. Use +explicit commands below in scripts; they never open an editor or prompt. -POSITIONAL ARGUMENTS: - files audio files +## CLI and agents + +```sh +opusab inspect '/path/to/book.m4b' --json +opusab plan '/path/to/MP3 folder' --json +opusab convert '/path/to/book.m4b' -o book.opus --jobs 6 --json +opusab convert 01.mp3 02.mp3 03.mp3 -o book.opus --metadata metadata.json +opusab tags show book.opus --json +opusab tags set book.opus --title 'A Farewell to Arms' --author 'Ernest Hemingway' +opusab tags apply book.opus --from metadata.json --in-place --json +opusab doctor --json ``` -**This is work in progress. Suggestions and PRs are very welcome!** +Explicit file arguments retain their order. A directory is scanned recursively; +when all files have track tags, disc/track order wins, otherwise filenames are +naturally sorted (`2` before `10`). Inspect the plan when tags are inconsistent. +Source chapters are preserved; multiple files without chapters become chapters. +Folder artwork prefers `cover`, `folder`, then names containing `front`. + +A folder's default output is a **sibling** `Book Title.opus`, so scanning the same +folder again does not pick up the previous output. A single M4B/MP3 defaults to +its filename with `.opus`. Existing outputs require `--overwrite`; conversion +cannot overwrite its source. Temporary output is written beside the destination +and committed only after completion. + +`--json` emits one versioned result on stdout, with progress events and errors as +JSON Lines on stderr. `--quiet` suppresses progress. Exit codes are 0 for success, +1 for operational errors, 2 for invalid arguments, and 130 for cancellation. +Errors contain `schema_version`, `error.code`, and `error.message`. Every command +has `--help`; `opusab completions zsh` (or bash/fish/etc.) generates completions. + +### Metadata JSON -## Installation +Use a raw patch or a `{ "schema_version": 1, "metadata": { ... } }` envelope: + +```json +{ + "title": "A Farewell to Arms", + "author": "Ernest Hemingway", + "narrator": "Narrator name", + "series": "", + "series_part": "", + "date": "1929", + "genre": "Audiobook", + "description": "An optional description.", + "cover": "cover.jpg", + "chapters": [ + { "start_ms": 0, "title": "Chapter 1" }, + { "start_ms": 125340, "title": "Chapter 2" } + ], + "extra": { "LANGUAGE": ["eng"] } +} ``` -brew install nikstar/tap/opusab --HEAD + +Missing or `null` fields stay unchanged; an empty string clears a text field. +`chapters` replaces the chapter list, and `[]` removes it. Starts are integer +milliseconds, strictly increasing and within the recording. `cover` accepts JPEG +or PNG up to 8 MiB and resolves relative to the JSON file. `remove_cover: true` +removes embedded artwork. `extra` sets arbitrary tags (arrays allow repeated +values); `remove_tags` removes named extra tags. Dedicated fields must be used +for managed tags such as title or chapters. Unknown fields are rejected. + +`opusab tags show book.opus --json > metadata.json` produces a document that can +be edited and applied again. Embedded artwork is preserved without exporting it. +`--from -` and `--metadata -` read JSON from stdin. The full CLI contract and +implementation details are in [docs/DESIGN.md](docs/DESIGN.md). + +## Fast metadata updates + +New outputs reserve **256 KiB** of extra space in the OpusTags header. Updating +metadata rewrites only changed header pages and their Ogg checksums. It keeps the +file's size, identity, audio bytes, and audio offsets unchanged. Reading the small +header and tail does not require scanning the recording. + +If a larger cover or description does not fit, the edit fails with +`metadata_capacity` **before changing anything**. Expand the reserve explicitly: + +```sh +opusab tags reserve book.opus --padding-kib 1024 +opusab tags apply book.opus --from metadata.json ``` -*Please use the up-to-date `--HEAD` version until **opusab** reaches 1.0* -## Bitrates -+ 48 kbit/s or higher should only be used for high quality sources with occasional background music -+ 32 kbit/s is a **good default** that should be used to convert, for example, 120 kbit/s mp3s -+ 24 kbit/s offers good quality and I personally cannot tell difference from 32 kbit/s, but you might :) -+ 16 kbit/s is perfectly intelligible and gives you tiny files at just **7.2 MB per hour** +`reserve` is a one-time full file repack, preserving compressed audio packets. +Normal tag editing never silently falls back to a full rewrite. The same commands +work on Opus files created by other tools, subject to available header capacity. + +Updates use an exclusive file lock and a small adjacent recovery journal. If a +process or machine dies during a write, run `opusab tags recover book.opus` to +restore the previous metadata. Keep the `.opusab-journal` file with the audiobook +until recovery finishes. Readers that ignore the lock can briefly observe an +in-progress header; fully atomic whole-file replacement would violate the fast +edit requirement. Single-stream Ogg Opus is supported; chained or multiplexed +streams must first be converted to one logical stream. + +## Encoding defaults + +| Preset | Suggested bitrate | Intended use | +| --- | --- | --- | +| `speech` (default) | 32 kbit/s VBR | Narration | +| `speech`, low-bandwidth source | 24 kbit/s VBR | Sources at ≤24 kHz, or MP3 at ≤64 kbit/s | +| `compact` | 24 kbit/s VBR | Smaller narration files | +| `mixed` | 64 kbit/s VBR | Speech with music or stereo effects | + +These are editable starting points, not equivalent-quality guarantees. Lossy +transcoding cannot recover source detail. Use `--bitrate 40` to override. Original +channels are kept by default; `--channels mono` or `stereo` performs an additional +lossless preparation pass. This can require substantial temporary disk space. +Worker count defaults to up to eight available cores; `--jobs 1` disables the +SuperFast parallel path. Encoding uses 20 ms frames and complexity 10. + +Chapter offsets retain millisecond precision. Source duration estimates can +include codec priming or padding, so generated file boundaries can differ slightly +from the decoded audio. The tool deliberately does not use fre:ac 1.1.7's generated +whole-second chapter offsets, which accumulate rounding error. + +The full-book results and verification details are in [docs/VALIDATION.md](docs/VALIDATION.md). + +## Build and distribution + +```sh +cargo build --release --locked +cargo test --all-targets --locked +cargo clippy --all-targets --locked -- -D warnings +``` + +Use a current stable Rust toolchain. Metadata inspection and editing need only the +`opusab` executable. Conversion needs `freaccmd` and `ffprobe`; explicit channel +conversion and artwork extraction also use `ffmpeg`. + +Discovery checks `OPUSAB_FREACCMD`, `OPUSAB_FFPROBE`, and `OPUSAB_FFMPEG`, then +`OPUSAB_RUNTIME`, a `runtime` directory beside the executable, and PATH. On macOS +it also checks `/Applications/freac.app` and common Homebrew paths. `doctor` shows +the exact tools selected. + +For a portable macOS package, install the official fre:ac app, then run: + +```sh +scripts/build-ffmpeg.sh +scripts/package-macos.sh +``` + +This builds small FFmpeg/ffprobe executables from pinned, checksummed source and +packages them with the intact fre:ac runtime. The result is a relocatable directory +and `.tar.gz` under `dist/`, with no Homebrew dependency at runtime. Keep its +`runtime` directory beside `opusab`. Run the included `install.sh` to install into +`~/.local`, or pass a different prefix. It refuses to replace existing installs. + +This first distribution targets the host macOS architecture and is a local +unsigned development bundle. It is not a single static binary. Public releases +still need signing/notarization and complete corresponding-source/license +packaging; see [third-party notes](docs/THIRD_PARTY.md). Linux and Windows builds +have CI configuration but their runtime packages have not been validated here. +Phone support for Opus, Vorbis-style chapter tags, and cover art depends on the +player. Playback and chapter navigation on a physical phone have not been tested. diff --git a/Sources/Opusab/main.swift b/Sources/Opusab/main.swift deleted file mode 100644 index 28509a6..0000000 --- a/Sources/Opusab/main.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation -import OpusabCore - -do { - let opusab = try Opusab() - try opusab.run() -} catch { - print("error: \(error)", to: &stderr) - exit(1) -} diff --git a/Sources/OpusabCore/Helpers/ProgressReporter.swift b/Sources/OpusabCore/Helpers/ProgressReporter.swift deleted file mode 100644 index fb19bde..0000000 --- a/Sources/OpusabCore/Helpers/ProgressReporter.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation - -final class ProgressReporter { - let string: String - let steps: Int - var currentStep = 1 - let verbose: Bool - - private var descriptionLength = 0 - private var terminated = false - - init(description: String, steps: Int, verbose: Bool = false) { - self.string = description - self.steps = steps - self.verbose = verbose - } - - func nextStep(description: String) { - assert(currentStep <= steps) - let line = "\(string) (\(currentStep)/\(steps)): \(description)" - - if verbose == false { - print("\r" + String(repeating: " ", count: descriptionLength) + "\r", terminator: "", to: &stderr) - print(line, terminator: "", to: &stderr) - stderr.flush() - } else { - print(line, to: &stderr) - } - - descriptionLength = line.count - currentStep += 1 - } - - func terminate() { - assert(terminated == false) - print("", to: &stderr) - terminated = true - } -} - diff --git a/Sources/OpusabCore/Helpers/Stderr.swift b/Sources/OpusabCore/Helpers/Stderr.swift deleted file mode 100644 index b17af1a..0000000 --- a/Sources/OpusabCore/Helpers/Stderr.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -public struct Stderr : TextOutputStream { - private var _stderr = FileHandle.standardError - - fileprivate init() {} - - public mutating func write(_ string: String) { - let data = string.data(using: .utf8)! - _stderr.write(data) - } - - public func flush() { - fflush(__stderrp) - } -} - -public var stderr = Stderr() diff --git a/Sources/OpusabCore/Metadata/ChapterMetadata.swift b/Sources/OpusabCore/Metadata/ChapterMetadata.swift deleted file mode 100644 index 81c5efc..0000000 --- a/Sources/OpusabCore/Metadata/ChapterMetadata.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -struct ChapterMetadata { - var name: String - var start: Double -} - -extension ChapterMetadata : Codable {} - -extension Array where Element == ChapterMetadata { - init(files: [FileMetadata]) { - var acc = 0.0 - self = files.map { file in - defer { acc += file.duration } - return ChapterMetadata(name: file.name, start: acc) - } - } -} diff --git a/Sources/OpusabCore/Metadata/FileMetadata.swift b/Sources/OpusabCore/Metadata/FileMetadata.swift deleted file mode 100644 index cd9f19d..0000000 --- a/Sources/OpusabCore/Metadata/FileMetadata.swift +++ /dev/null @@ -1,47 +0,0 @@ -import Foundation -import Proc - -struct FileMetadata { - let filename: String - var name: String - let album: String - let author: String - let duration: Double -} - -extension FileMetadata { - init(file: String) throws { - let m = try Proc.mediainfo(file) - let duration = Proc.ffmpeg_accurateDuration(for: file) - self.init(filename: file, name: m.trackName, album: m.album, author: m.performer, duration: duration) - } -} - -extension Array where Element == FileMetadata { - mutating func stripCommonPrefix() { - guard count > 1 else { return } - let prefix = self.map { $0.name }.commonPrefix - if prefix.count > 0 { - for i in 0.. 0 else { return "" } - var prefix = self[0] - loop: while prefix.count > 0 { - for s in self.dropFirst() { - if !s.hasPrefix(prefix) { prefix = String(prefix.dropLast()); continue loop } - } - break - } - return prefix - } -} diff --git a/Sources/OpusabCore/Metadata/Metadata.swift b/Sources/OpusabCore/Metadata/Metadata.swift deleted file mode 100644 index 88200d2..0000000 --- a/Sources/OpusabCore/Metadata/Metadata.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation - -struct Metadata { - var title: String - var author: String - var chapters: [ChapterMetadata] - var totalDuration: Double -} - -extension Metadata : Codable {} - -extension Metadata { - var overview: String { - """ - \(title) by \(author) - \(chapters.count) chapters; total duration: \(String(time: totalDuration)) - """ - } - - func printJSON() { - let e = JSONEncoder() - e.outputFormatting = .prettyPrinted - let data = try! e.encode(self) - print(String(data: data, encoding: .utf8)!) - } -} - -extension Metadata { - init(files: [String], verbose: Bool = false) throws { - precondition(files.count > 0, "At least one input file required") - let progressReporter = ProgressReporter(description: "Parsing metadata", steps: files.count, verbose: verbose) - defer { progressReporter.terminate() } - var filesMetadata = try files.map(reporter: progressReporter) { try FileMetadata(file: $0) } - let title = filesMetadata.first!.album - let author = filesMetadata.first!.author - filesMetadata.stripCommonPrefix() - let chaptersMetadata = [ChapterMetadata](files: filesMetadata) - let totalDuration = chaptersMetadata.last!.start + filesMetadata.last!.duration - self.init(title: title, author: author, chapters: chaptersMetadata, totalDuration: totalDuration) - } -} - -fileprivate extension Array where Element == String { - func map(reporter: ProgressReporter, _ f: (Element) throws -> T ) rethrows -> [T] { - return try map { - reporter.nextStep(description: $0) - return try f($0) - } - } -} diff --git a/Sources/OpusabCore/Opusab.swift b/Sources/OpusabCore/Opusab.swift deleted file mode 100644 index 09e246f..0000000 --- a/Sources/OpusabCore/Opusab.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Files -import Foundation -import CLInterface -import Proc - -public final class Opusab: CLInterface { - public var description = "Create Opus audiobooks from a list of mp3 files" - public var optionsString = " files..." - - // MARK: input and output - - @PositionalArgument(name: "files", usage: "audio files") - var audioFiles: [String] - - @Argument("--output", "-o", usage: "name of the output file") - var outputPath: String - - // MARK: configuration - - @Argument("--cover", "-c", usage: "path to the cover file") - var coverPath: String? - - @Argument("--bitrate", "-b", usage: "bitrate in kbits (default 32)", default: 32) - var bitrate: Int - - @Argument("--verbose", "-v", usage: "verbose output", default: false) - var verbose: Bool - - @Argument("--dry-run", "-n", usage: "do not execute final command", default: false) - var dryRun: Bool - - // MARK: metadata - - @Argument("--print-metadata", "-p", usage: "print metadata to standard output", default: false) - var printMetadata: Bool - - @Argument("--metadata", "-m", usage: "json file, containing metadata (optional)") - var metadataPath: String? - - public init(arguments: [String]? = nil) throws { - try parseArguments(arguments) - try verifyFilesExist() - } - - public func run() throws { - let metadata = try loadMetadataFromDisk() ?? Metadata(files: audioFiles, verbose: verbose) - print(metadata.overview, to: &stderr) - if printMetadata || verbose { - metadata.printJSON() - } - - let cat = Proc("/bin/cat", audioFiles) - let ffmpeg = Proc.ffmpeg_mp3ToWav() - let opusenc = Proc.opusenc(metadata: metadata, output: outputPath, bitrate: bitrate, cover: coverPath) - let p = cat.pipe(to: ffmpeg).pipe(to: opusenc) - if dryRun || verbose { - print("final command: \(p)", to: &stderr) - } - - if !dryRun { - try p.run() - p.waitUntilExit() - } - } - - fileprivate func verifyFilesExist() throws { - try audioFiles.forEach { _ = try File(path: $0) } - try coverPath.map { _ = try File(path: $0) } - try metadataPath.map { _ = try File(path: $0) } - } - - private func loadMetadataFromDisk() throws -> Metadata? { - return try metadataPath.map { path in - let url = URL(fileURLWithPath: path) - let data = try Data(contentsOf: url) - let d = JSONDecoder() - return try d.decode(Metadata.self, from: data) - } - } -} diff --git a/Sources/OpusabCore/Proc/Proc+Ffmpeg.swift b/Sources/OpusabCore/Proc/Proc+Ffmpeg.swift deleted file mode 100644 index d591f18..0000000 --- a/Sources/OpusabCore/Proc/Proc+Ffmpeg.swift +++ /dev/null @@ -1,30 +0,0 @@ -import Foundation -import Proc - -extension Proc { - fileprivate static var commonArgs = [ "-hide_banner", "-nostdin", "-loglevel", "fatal", "-nostats" ] - - static func ffmpeg_mp3ToWav() -> Proc { - let args = commonArgs + [ "-f", "mp3", "-i", "pipe:0", "-f", "wav", "-" ] - return Proc(name: "ffmpeg", args) - } - - static func ffmpeg_accurateDuration(for file: String) -> Double { - let string = try! Proc(name: "bash", "-c", "ffmpeg -hide_banner -nostdin -nostats -i \"\(file)\" -f null /dev/null 2>&1") - .runForStdout() - .split(separator: " ") - .first(where: { $0.hasPrefix("time=")})! - .dropFirst(5) - return Double(ffmpegTime: String(string)) - } -} - -fileprivate extension Double { - init(ffmpegTime string: String) { - let t = string.split(separator: ":") - let h = Double(t[0])! - let m = Double(t[1])! - let s = Double(t[2])! - self = h * 3600 + m * 60 + s - } -} diff --git a/Sources/OpusabCore/Proc/Proc+Mediainfo.swift b/Sources/OpusabCore/Proc/Proc+Mediainfo.swift deleted file mode 100644 index bb95da0..0000000 --- a/Sources/OpusabCore/Proc/Proc+Mediainfo.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Files -import Foundation -import Proc - -struct MediainfoMetadata { - var trackName: String - var album: String - var performer: String -} - -extension Proc { - static func mediainfo(_ filename: String) throws -> MediainfoMetadata { - let data = try Proc(name: "mediainfo", "--Output=JSON", filename) - .runForStdout() - .data(using: .utf8)! - let metadata = try JSONDecoder().decode(Mediainfo.self, from: data) - - let trackName = metadata.media.track[0].Track ?? (try! File(path: filename).nameExcludingExtension) - let album = metadata.media.track[0].Album! - let author = metadata.media.track[0].Performer! - - return MediainfoMetadata(trackName: trackName, album: album, performer: author) - } -} - -fileprivate struct Mediainfo: Decodable { - var media: Media - - struct Media: Decodable { - var track: [Track] - - struct Track: Decodable { - var Track: String? - var Album: String? - var Performer: String? - } - } -} diff --git a/Sources/OpusabCore/Proc/Proc+Opusenc.swift b/Sources/OpusabCore/Proc/Proc+Opusenc.swift deleted file mode 100644 index 68d3060..0000000 --- a/Sources/OpusabCore/Proc/Proc+Opusenc.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -import Proc - -extension Proc { - static func opusenc(metadata: Metadata, output: String, bitrate: Int, cover: String?) -> Proc { - let args = opusencArgs(metadata: metadata, output: output, bitrate: bitrate, cover: cover) - return Proc(name: "opusenc", args) - } -} - -fileprivate func opusencArgs(metadata: Metadata, output: String, bitrate: Int, cover: String?) -> [String] { - var args = [ - "--bitrate", "\(bitrate)", - "--downmix-mono", // TODO: Explore importance, should probably remove - "--title", metadata.title, - "--artist", metadata.author, - "--album", metadata.title, // TODO: See if used anywhere - ] - if let cover = cover { - args += [ "--picture", cover ] - } - for (idx, chapter) in metadata.chapters.enumerated() { - let time = String(time: chapter.start) - args += [ - "--comment", String(format: "CHAPTER%03d=\(time)", idx+1), - "--comment", String(format: "CHAPTER%03dNAME=\(chapter.name)", idx+1) - ] - } - args += ["-", output] - return args -} - -extension String { - init(time: Double) { - let sec = time.truncatingRemainder(dividingBy: 60.0) - let min = Int(time) / 60 % 60 - let h = Int(time) / 3_600 - self = .init(format: "%02d:%02d:%06.3lf", h, min, sec) - } -} diff --git a/Sources/OpusabTest/main.swift b/Sources/OpusabTest/main.swift deleted file mode 100644 index 55f5f6f..0000000 --- a/Sources/OpusabTest/main.swift +++ /dev/null @@ -1,15 +0,0 @@ -import os -import OpusabCore - -let short = ["-o", "test.opus", "-b", "24", - "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/01 Chapter 01 - Dudley Demented.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/02 Chapter 02 - A Peck of Owls.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/03 Chapter 03 - The Advance Guard.mp3"] -let all = [".build/debug/Opusab", "-o", "test.opus", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/01 Chapter 01 - Dudley Demented.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/02 Chapter 02 - A Peck of Owls.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/03 Chapter 03 - The Advance Guard.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/04 Chapter 04 - Number Twelve Grimmauld Place.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/05 Chapter 05 - The Order of the Phoenix.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/06 Chapter 06 - The Noble and Most Ancient House of Black.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/07 Chapter 07 - The Ministry of Magic.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/08 Chapter 08 - The Hearing.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/09 Chapter 09 - The Woes of Mrs. Weasley.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/10 Chapter 10 - Luna Lovegood.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/11 Chapter 11 - The Sorting Hat\'s New Song.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/12 Chapter 12 - Professor Umbridge.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/13 Chapter 13 - Detention With Dolores.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/14 Chapter 14 - Percy and Padfoot.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/15 Chapter 15 - The Hogwarts High Inquisitor.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/16 Chapter 16 -In The Hog\'s Head.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/17 Chapter 17 - Educational Decree Number Twenty-Four.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/18 Chapter 18 - Dumbledore\'s Army.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/19 Chapter 19 - The Lion and the Serpent.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/20 Chapter 20 - Hagrid\'s Tale.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/21 Chapter 21 - The Eye of the Snake.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/22 Chapter 22 - St. Mungo\'s Hospital for Magical Maladies and Injuries.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/23 Chapter 23 - Christmas on the Closed Ward.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/24 Chapter 24 - Occlumency.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/25 Chapter 25 - The Beetle at Bay.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/26 Chapter 26 - Seen and Unforeseen.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/27 Chapter 27 - The Centaur and the Sneak.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/28 Chapter 28 - Snape\'s Worst Memory.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/29 Chapter 29 - Career Advice.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/30 Chapter 30 - Grawp.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/31 Chapter 31 - O.W.L.S.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/32 Chapter 32 - Out of the Fire.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/33 Chapter 33 - Fight and Flight.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/34 Chapter 34 - The Department of Mysteries.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/35 Chapter 35 - Beyond the Veil.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/36 Chapter 36 - The Only One He Ever Feared.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/37 Chapter 37 - The Lost Prophecy.mp3", "/Volumes/Green/Audiobooks/Harry Potter (Stephen Fry)/5/38 Chapter 38 - The Second War Begins.mp3"] -let args = Array(CommandLine.arguments.dropFirst()) - -do { - let tool = try Opusab(arguments: short + ["-n"]) - try tool.run() -} catch { - print("error: \(error)") - exit(1) -} diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..745b38f --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,159 @@ +# Design and CLI contract + +## Boundaries + +`model` describes a book, ordered sources, millisecond chapters, and editable tags. +`probe` discovers files and reads stream/container metadata. `app` builds a plan and +executes it. `cli` and `tui` share those operations. `backend` supervises child +processes; `ogg` owns the file-format code for fast metadata edits. + +Rust provides the distributable command, strict argument handling, structured +errors, a native TUI, and predictable file I/O. Encoding remains in the existing +fre:ac/BoCA implementation. No codec or parallel packet stitching was implemented +here. A macOS runtime bundle is deliberately preferred to embedding all of fre:ac +as a library or promising a fully static cross-platform binary. + +## Conversion + +1. Canonicalize inputs. A directory is recursively scanned, naturally sorted, then + ordered by disc/track if every file has track metadata. Explicit file arguments + keep their order. Duplicates fail. +2. Probe metadata, channels, bitrate, duration, chapters, and artwork. Existing + Ogg Opus files use the native header/tail reader; other formats use ffprobe. +3. Merge audiobook metadata and build file chapters. Apply the caller's patch, + validate the result, and select a suggested bitrate. `plan` exposes this state. +4. A single existing Opus file uses metadata-only editing by default. An alternate + output copies its compressed audio. Explicit bitrate/channel/preset changes or + `--reencode` request an encode, always to a different path from the input. +5. Run freaccmd with `-e opus --superfast --threads=N`, VBR bitrate, complexity 10, + and 20 ms frames. For `N=1`, omit `--superfast`: this backend treats ≤1 in that + mode as automatic worker selection. The single M4B uses the same parallel driver + as multi-file input. We don't load a user's fre:ac settings profile. +6. An explicit channel change first decodes/resamples to lossless FLAC temporary + files. If needed, ffmpeg extracts artwork that would otherwise be lost in that + preparation. The default preserves channels and avoids this pass. +7. Preserve our source/manual chapter offsets. fre:ac 1.1.7's generated chapter + tags lose fractional seconds cumulatively; they are not a reliable replacement. + Container duration estimates can still include priming/padding. In the real MP3 + fixture the aggregate discrepancy was 78 ms over 8.5 hours. +8. Write final tags and reserved space into a temporary padded Opus file. Copy + compressed packets, adjust Ogg page sequence numbers and CRCs, and check every + copied page. Sync and atomically install the final file. Keep sources intact. + +Child stdout/stderr are drained concurrently. `--json` owns stdout exclusively; +backend chatter never leaks into the protocol. Ctrl+C terminates the whole child +process group on Unix, waits briefly, and escalates if needed. RAII removes +unfinished outputs. Source/output alias checks and no-clobber persistence guard +against accidental replacement. + +## In-place OpusTags editing + +The output is one ordinary Ogg Opus logical stream, not a concatenation of separate +Opus streams. The identification header and comment header occupy their own pages. +The OpusTags packet has 256 KiB of trailing reserve by default, permitted by +[RFC 7845 §5.2](https://www.rfc-editor.org/rfc/rfc7845#section-5.2). + +An edit reads the header and at most 128 KiB at the tail for the duration. It +serializes the changed comments into exactly the old packet capacity, pads the +unused portion, reuses the original page boundaries, and recalculates CRCs. Only +pages with different bytes are written. Consequently the file length, identity, +page sequence numbers, audio offsets, and all audio bytes remain unchanged. The +cost depends on metadata size, not audiobook duration. Changing a text length can +shift embedded artwork within the header, so more than one page may change. + +Repeated and unknown tags survive. Known aliases are consolidated only when that +field is edited. Opaque non-padding suffix bytes required by RFC 7845 are retained. +Limits: 16 MiB comment packet, 8 MiB supplied cover, up to 1,000 chapter tags. +`CHAPTER000` through `CHAPTER999` use `HH:MM:SS.mmm`; names use the matching `NAME` +tag. Artwork uses the standard base64 FLAC `METADATA_BLOCK_PICTURE` structure. + +A capacity shortfall returns `metadata_capacity` before any write. `tags reserve` +is an explicit full file repack and can replace an existing file atomically; it +preserves the compressed packet payloads but may alter audio page headers/offsets. +It is never a hidden fallback for an ordinary metadata edit. + +## Interrupted writes + +In-place updates cannot give unaware readers an atomic view of all changed pages. +We use an exclusive advisory lock plus recovery: + +1. Verify the opened file still matches the path; reject a pending journal. +2. Build and validate every changed page in memory. +3. Write original/replacement pages, offsets, file identity/size, and OpusHead hash + to a temporary journal. Sync and atomically publish it beside the audiobook. +4. Write changed metadata pages, then sync the audiobook. +5. Remove the journal and sync the parent directory. + +If a journal remains, normal edits stop. `tags recover` locks and checks the file +identity, header hash, lengths, ranges, and original CRCs, then restores the old +pages even if the current header contains a torn write. An interruption after all +pages were written but before journal removal still rolls back on recovery. No +whole audiobook backup is required. Locks coordinate opusab processes; other +programs must also cooperate if they edit concurrently. + +## JSON interface, version 1 + +Pass `--json` globally or after any command. Stdout contains one JSON document on +success. Stderr contains newline-delimited progress events and, on failure, one +final error. `--quiet` removes progress events. All documents include +`schema_version: 1`. Agents should select fields by name and accept added fields. + +| Command | Result | +| --- | --- | +| `inspect SOURCES...` | Book: ordered `sources`, `metadata`, `duration_ms`, warnings | +| `plan SOURCES...` | Operation, output path, book, recommendation, target settings | +| `convert SOURCES...` | Operation, final path, duration, size, elapsed time, metadata I/O report | +| `tags show FILE` | Editable `metadata`, cover presence, capacity and available bytes | +| `tags set/apply/reserve/recover` | `result` containing metadata I/O report | +| `doctor` | `ready` and exact tool paths | + +Example progress event: + +```json +{"schema_version":1,"event":"progress","stage":"encoding","message":"Encoding audiobook","elapsed_seconds":3.0} +``` + +Example failure: + +```json +{"schema_version":1,"error":{"code":"metadata_capacity","message":"Metadata needs more header space…"}} +``` + +Specific error codes include `invalid_arguments`, `terminal_required`, +`backend_missing`, `backend_failed`, `probe_failed`, `file_busy`, +`metadata_capacity`, `invalid_chapters`, `invalid_opus`, `output_exists`, and +`cancelled`. Other validation or I/O failures use `operation_failed`; callers +must not assume every failure has a specialized code. Exit codes: 0 success, +1 operational failure, 2 argument syntax, 130 cancellation. + +A metadata patch is strict within `metadata`; unknown field names are errors. +Export envelopes may carry informational fields (capacity, cover presence, etc.). +Omitted/null fields preserve data. Empty strings remove text, empty chapter arrays +remove chapters, and `remove_cover` is explicit. `extra` patches named keys, rather +than replacing all unknown tags. Dedicated fields take care of managed aliases. +The CLI does not require an agent SDK, MCP server, or interactive confirmation. + +## Validation and distribution + +`cargo test --all-targets` exercises metadata invariants, Ogg lacing boundaries, +recovery after a torn write, lock conflicts, malformed inputs, JSON behavior, +chapter precision, runtime discovery via symlinks, Unicode edits, and terminal +sizes. `cargo test --test backend -- --ignored` additionally runs a two-worker +fre:ac conversion, including filenames with spaces/apostrophes/Unicode and two +subsecond tracks. `cargo clippy --all-targets -- -D warnings` is clean. + +The checked-in tone fixture is a generated 250 ms 440 Hz sine, encoded as Opus; it +contains no user audio. Real audiobook fixtures and generated books remain ignored +under `.local/`; they are never included in a source or binary package. + +`build-ffmpeg.sh` downloads a pinned SHA-256 verified source archive and builds a +small static-library FFmpeg/ffprobe pair. `package-macos.sh` preserves the official +fre:ac bundle layout and includes the Rust executable, runtime, installer, docs, +and FFmpeg source/license materials. Its dependency audit rejects references to +Homebrew or other developer-only library paths. Runtime lookup canonicalizes the +executable, including installations through a symlink. + +The macOS builds target macOS 11 but have only been exercised on the development +host. The local package is unsigned/unnotarized; public release preparation is +separate, including corresponding source for all bundled native components. +Physical phone players and Windows/Linux runtime packages remain unverified. diff --git a/docs/THIRD_PARTY.md b/docs/THIRD_PARTY.md new file mode 100644 index 0000000..bd04c2b --- /dev/null +++ b/docs/THIRD_PARTY.md @@ -0,0 +1,22 @@ +# Runtime components + +The Rust application is MIT licensed. Its audio tools run as separate processes +and retain their respective licenses. + +* **fre:ac 1.1.7**, BoCA, smooth, and the codecs in the official macOS bundle: + https://www.freac.org/ and https://github.com/enzo1982/freac/tree/v1.1.7. + fre:ac is GPL-2.0-or-later. The complete upstream application bundle is preserved, + including its manual, component information, and notices. Source and development + kit downloads: https://www.freac.org/downloads-mainmenu-33. +* **FFmpeg 9.0.2**, built with the checked-in `build-ffmpeg.sh`: LGPL-2.1-or-later. + This build enables no GPL or nonfree components. License texts, the complete + unmodified source archive, configure log, and build script accompany the local + package. https://ffmpeg.org/download.html and https://ffmpeg.org/legal.html. +* Rust dependency versions are pinned in `Cargo.lock`; their individual licenses + remain in their upstream crates. + +`scripts/package-macos.sh` creates a local development bundle. It does not publish, +sign with a Developer ID, or notarize a release. A public distributor must also +provide the corresponding source and notices for the exact fre:ac/BoCA/smooth/ +codec binaries being distributed, and the notices for Rust dependencies. Do not +assume that linking an upstream download page alone satisfies those obligations. diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md new file mode 100644 index 0000000..a0beff3 --- /dev/null +++ b/docs/VALIDATION.md @@ -0,0 +1,58 @@ +# Local validation — 2026-10-04 + +Tested on the development Mac (Apple Silicon), using fre:ac 1.1.7. These are local +results, not claims of cross-platform or phone-player certification. + +## Complete recordings + +| Input | Input audio | Output | Duration | Chapters | Conversion time | +| --- | ---: | ---: | --- | ---: | ---: | +| Neuromancer, one M4B | 256.0 MiB | 91.8 MiB | 9:23:44.718 | 24 | 77.3 s | +| A Farewell to Arms, seven MP3s | 469.9 MiB | 110.2 MiB | 8:33:15.282 | 7 | 47.8 s | + +The jobs overlapped and each requested six workers; these timings are not isolated +benchmarks. Defaults selected 24 kbit/s for the 22.05 kHz M4B and 32 kbit/s for the +128 kbit/s MP3s. Opus VBR output size differs from a constant-bitrate estimate. + +An independent ffprobe recognized all 24/7 chapters and embedded covers. +Both complete outputs were decoded through the bundled FFmpeg with exit 0 and no +diagnostics. Covers match their source image bytes exactly. M4B chapter starts +match the original 24 entries. MP3 chapters follow the cumulative source durations +at millisecond resolution. The generated recordings are 69 ms and 78 ms shorter +than the respective source container duration estimates; codec priming/padding and +decoder duration accounting can differ. No audible boundary/quality assessment or +physical phone playback was performed. + +SHA-256 verification confirms the original M4B, seven MP3s, and JPEG were unchanged. +The source audio is not checked into the repository or included in packages. + +## Metadata and interface checks + +A title edit on each full output took about 20–24 ms including process startup. +Each edited two header pages (130,614 bytes) and zero audio bytes. File size, +inode, audio offset, and the SHA-256 of all bytes after the metadata header remained +unchanged. Original titles were restored afterward. Actual changed-page count +varies with the edit and artwork position. + +Automated tests cover insufficient capacity without any writes, padding/page +boundary cases, byte-identical audio preservation, unknown and repeated tags, +interrupted-write recovery, concurrent edit rejection, and truncated/corrupt data. +CLI tests cover strict JSON, noninteractive errors, avoiding accidental re-encode, +source overwrite rejection, chapter precision, and installed runtime discovery. +A real fre:ac test merges two 250 ms tracks with two workers and retains the 250 ms +chapter boundary and a custom name. + +The TUI was exercised through an actual pseudoterminal: open, edit a Unicode title, +save in place, and exit. Render tests cover three terminal sizes. A full-book encode +was interrupted with SIGINT: exit 130, structured cancellation error, no completed +output, and no residual temporary conversion directory. + +## Runtime package + +The portable archive includes the application, intact fre:ac app runtime, minimal +FFmpeg/ffprobe, installation script, and documentation. Native dependency checks +reject Homebrew paths. Bundled tools and installation through a symlink are checked +with a restricted PATH. A conversion through the installed command also exercised +mono channel preparation and embedded-cover extraction. The macOS deployment target is 11.0; execution on older +macOS versions has not been tested. Signing, notarization, and full public-release +source/license assembly remain release work. diff --git a/scripts/build-ffmpeg.sh b/scripts/build-ffmpeg.sh new file mode 100755 index 0000000..a75f8f2 --- /dev/null +++ b/scripts/build-ffmpeg.sh @@ -0,0 +1,44 @@ +#!/bin/sh +# Build a small FFmpeg/ffprobe pair with no third-party dynamic dependencies. +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +version=9.0.2 +sha256=8c3850283eb25fa026482078a04051e0be17347b09ef81a0849bec15a96e002e +cache="$repo/.local/downloads" +source_dir="$repo/.local/ffmpeg-$version" +prefix="$repo/.local/ffmpeg-runtime" +archive="$cache/ffmpeg-$version.tar.xz" +mkdir -p "$cache" "$prefix" +if [ ! -f "$archive" ]; then + curl --fail --location --retry 3 "https://ffmpeg.org/releases/ffmpeg-$version.tar.xz" -o "$archive.part" + mv "$archive.part" "$archive" +fi +actual=$(shasum -a 256 "$archive" | cut -d ' ' -f 1) +[ "$actual" = "$sha256" ] || { echo 'FFmpeg source checksum mismatch' >&2; exit 1; } +if [ ! -d "$source_dir" ]; then tar -xf "$archive" -C "$repo/.local"; fi +cd "$source_dir" +if [ "$(uname -s)" = Darwin ]; then + export MACOSX_DEPLOYMENT_TARGET=${MACOSX_DEPLOYMENT_TARGET:-11.0} +fi +./configure --prefix="$prefix" \ + --disable-autodetect --disable-everything --disable-network \ + --disable-doc --disable-debug --disable-shared --enable-static \ + --disable-avdevice --disable-programs --enable-ffmpeg --enable-ffprobe \ + --enable-zlib --enable-protocol=file,pipe \ + --enable-demuxer=mov,mp3,ogg,flac,wav,aiff,aac,matroska \ + --enable-decoder=aac,aac_fixed,mp3,mp3float,flac,alac,opus,vorbis,pcm_s16le,pcm_s24le,pcm_s32le,pcm_f32le,pcm_f64le,pcm_s16be,pcm_s24be,pcm_s32be,mjpeg,png \ + --enable-parser=aac,mpegaudio,flac,opus,vorbis,mjpeg,png \ + --enable-muxer=flac,image2,null,wav \ + --enable-encoder=flac,mjpeg,png,pcm_s16le,wrapped_avframe \ + --enable-filter=aresample,aformat,anull,format,scale,null \ + "$@" +jobs=${OPUSAB_BUILD_JOBS:-8} +# A changed deployment target must also rebuild existing object files. +make clean +make -j "$jobs" +make install +mkdir -p "$prefix/licenses" "$prefix/source" +cp COPYING.LGPLv2.1 LICENSE.md "$prefix/licenses/" +cp ffbuild/config.log "$prefix/source/" +cp "$archive" "$prefix/source/" +echo "Built FFmpeg runtime at $prefix" diff --git a/scripts/check-bundle.py b/scripts/check-bundle.py new file mode 100755 index 0000000..1c99ee6 --- /dev/null +++ b/scripts/check-bundle.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Verify all native binaries depend only on macOS or their own bundle.""" +import json +import pathlib +import subprocess +import sys + +root = pathlib.Path(sys.argv[1]).resolve() +manifest = json.loads(subprocess.check_output([str(root / "opusab"), "doctor", "--json"])) +for tool in manifest["tools"]: + path = tool["path"] + assert path is not None and pathlib.Path(path).is_relative_to(root), f"Unbundled tool: {tool}" +count = 0 +for file in root.rglob("*"): + if not file.is_file() or file.is_symlink(): + continue + with file.open("rb") as f: + magic = f.read(4) + if magic not in (b"\xcf\xfa\xed\xfe", b"\xca\xfe\xba\xbe", b"\xce\xfa\xed\xfe"): + continue + result = subprocess.check_output(["otool", "-L", str(file)], text=True) + # otool -L includes a dylib's own install ID, which is not a dependency. + ids = subprocess.check_output(["otool", "-D", str(file)], text=True) + own_ids = {line.strip() for line in ids.splitlines() if not line.endswith(":")} + for line in result.splitlines(): + if not line.startswith("\t"): + continue + dependency = line.strip().split(" (", 1)[0] + if dependency in own_ids: + continue + assert dependency.startswith(("/usr/lib/", "/System/Library/", "@")), (file, dependency) + count += 1 +assert count > 3, "Incomplete bundle" +print(f"Verified {count} Mach-O files and all three bundled tools") diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..6648946 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Run from an extracted portable package. No sudo required. +set -eu +bundle=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +prefix=${1:-"$HOME/.local"} +version=$("$bundle/opusab" --version | cut -d ' ' -f 2) +destination="$prefix/lib/opusab-$version" +link="$prefix/bin/opusab" +[ -d "$bundle/runtime" ] || { echo 'Run install.sh from the portable package.' >&2; exit 1; } +[ ! -e "$destination" ] || { echo "Already exists: $destination" >&2; exit 1; } +if [ -e "$link" ] || [ -L "$link" ]; then + echo "Already exists: $link. Remove or relocate it before installing." >&2 + exit 1 +fi +mkdir -p "$destination" "$prefix/bin" +cp -R "$bundle/." "$destination/" +destination=$(CDPATH= cd -- "$destination" && pwd) +ln -s "$destination/opusab" "$link" +echo "Installed $link" +echo "Add $prefix/bin to PATH if it is not already there." diff --git a/scripts/package-macos.sh b/scripts/package-macos.sh new file mode 100755 index 0000000..d823cd4 --- /dev/null +++ b/scripts/package-macos.sh @@ -0,0 +1,34 @@ +#!/bin/sh +set -eu +repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$repo" +[ "$(uname -s)" = Darwin ] || { echo 'This packaging script targets macOS.' >&2; exit 1; } +cargo=${CARGO:-cargo} +freac=${FREAC_APP:-/Applications/freac.app} +ffmpeg=${FFMPEG_RUNTIME:-"$repo/.local/ffmpeg-runtime"} +[ -x "$freac/Contents/MacOS/freaccmd" ] || { echo 'Set FREAC_APP to an official freac.app bundle.' >&2; exit 1; } +[ -x "$ffmpeg/bin/ffprobe" ] || { echo 'Run scripts/build-ffmpeg.sh first.' >&2; exit 1; } +"$cargo" build --release --locked +version=$(target/release/opusab --version | cut -d ' ' -f 2) +name="opusab-$version-macos-$(uname -m)" +stage=$(mktemp -d "$repo/.local/package.XXXXXX") +trap 'rm -rf "$stage"' EXIT HUP INT TERM +bundle="$stage/$name" +mkdir -p "$bundle/runtime/bin" "$bundle/licenses/ffmpeg" "$bundle/source" +cp target/release/opusab README.md LICENSE "$bundle/" +cp docs/THIRD_PARTY.md "$bundle/" +cp -R docs "$bundle/docs" +cp scripts/install.sh "$bundle/" +ditto "$freac" "$bundle/runtime/freac.app" +cp "$ffmpeg/bin/ffmpeg" "$ffmpeg/bin/ffprobe" "$bundle/runtime/bin/" +cp "$ffmpeg/licenses/"* "$bundle/licenses/ffmpeg/" +cp "$ffmpeg/source/"* "$bundle/source/" +cp scripts/build-ffmpeg.sh "$bundle/source/" +# Never ship dependencies that accidentally refer to this developer's Homebrew tree. +python3 scripts/check-bundle.py "$bundle" +mkdir -p dist +[ ! -e "dist/$name" ] || { echo "dist/$name already exists; move it aside before rebuilding." >&2; exit 1; } +mv "$bundle" "dist/$name" +COPYFILE_DISABLE=1 tar -czf "dist/$name.tar.gz" -C dist "$name" +shasum -a 256 "dist/$name.tar.gz" > "dist/$name.tar.gz.sha256" +echo "Portable package: dist/$name.tar.gz" diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..3cfad55 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,377 @@ +use crate::{ + backend::{self, Progress}, + error::fail, + model::{self, Book, Channels, MetadataPatch, Preset, Recommendation}, + ogg, probe, +}; +use anyhow::{Context, Result, ensure}; +use serde::{Deserialize, Serialize}; +use std::{ + fs::{self, File}, + io::Read, + path::{Path, PathBuf}, + time::Instant, +}; + +#[derive(Clone, Debug, clap::Args)] +pub struct ConversionOptions { + /// Output .opus path. Existing source Opus files are edited in place unless re-encoding. + #[arg(short, long)] + pub output: Option, + /// JSON metadata patch, or - for stdin. Relative cover paths resolve beside this file. + #[arg(short, long)] + pub metadata: Option, + /// Target VBR bitrate in kbit/s (6..510). Omit to use the recommendation. + #[arg(short,long,value_parser=clap::value_parser!(u32).range(6..=510))] + pub bitrate: Option, + #[arg(long, value_enum, default_value = "speech")] + pub preset: Preset, + #[arg(long, value_enum, default_value = "keep")] + pub channels: Channels, + /// Encoder workers. 0 selects up to 8 available cores. + #[arg(short, long, default_value_t = 0)] + pub jobs: usize, + /// Extra space for future metadata edits, in KiB. + #[arg(long,default_value_t=256,value_parser=clap::value_parser!(u32).range(0..=8192))] + pub padding_kib: u32, + /// Allow replacing an existing output. Source audio is never overwritten by conversion. + #[arg(long)] + pub overwrite: bool, + /// Re-encode even if the input is already Opus. + #[arg(long)] + pub reencode: bool, +} +impl Default for ConversionOptions { + fn default() -> Self { + Self { + output: None, + metadata: None, + bitrate: None, + preset: Preset::Speech, + channels: Channels::Keep, + jobs: 0, + padding_kib: 256, + overwrite: false, + reencode: false, + } + } +} +#[derive(Clone, Debug, Serialize)] +pub struct Plan { + pub schema_version: u32, + pub operation: String, + pub output: PathBuf, + pub book: Book, + pub recommendation: Recommendation, + pub target_bitrate_kbps: u32, + pub channels: Channels, + pub jobs: usize, + pub padding_bytes: usize, + pub overwrite: bool, + pub metadata_patch: MetadataPatch, +} +pub fn read_patch(path: &Path) -> Result { + let mut bytes = Vec::new(); + if path == Path::new("-") { + std::io::stdin() + .take(8 * 1024 * 1024 + 1) + .read_to_end(&mut bytes)?; + } else { + File::open(path)? + .take(8 * 1024 * 1024 + 1) + .read_to_end(&mut bytes)?; + } + ensure!( + bytes.len() <= 8 * 1024 * 1024, + "Metadata JSON exceeds 8 MiB" + ); + let mut v: serde_json::Value = + serde_json::from_slice(&bytes).context("Invalid metadata JSON")?; + if let Some(version) = v.get("schema_version") { + ensure!(version == 1, "Unsupported metadata schema version"); + } + if v.get("metadata").is_some() { + v = v["metadata"].take(); + } + let mut patch: MetadataPatch = serde_json::from_value(v).context("Invalid metadata fields")?; + if let Some(cover) = &mut patch.cover + && cover.is_relative() + && path != Path::new("-") + { + *cover = path.parent().unwrap_or(Path::new(".")).join(&cover); + } + Ok(patch) +} +pub fn make_plan(paths: &[PathBuf], options: &ConversionOptions) -> Result { + let book = probe::book(paths)?; + let patch = options + .metadata + .as_ref() + .map(|p| read_patch(p)) + .transpose()? + .unwrap_or_default(); + let plan = plan_book(book, options, patch)?; + validate_cover(&plan)?; + Ok(plan) +} +pub fn plan_book( + mut book: Book, + options: &ConversionOptions, + patch: MetadataPatch, +) -> Result { + patch.validate()?; + ensure!( + !book.sources.is_empty(), + "At least one audio source is required" + ); + patch.apply(&mut book.metadata); + model::validate_metadata(&book.metadata, Some(book.duration_ms))?; + let recommendation = model::recommend(&book, options.preset); + let one_opus = book.sources.len() == 1 && book.sources[0].codec == "opus"; + let encode = !one_opus + || options.reencode + || options.bitrate.is_some() + || options.channels != Channels::Keep + || options.preset != Preset::Speech; + let output = options.output.clone().unwrap_or_else(|| { + if one_opus && !encode { + book.sources[0].path.clone() + } else if one_opus { + book.sources[0].path.with_file_name(format!( + "{}-converted.opus", + book.sources[0] + .path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + )) + } else if let Some(dir) = &book.input_directory { + dir.parent() + .unwrap_or(Path::new(".")) + .join(format!("{}.opus", safe_filename(&book.metadata.title))) + } else if book.sources.len() == 1 { + book.sources[0].path.with_extension("opus") + } else { + book.sources[0] + .path + .parent() + .unwrap_or(Path::new(".")) + .join(format!("{}.opus", safe_filename(&book.metadata.title))) + } + }); + ensure!( + output + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("opus")), + "Output filename must end with .opus" + ); + let output = absolute_output(&output)?; + let same_source = book.sources.iter().any(|s| s.path == output); + ensure!( + !(encode && same_source), + "Conversion output must differ from every source file" + ); + let operation = if encode { + "encode" + } else if same_source { + "metadata" + } else { + "copy" + }; + let jobs = if options.jobs == 0 { + std::thread::available_parallelism() + .map(|v| v.get()) + .unwrap_or(2) + .min(8) + } else { + options.jobs + }; + ensure!((1..=256).contains(&jobs), "Worker count must be 1..256"); + let target_bitrate = if encode { + options.bitrate.unwrap_or(recommendation.bitrate_kbps) + } else { + book.sources[0].bitrate_kbps + }; + Ok(Plan { + schema_version: 1, + operation: operation.into(), + output, + book, + recommendation, + target_bitrate_kbps: target_bitrate, + channels: options.channels, + jobs, + padding_bytes: options.padding_kib as usize * 1024, + overwrite: options.overwrite, + metadata_patch: patch, + }) +} +pub fn absolute_output(path: &Path) -> Result { + if path.exists() { + return Ok(path.canonicalize()?); + } + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + Ok(parent + .canonicalize() + .with_context(|| format!("Output directory does not exist: {}", parent.display()))? + .join(path.file_name().context("Invalid output name")?)) +} +fn safe_filename(s: &str) -> String { + let s: String = s + .chars() + .map(|c| { + if c.is_control() || "/\\:*?\"<>|".contains(c) { + '_' + } else { + c + } + }) + .collect(); + if s.trim().is_empty() { + "audiobook".into() + } else { + s.trim().into() + } +} + +#[derive(Serialize, Deserialize)] +pub struct ConversionReport { + pub schema_version: u32, + pub operation: String, + pub output: PathBuf, + pub duration_ms: u64, + pub size_bytes: u64, + pub elapsed_seconds: f64, + pub metadata: ogg::EditReport, +} +pub fn execute(plan: &Plan, progress: &mut dyn FnMut(Progress)) -> Result { + let start = Instant::now(); + validate_cover(plan)?; + if plan.operation == "metadata" { + let edit = ogg::edit(&plan.output, &plan.metadata_patch)?; + return Ok(ConversionReport { + schema_version: 1, + operation: "metadata".into(), + output: plan.output.clone(), + duration_ms: plan.book.duration_ms, + size_bytes: fs::metadata(&plan.output)?.len(), + elapsed_seconds: start.elapsed().as_secs_f64(), + metadata: edit, + }); + } + if plan.output.exists() && !plan.overwrite { + return Err(fail( + "output_exists", + format!( + "Output exists: {}. Choose another path or use --overwrite.", + plan.output.display() + ), + )); + } + let parent = plan.output.parent().context("Missing output directory")?; + let temp = tempfile::Builder::new() + .prefix(".opusab-") + .tempdir_in(parent)?; + let raw = temp.path().join("encoded.opus"); + let mut final_metadata = plan.book.metadata.clone(); + let encoded = if plan.operation == "copy" { + plan.book.sources[0].path.clone() + } else { + // Resolve dependencies before doing work. + backend::find("freaccmd")?; + let channels = match plan.channels { + Channels::Keep => None, + Channels::Mono => Some(1), + Channels::Stereo => Some(2), + }; + let mut inputs = Vec::new(); + for (i, source) in plan.book.sources.iter().enumerate() { + if let Some(n) = channels.filter(|n| *n != source.channels) { + let flac = temp.path().join(format!("{i:05}.flac")); + progress(backend::event( + "preparing_channels", + format!("Preparing {}", source.path.display()), + start.elapsed().as_secs_f64(), + )); + backend::prepare_channels(&source.path, &flac, n, progress)?; + inputs.push(flac); + } else { + inputs.push(source.path.clone()); + } + } + progress(backend::event( + "encoding", + format!( + "Encoding with {} workers at {} kbit/s VBR", + plan.jobs, plan.target_bitrate_kbps + ), + start.elapsed().as_secs_f64(), + )); + backend::encode(&inputs, &raw, plan.target_bitrate_kbps, plan.jobs, progress)?; + // Keep our millisecond chapter timings: fre:ac 1.1.7 rounds each track + // duration to whole seconds when constructing its chapter tags. + let mut f = File::open(&raw)?; + let h = ogg::read_header(&mut f)?; + if channels.is_some() + && final_metadata.cover.is_none() + && !h.has_cover() + && plan.book.sources[0].has_embedded_cover + && !plan.metadata_patch.remove_cover + { + let cover = temp.path().join("cover.jpg"); + let mut command = std::process::Command::new(backend::find("ffmpeg")?); + command + .args(["-v", "error", "-nostdin", "-i"]) + .arg(&plan.book.sources[0].path) + .args(["-map", "0:v:0", "-frames:v", "1"]) + .arg(&cover); + backend::run(&mut command, "cover", progress)?; + final_metadata.cover = Some(cover); + } + raw + }; + let mut patch = if plan.operation == "copy" { + plan.metadata_patch.clone() + } else { + MetadataPatch::from(&final_metadata) + }; + patch.remove_cover = plan.metadata_patch.remove_cover; + patch.remove_tags = plan.metadata_patch.remove_tags.clone(); + if patch.remove_cover { + patch.cover = None; + } + progress(backend::event( + "finalizing", + "Writing metadata and reserving space for future edits", + start.elapsed().as_secs_f64(), + )); + let report = ogg::rewrite( + &encoded, + &plan.output, + &patch, + plan.padding_bytes, + plan.overwrite, + )?; + let mut output = File::open(&plan.output)?; + let h = ogg::read_header(&mut output)?; + let duration = ogg::duration_ms(&mut output, &h)?; + Ok(ConversionReport { + schema_version: 1, + operation: plan.operation.clone(), + output: plan.output.clone(), + duration_ms: duration, + size_bytes: output.metadata()?.len(), + elapsed_seconds: start.elapsed().as_secs_f64(), + metadata: report, + }) +} +fn validate_cover(plan: &Plan) -> Result<()> { + if let Some(cover) = &plan.book.metadata.cover { + // Catch missing, oversized, or invalid artwork before starting a long encode. + ogg::encode_picture(cover)?; + } + Ok(()) +} diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..b8bfc88 --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,291 @@ +use crate::error::{cancelled, fail}; +use anyhow::{Context, Result, ensure}; +use serde::Serialize; +use std::{ + env, fs, + io::Read, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, + }, + thread, + time::{Duration, Instant}, +}; + +static CANCEL: AtomicBool = AtomicBool::new(false); +pub fn cancel() { + CANCEL.store(true, Ordering::SeqCst); +} +pub fn reset_cancel() { + CANCEL.store(false, Ordering::SeqCst); +} +pub fn is_cancelled() -> bool { + CANCEL.load(Ordering::SeqCst) +} +pub fn install_signals() -> Result<()> { + ctrlc::set_handler(cancel)?; + Ok(()) +} + +pub fn find(name: &str) -> Result { + let env_key = format!("OPUSAB_{}", name.to_ascii_uppercase()); + if let Some(p) = env::var_os(&env_key) { + let p = PathBuf::from(p); + ensure!(p.is_file(), "{env_key} does not point to a file"); + return Ok(p); + } + let mut roots = Vec::new(); + if let Some(p) = env::var_os("OPUSAB_RUNTIME") { + roots.push(PathBuf::from(p)); + } + if let Ok(p) = env::current_exe().and_then(|p| p.canonicalize()) + && let Some(dir) = p.parent() + { + roots.push(dir.join("runtime")); + roots.push(dir.join("../lib/opusab/runtime")); + } + let suffix = if cfg!(windows) { ".exe" } else { "" }; + for root in &roots { + for p in [ + root.join(format!("bin/{name}{suffix}")), + root.join(format!("{name}{suffix}")), + root.join(format!("freac.app/Contents/MacOS/{name}")), + root.join(format!("freac/{name}{suffix}")), + ] { + if p.is_file() { + return Ok(p); + } + } + } + for dir in env::split_paths(&env::var_os("PATH").unwrap_or_default()) { + let p = dir.join(format!("{name}{suffix}")); + if p.is_file() { + return Ok(p); + } + } + if cfg!(target_os = "macos") { + for p in [ + PathBuf::from(format!("/Applications/freac.app/Contents/MacOS/{name}")), + PathBuf::from(format!("/opt/homebrew/bin/{name}")), + PathBuf::from(format!("/usr/local/bin/{name}")), + ] { + if p.is_file() { + return Ok(p); + } + } + } + Err(fail( + "backend_missing", + format!( + "Cannot find {name}. Use the bundled distribution, set {env_key}, or add it to PATH." + ), + )) +} +#[derive(Clone, Debug, Serialize)] +pub struct Progress { + pub schema_version: u32, + pub event: String, + pub stage: String, + pub message: String, + pub elapsed_seconds: f64, +} +pub fn event(stage: &str, message: impl Into, elapsed: f64) -> Progress { + Progress { + schema_version: 1, + event: "progress".into(), + stage: stage.into(), + message: message.into(), + elapsed_seconds: elapsed, + } +} + +/// Drain both pipes concurrently, bound retained diagnostics, and kill the whole child group on cancellation. +pub fn run(command: &mut Command, stage: &str, progress: &mut dyn FnMut(Progress)) -> Result<()> { + if is_cancelled() { + return Err(cancelled()); + } + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut child = command.spawn().context("Could not start audio backend")?; + let (tx, rx) = mpsc::channel(); + let readers: Vec> = vec![ + Box::new(child.stdout.take().unwrap()), + Box::new(child.stderr.take().unwrap()), + ]; + let threads: Vec<_> = readers + .into_iter() + .map(|mut stream| { + let tx = tx.clone(); + thread::spawn(move || { + let mut b = [0; 2048]; + loop { + match stream.read(&mut b) { + Ok(0) | Err(_) => break, + Ok(n) => { + if tx + .send(String::from_utf8_lossy(&b[..n]).to_string()) + .is_err() + { + break; + } + } + } + } + }) + }) + .collect(); + drop(tx); + let start = Instant::now(); + let mut last = Instant::now(); + let mut log = String::new(); + let status = loop { + for data in rx.try_iter() { + log.push_str(&data); + if log.len() > 32768 { + let mut n = log.len() - 16384; + while !log.is_char_boundary(n) { + n += 1; + } + log.drain(..n); + } + } + if is_cancelled() { + #[cfg(unix)] + unsafe { + libc::kill(-(child.id() as i32), libc::SIGTERM); + } + #[cfg(not(unix))] + { + let _ = child.kill(); + } + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline { + if child.try_wait()?.is_some() { + break; + } + thread::sleep(Duration::from_millis(50)); + } + #[cfg(unix)] + unsafe { + libc::kill(-(child.id() as i32), libc::SIGKILL); + } + let _ = child.kill(); + let _ = child.wait(); + for t in threads { + let _ = t.join(); + } + return Err(cancelled()); + } + if let Some(status) = child.try_wait()? { + break status; + } + if last.elapsed() >= Duration::from_secs(1) { + progress(event( + stage, + "Encoding audiobook", + start.elapsed().as_secs_f64(), + )); + last = Instant::now(); + } + thread::sleep(Duration::from_millis(100)); + }; + for t in threads { + let _ = t.join(); + } + for data in rx.try_iter() { + log.push_str(&data); + } + if !status.success() { + return Err(fail( + "backend_failed", + format!("Audio backend exited with {status}: {}", log.trim()), + )); + } + Ok(()) +} +pub fn encode( + sources: &[PathBuf], + output: &Path, + bitrate: u32, + jobs: usize, + progress: &mut dyn FnMut(Progress), +) -> Result<()> { + let mut cmd = Command::new(find("freaccmd")?); + cmd.args(["-e", "opus"]); + if jobs > 1 { + cmd.arg("--superfast"); + } + cmd.arg(format!("--threads={jobs}")) + .arg("-o") + .arg(output) + .args([ + "--bitrate", + &bitrate.to_string(), + "--comp", + "10", + "--framesize", + "20", + ]); + cmd.args(sources); + run(&mut cmd, "encoding", progress)?; + ensure!( + fs::metadata(output).is_ok_and(|m| m.len() > 0), + "Encoder did not produce an output file" + ); + Ok(()) +} +pub fn prepare_channels( + source: &Path, + output: &Path, + channels: u32, + progress: &mut dyn FnMut(Progress), +) -> Result<()> { + let mut cmd = Command::new(find("ffmpeg")?); + cmd.args(["-v", "error", "-nostdin", "-i"]) + .arg(source) + .args([ + "-map", + "0:a:0", + "-map_metadata", + "-1", + "-map_chapters", + "-1", + "-ac", + &channels.to_string(), + "-c:a", + "flac", + "-compression_level", + "0", + ]) + .arg(output); + run(&mut cmd, "preparing_channels", progress) +} + +#[derive(Serialize)] +pub struct ToolStatus { + pub name: String, + pub path: Option, + pub available: bool, +} +pub fn doctor() -> Vec { + ["freaccmd", "ffprobe", "ffmpeg"] + .iter() + .map(|name| { + let path = find(name).ok(); + ToolStatus { + name: (*name).into(), + available: path.is_some(), + path, + } + }) + .collect() +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..d8808f3 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,358 @@ +use crate::{ + app::{self, ConversionOptions}, + backend, + error::fail, + model::{self, MetadataPatch}, + ogg, probe, +}; +use anyhow::{Result, ensure}; +use clap::{Args, CommandFactory, Parser, Subcommand}; +use std::{ + collections::BTreeMap, + fs::File, + io::{self, IsTerminal}, + path::PathBuf, +}; + +#[derive(Parser, Debug)] +#[command( + version, + about = "Prepare, convert, and edit audiobooks", + long_about = "An audiobook CLI and terminal editor. Pass a file or folder to open the TUI; use explicit commands for scripts and agents." +)] +pub struct Cli { + /// Emit versioned JSON results and structured errors. + #[arg(long, global = true)] + pub json: bool, + /// Suppress progress (results and errors are still printed). + #[arg(short, long, global = true)] + pub quiet: bool, + #[command(subcommand)] + pub command: Option, + /// File or folder to open in the interactive editor. + pub source: Option, +} +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Inspect sources, tags, chapters, and inferred file order. + Inspect { + #[arg(required = true)] + sources: Vec, + }, + /// Preview the operation, settings, metadata, and output without writing. + Plan { + #[arg(required = true)] + sources: Vec, + #[command(flatten)] + options: ConversionOptions, + }, + /// Convert or update a book without prompts. Existing Opus audio is kept by default. + Convert { + #[arg(required = true)] + sources: Vec, + #[command(flatten)] + options: ConversionOptions, + }, + /// Open the interactive editor. + Edit { source: PathBuf }, + /// Read or edit Opus tags without re-encoding audio. + Tags { + #[command(subcommand)] + command: TagCommands, + }, + /// Locate the bundled or installed audio tools. + Doctor, + /// Generate shell completions. + Completions { + #[arg(value_enum)] + shell: clap_complete::Shell, + }, +} +#[derive(Subcommand, Debug)] +pub enum TagCommands { + /// Show editable metadata and available header space. + Show { file: PathBuf }, + /// Update selected fields. All edits are in place; insufficient capacity is an error. + Set { + file: PathBuf, + #[command(flatten)] + fields: Box, + }, + /// Apply a JSON patch. Missing fields are preserved; empty strings clear fields. + Apply { + file: PathBuf, + #[arg(long)] + from: PathBuf, + #[arg(long)] + in_place: bool, + }, + /// Repack once to reserve extra metadata space. Audio packets are preserved. + Reserve { + file: PathBuf, + #[arg(long,default_value_t=256,value_parser=clap::value_parser!(u32).range(0..=8192))] + padding_kib: u32, + }, + /// Roll back an interrupted metadata update using its recovery journal. + Recover { file: PathBuf }, + /// Open the metadata TUI for an Opus file. + Edit { file: PathBuf }, +} +#[derive(Args, Debug, Default)] +pub struct TagFields { + #[arg(long)] + title: Option, + #[arg(long)] + author: Option, + #[arg(long)] + narrator: Option, + #[arg(long)] + series: Option, + #[arg(long)] + series_part: Option, + #[arg(long)] + date: Option, + #[arg(long)] + genre: Option, + #[arg(long)] + description: Option, + #[arg(long, conflicts_with = "remove_cover")] + cover: Option, + #[arg(long)] + remove_cover: bool, + /// Set an additional tag (repeat for multiple values of the same key). + #[arg(long = "tag", value_name = "KEY=VALUE")] + tags: Vec, + #[arg(long = "remove-tag", value_name = "KEY")] + remove_tags: Vec, +} +impl TagFields { + fn patch(self) -> Result { + let mut extra: BTreeMap> = BTreeMap::new(); + for tag in self.tags { + let (k, v) = tag + .split_once('=') + .ok_or_else(|| fail("invalid_tag", "Use --tag KEY=VALUE"))?; + extra.entry(k.to_uppercase()).or_default().push(v.into()); + } + Ok(MetadataPatch { + title: self.title, + author: self.author, + narrator: self.narrator, + series: self.series, + series_part: self.series_part, + date: self.date, + genre: self.genre, + description: self.description, + cover: self.cover, + remove_cover: self.remove_cover, + chapters: None, + extra: Some(extra), + remove_tags: self.remove_tags, + }) + } +} +pub fn run(cli: Cli) -> Result<()> { + if let Some(source) = cli.source { + ensure!( + cli.command.is_none(), + "Choose a source or an explicit command" + ); + return open_tui(source); + } + let Some(command) = cli.command else { + Cli::command().print_help()?; + println!(); + return Ok(()); + }; + match command { + Commands::Inspect { sources } => { + let book = probe::book(&sources)?; + if cli.json { + print_json(&book)?; + } else { + print_book(&book); + } + } + Commands::Plan { sources, options } => { + let plan = app::make_plan(&sources, &options)?; + if cli.json { + print_json(&plan)?; + } else { + print_plan(&plan); + } + } + Commands::Convert { sources, options } => { + let plan = app::make_plan(&sources, &options)?; + let mut progress = |p: backend::Progress| { + if !cli.quiet { + if cli.json { + if let Ok(s) = serde_json::to_string(&p) { + eprintln!("{s}"); + } + } else { + eprintln!("{} · {} ({:.0}s)", p.stage, p.message, p.elapsed_seconds); + } + } + }; + let report = app::execute(&plan, &mut progress)?; + if cli.json { + print_json(&report)?; + } else { + println!( + "{}\n{} · {:.1} MiB · {:.1}s", + report.output.display(), + model::time_string(report.duration_ms), + report.size_bytes as f64 / 1048576.0, + report.elapsed_seconds + ); + } + } + Commands::Edit { source } => open_tui(source)?, + Commands::Tags { command } => match command { + TagCommands::Show { file } => { + let file = file.canonicalize()?; + ogg::check_journal(&file)?; + let mut f = File::open(&file)?; + let h = ogg::read_header(&mut f)?; + let value = serde_json::json!({"schema_version":1,"metadata":h.metadata(),"has_embedded_cover":h.has_cover(),"capacity_bytes":h.capacity,"available_bytes":h.capacity-h.used}); + if cli.json { + print_json(&value)?; + } else { + let m = h.metadata(); + println!( + "{}\nAuthor: {}\nNarrator: {}\nChapters: {}\nCover: {}\nMetadata space: {} / {} bytes free", + m.title, + m.author, + m.narrator, + m.chapters.len(), + h.has_cover(), + h.capacity - h.used, + h.capacity + ); + for c in m.chapters { + println!(" {} {}", model::time_string(c.start_ms), c.title); + } + } + } + TagCommands::Set { file, fields } => { + edit_result(ogg::edit(&file, &fields.patch()?)?, cli.json)? + } + TagCommands::Apply { file, from, .. } => { + edit_result(ogg::edit(&file, &app::read_patch(&from)?)?, cli.json)? + } + TagCommands::Reserve { file, padding_kib } => { + let file = file.canonicalize()?; + edit_result( + ogg::rewrite( + &file, + &file, + &MetadataPatch::default(), + padding_kib as usize * 1024, + true, + )?, + cli.json, + )?; + } + TagCommands::Recover { file } => edit_result(ogg::recover(&file)?, cli.json)?, + TagCommands::Edit { file } => open_tui(file)?, + }, + Commands::Doctor => { + let tools = backend::doctor(); + let ready = tools.iter().take(2).all(|t| t.available); + if cli.json { + print_json(&serde_json::json!({"schema_version":1,"ready":ready,"tools":tools}))?; + } else { + for t in tools { + println!( + "{}: {}", + t.name, + t.path + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "missing".into()) + ); + } + println!("Metadata editing of Opus files needs no external tools."); + } + if !ready { + return Err(fail( + "backend_missing", + "Conversion requires freaccmd and ffprobe", + )); + } + } + Commands::Completions { shell } => { + clap_complete::generate(shell, &mut Cli::command(), "opusab", &mut io::stdout()) + } + } + Ok(()) +} +fn open_tui(source: PathBuf) -> Result<()> { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err(fail( + "terminal_required", + "The editor requires a terminal. Use inspect, plan, convert, or tags commands for automation.", + )); + } + crate::tui::run(source) +} +pub fn print_json(value: &impl serde::Serialize) -> Result<()> { + serde_json::to_writer_pretty(io::stdout().lock(), value)?; + println!(); + Ok(()) +} +fn edit_result(report: ogg::EditReport, json: bool) -> Result<()> { + if json { + print_json(&serde_json::json!({"schema_version":1,"result":report}))?; + } else { + println!( + "{}: wrote {} metadata bytes across {} pages; {} audio bytes written. {} bytes available.", + report.mode, + report.bytes_written, + report.pages_written, + report.audio_bytes_written, + report.available_bytes + ); + } + Ok(()) +} +pub fn print_book(book: &model::Book) { + println!( + "{}\n{}{}\n{} · {} source file(s) · {} chapters", + book.metadata.title, + book.metadata.author, + if book.metadata.narrator.is_empty() { + String::new() + } else { + format!(" · read by {}", book.metadata.narrator) + }, + model::time_string(book.duration_ms), + book.sources.len(), + book.metadata.chapters.len() + ); + for (i, s) in book.sources.iter().enumerate() { + println!( + " {:>2}. {} · {} kbit/s · {} ch · {}\n {}", + i + 1, + s.codec, + s.bitrate_kbps, + s.channels, + model::time_string(s.duration_ms), + s.path.display() + ); + } + for warning in &book.warnings { + println!("Note: {warning}"); + } +} +pub fn print_plan(plan: &app::Plan) { + print_book(&plan.book); + println!( + "\nOperation: {}\nOutput: {}\nTarget: {} kbit/s VBR · {} workers\nSuggestion: {}\nEstimated audio size: {:.1} MiB", + plan.operation, + plan.output.display(), + plan.target_bitrate_kbps, + plan.jobs, + plan.recommendation.reason, + plan.recommendation.estimated_bytes as f64 / 1048576.0 + ); +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..ca3c62e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,30 @@ +use std::fmt; + +#[derive(Debug)] +pub struct Failure { + pub code: &'static str, + pub message: String, + pub exit: i32, +} +impl fmt::Display for Failure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} +impl std::error::Error for Failure {} +pub fn fail(code: &'static str, message: impl Into) -> anyhow::Error { + Failure { + code, + message: message.into(), + exit: 1, + } + .into() +} +pub fn cancelled() -> anyhow::Error { + Failure { + code: "cancelled", + message: "Operation cancelled".into(), + exit: 130, + } + .into() +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9ea8cbc --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,10 @@ +pub mod app; +pub mod backend; +pub mod cli; +pub mod error; +pub mod model; +pub mod ogg; +pub mod probe; +pub mod tui; + +pub const SCHEMA_VERSION: u32 = 1; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b621c3c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,40 @@ +use clap::Parser; +use opusab::{backend, cli::Cli, error::Failure}; +fn main() { + let json = std::env::args_os().any(|s| s == "--json"); + let cli = match Cli::try_parse() { + Ok(c) => c, + Err(e) => { + if json + && !matches!( + e.kind(), + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) + { + eprintln!( + "{}", + serde_json::json!({"schema_version":1,"error":{"code":"invalid_arguments","message":e.to_string()}}) + ); + std::process::exit(2); + } else { + e.exit(); + } + } + }; + let result = backend::install_signals().and_then(|_| opusab::cli::run(cli)); + if let Err(e) = result { + let (code, exit) = e + .downcast_ref::() + .map(|e| (e.code, e.exit)) + .unwrap_or(("operation_failed", 1)); + if json { + eprintln!( + "{}", + serde_json::json!({"schema_version":1,"error":{"code":code,"message":format!("{e:#}")}}) + ); + } else { + eprintln!("opusab: {e:#}"); + } + std::process::exit(exit); + } +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..868df84 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,314 @@ +use crate::error::fail; +use anyhow::{Result, ensure}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::PathBuf}; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Chapter { + pub start_ms: u64, + pub title: String, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +#[serde(default, deny_unknown_fields)] +pub struct Metadata { + pub title: String, + pub author: String, + pub narrator: String, + pub series: String, + pub series_part: String, + pub date: String, + pub genre: String, + pub description: String, + /// External cover to embed. None preserves an existing embedded cover. + pub cover: Option, + pub chapters: Vec, + pub extra: BTreeMap>, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct MetadataPatch { + pub title: Option, + pub author: Option, + pub narrator: Option, + pub series: Option, + pub series_part: Option, + pub date: Option, + pub genre: Option, + pub description: Option, + pub cover: Option, + pub remove_cover: bool, + pub chapters: Option>, + pub extra: Option>>, + pub remove_tags: Vec, +} +impl MetadataPatch { + pub fn validate(&self) -> Result<()> { + ensure!( + !(self.cover.is_some() && self.remove_cover), + "Cannot add and remove cover together" + ); + for key in &self.remove_tags { + ensure!(!is_managed(key), "Use the dedicated field to clear {key}"); + } + Ok(()) + } + pub fn is_empty(&self) -> bool { + self.title.is_none() + && self.author.is_none() + && self.narrator.is_none() + && self.series.is_none() + && self.series_part.is_none() + && self.date.is_none() + && self.genre.is_none() + && self.description.is_none() + && self.cover.is_none() + && !self.remove_cover + && self.chapters.is_none() + && self.extra.as_ref().is_none_or(|v| v.is_empty()) + && self.remove_tags.is_empty() + } + pub fn apply(&self, m: &mut Metadata) { + macro_rules! field { ($($f:ident),*) => { $(if let Some(v) = &self.$f { m.$f = v.clone(); })* }; } + field!( + title, + author, + narrator, + series, + series_part, + date, + genre, + description, + chapters + ); + if let Some(v) = &self.cover { + m.cover = Some(v.clone()); + } + if self.remove_cover { + m.cover = None; + } + if let Some(extra) = &self.extra { + for (k, v) in extra { + m.extra.insert(k.to_uppercase(), v.clone()); + } + } + for key in &self.remove_tags { + m.extra.remove(&key.to_uppercase()); + } + } +} +impl From<&Metadata> for MetadataPatch { + fn from(m: &Metadata) -> Self { + Self { + title: Some(m.title.clone()), + author: Some(m.author.clone()), + narrator: Some(m.narrator.clone()), + series: Some(m.series.clone()), + series_part: Some(m.series_part.clone()), + date: Some(m.date.clone()), + genre: Some(m.genre.clone()), + description: Some(m.description.clone()), + cover: m.cover.clone(), + chapters: Some(m.chapters.clone()), + extra: Some(m.extra.clone()), + ..Self::default() + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Source { + pub path: PathBuf, + pub codec: String, + pub duration_ms: u64, + pub sample_rate: u32, + pub channels: u32, + pub bitrate_kbps: u32, + pub size_bytes: u64, + pub disc: Option, + pub track: Option, + pub metadata: Metadata, + pub has_embedded_cover: bool, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Book { + pub schema_version: u32, + pub input_directory: Option, + pub sources: Vec, + pub metadata: Metadata, + pub duration_ms: u64, + pub warnings: Vec, +} +impl Book { + pub fn rebuild_chapters(&mut self) { + let mut elapsed = 0; + let mut chapters = Vec::new(); + let multiple = self.sources.len() > 1; + let same_titles = self + .sources + .windows(2) + .all(|w| w[0].metadata.title == w[1].metadata.title); + for s in &self.sources { + if s.metadata.chapters.is_empty() { + if multiple { + let title = if s.metadata.title.is_empty() || same_titles { + s.path + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .into_owned() + } else { + s.metadata.title.clone() + }; + chapters.push(Chapter { + start_ms: elapsed, + title, + }); + } + } else { + chapters.extend(s.metadata.chapters.iter().map(|c| Chapter { + start_ms: elapsed + c.start_ms, + title: c.title.clone(), + })); + } + elapsed += s.duration_ms; + } + self.metadata.chapters = chapters; + self.duration_ms = elapsed; + } +} + +#[derive(Clone, Copy, Debug, Default, clap::ValueEnum, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Preset { + #[default] + Speech, + Compact, + Mixed, +} +#[derive(Clone, Copy, Debug, Default, clap::ValueEnum, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Channels { + #[default] + Keep, + Mono, + Stereo, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Recommendation { + pub bitrate_kbps: u32, + pub reason: String, + pub estimated_bytes: u64, +} +pub fn recommend(book: &Book, preset: Preset) -> Recommendation { + let low_rate = book.sources.iter().all(|s| s.sample_rate <= 24000); + let low_mp3 = book + .sources + .iter() + .all(|s| s.codec == "mp3" && s.bitrate_kbps > 0 && s.bitrate_kbps <= 64); + let (bitrate, reason) = match preset { + Preset::Compact => ( + 24, + "Compact narration preset; prioritize size over fidelity.", + ), + Preset::Mixed => ( + 64, + "Mixed speech/music preset; retain more detail for music and stereo effects.", + ), + Preset::Speech if low_mp3 || low_rate => ( + 24, + "Lower-bandwidth source; suggest 24 kbit/s VBR for narration. Preview before choosing.", + ), + Preset::Speech => ( + 32, + "Narration preset: 32 kbit/s VBR. Source bitrate is a hint, not a quality equivalence.", + ), + }; + Recommendation { + bitrate_kbps: bitrate, + reason: reason.into(), + estimated_bytes: book.duration_ms * bitrate as u64 / 8, + } +} +pub fn validate_metadata(m: &Metadata, duration_ms: Option) -> Result<()> { + ensure!( + m.chapters.len() <= 1000, + "At most 1000 chapters are supported" + ); + let mut last = None; + for c in &m.chapters { + if last.is_some_and(|v| c.start_ms <= v) { + return Err(fail( + "invalid_chapters", + "Chapter times must be strictly increasing", + )); + } + if duration_ms.is_some_and(|v| c.start_ms >= v) { + return Err(fail( + "invalid_chapters", + "Chapter starts beyond the end of the book", + )); + } + last = Some(c.start_ms); + } + for k in m.extra.keys() { + ensure!( + !k.is_empty() && k.bytes().all(|v| (0x20..=0x7d).contains(&v) && v != b'='), + "Invalid tag name: {k}" + ); + ensure!(!is_managed(k), "Use the dedicated field for {k}"); + } + Ok(()) +} +pub fn is_managed(key: &str) -> bool { + let k = key.to_ascii_uppercase(); + matches!( + k.as_str(), + "TITLE" + | "ALBUM" + | "ARTIST" + | "AUTHOR" + | "ALBUMARTIST" + | "ALBUM_ARTIST" + | "NARRATOR" + | "COMPOSER" + | "PERFORMER" + | "SERIES" + | "SERIES-PART" + | "SERIES_PART" + | "PART" + | "DATE" + | "YEAR" + | "GENRE" + | "DESCRIPTION" + | "COMMENT" + | "METADATA_BLOCK_PICTURE" + ) || k.starts_with("CHAPTER") +} +pub fn time_string(ms: u64) -> String { + format!( + "{:02}:{:02}:{:02}.{:03}", + ms / 3_600_000, + ms / 60_000 % 60, + ms / 1000 % 60, + ms % 1000 + ) +} +pub fn parse_time(s: &str) -> Result { + let parts: Vec<_> = s.trim().split(':').collect(); + ensure!( + (1..=3).contains(&parts.len()), + "Use HH:MM:SS.mmm or seconds" + ); + let mut secs = 0.0; + for p in parts { + let v: f64 = p.parse()?; + ensure!(v.is_finite() && v >= 0.0, "Invalid timestamp"); + secs = secs * 60.0 + v; + } + ensure!(secs < 1e12, "Timestamp too large"); + Ok((secs * 1000.0).round() as u64) +} diff --git a/src/ogg.rs b/src/ogg.rs new file mode 100644 index 0000000..bd979b7 --- /dev/null +++ b/src/ogg.rs @@ -0,0 +1,752 @@ +//! Ogg Opus metadata I/O. The fast path never scans or copies audio pages. +use crate::{ + error::fail, + model::{self, Chapter, Metadata, MetadataPatch}, +}; +use anyhow::{Context, Result, ensure}; +use base64::{Engine, engine::general_purpose::STANDARD as B64}; +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs::{self, File, OpenOptions}, + io::{Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, +}; + +pub const DEFAULT_PADDING: usize = 256 * 1024; +pub const MAX_HEADER: usize = 16 * 1024 * 1024; +const POLY: u32 = 0x04c11db7; + +#[derive(Debug, Clone)] +pub struct Page { + pub offset: u64, + pub bytes: Vec, + pub header_len: usize, +} +impl Page { + pub fn serial(&self) -> u32 { + u32::from_le_bytes(self.bytes[14..18].try_into().unwrap()) + } + pub fn sequence(&self) -> u32 { + u32::from_le_bytes(self.bytes[18..22].try_into().unwrap()) + } + pub fn body(&self) -> &[u8] { + &self.bytes[self.header_len..] + } + pub fn checksum(&mut self) { + self.bytes[22..26].fill(0); + let crc = crc(&self.bytes); + self.bytes[22..26].copy_from_slice(&crc.to_le_bytes()); + } +} +pub fn crc(data: &[u8]) -> u32 { + let mut result = 0u32; + for &b in data { + result ^= (b as u32) << 24; + for _ in 0..8 { + result = if result & 0x8000_0000 != 0 { + (result << 1) ^ POLY + } else { + result << 1 + }; + } + } + result +} +pub fn read_page(r: &mut R) -> Result { + let offset = r.stream_position()?; + let mut header = [0u8; 27]; + r.read_exact(&mut header).context("Truncated Ogg page")?; + ensure!( + &header[..4] == b"OggS" && header[4] == 0, + "Invalid Ogg page at {offset}" + ); + let mut lacing = vec![0; header[26] as usize]; + r.read_exact(&mut lacing)?; + let body_len: usize = lacing.iter().map(|v| *v as usize).sum(); + let header_len = 27 + lacing.len(); + let mut bytes = Vec::with_capacity(header_len + body_len); + bytes.extend(header); + bytes.extend(lacing); + bytes.resize(header_len + body_len, 0); + r.read_exact(&mut bytes[header_len..])?; + let expected = u32::from_le_bytes(bytes[22..26].try_into().unwrap()); + bytes[22..26].fill(0); + ensure!(crc(&bytes) == expected, "Ogg checksum mismatch at {offset}"); + bytes[22..26].copy_from_slice(&expected.to_le_bytes()); + Ok(Page { + offset, + bytes, + header_len, + }) +} + +#[derive(Debug, Clone)] +pub struct Header { + pub head: Page, + pub pages: Vec, + pub vendor: Vec, + pub comments: Vec, + pub opaque: Vec, + pub capacity: usize, + pub used: usize, + pub end: u64, + pub channels: u32, + pub pre_skip: u16, +} +fn get_u32(packet: &[u8], at: &mut usize) -> Result { + ensure!(*at + 4 <= packet.len(), "Truncated OpusTags length"); + let n = u32::from_le_bytes(packet[*at..*at + 4].try_into().unwrap()) as usize; + *at += 4; + Ok(n) +} +fn get_bytes(packet: &[u8], at: &mut usize) -> Result> { + let n = get_u32(packet, at)?; + ensure!( + n <= packet.len().saturating_sub(*at), + "Invalid OpusTags field length" + ); + let v = packet[*at..*at + n].to_vec(); + *at += n; + Ok(v) +} +pub fn read_header(r: &mut R) -> Result
{ + r.seek(SeekFrom::Start(0))?; + let head = read_page(r)?; + ensure!( + head.bytes[5] & 2 != 0 + && head.sequence() == 0 + && head.body().starts_with(b"OpusHead") + && head.body().len() >= 19, + "Expected an Ogg Opus file" + ); + ensure!(head.body()[8] >> 4 == 0, "Unsupported Opus header version"); + let channels = head.body()[9] as u32; + let pre_skip = u16::from_le_bytes(head.body()[10..12].try_into().unwrap()); + let mut pages = Vec::new(); + let mut packet = Vec::new(); + loop { + let page = read_page(r)?; + ensure!( + page.serial() == head.serial() && page.sequence() == pages.len() as u32 + 1, + "Unexpected Opus metadata stream/sequence" + ); + ensure!( + (page.bytes[5] & 1 != 0) == !pages.is_empty(), + "Invalid metadata continuation flag" + ); + ensure!(page.bytes[5] & 6 == 0, "Invalid metadata page flags"); + let laces = &page.bytes[27..page.header_len]; + ensure!(!laces.is_empty(), "Empty metadata page"); + ensure!( + laces[..laces.len() - 1].iter().all(|v| *v == 255), + "Metadata must occupy complete pages" + ); + let done = *laces.last().unwrap() < 255; + ensure!( + packet.len() + page.body().len() <= MAX_HEADER, + "Opus metadata exceeds 16 MiB limit" + ); + packet.extend_from_slice(page.body()); + pages.push(page); + if done { + break; + } + } + ensure!(packet.starts_with(b"OpusTags"), "Missing OpusTags packet"); + let capacity = packet.len(); + let mut at = 8; + let vendor = get_bytes(&packet, &mut at)?; + let count = get_u32(&packet, &mut at)?; + ensure!( + count <= packet.len().saturating_sub(at) / 4, + "Invalid OpusTags comment count" + ); + let mut comments = Vec::new(); + for _ in 0..count { + comments.push(String::from_utf8(get_bytes(&packet, &mut at)?).context("Tag is not UTF-8")?); + } + let opaque = if packet.get(at).is_some_and(|b| b & 1 != 0) { + packet[at..].to_vec() + } else { + Vec::new() + }; + Ok(Header { + head, + pages, + vendor, + comments, + used: at + opaque.len(), + opaque, + capacity, + end: r.stream_position()?, + channels, + pre_skip, + }) +} +impl Header { + pub fn tags(&self) -> BTreeMap> { + let mut tags: BTreeMap> = BTreeMap::new(); + for c in &self.comments { + if let Some((k, v)) = c.split_once('=') { + tags.entry(k.to_ascii_uppercase()) + .or_default() + .push(v.into()); + } + } + tags + } + pub fn metadata(&self) -> Metadata { + metadata_from_tags(&self.tags()) + } + pub fn has_cover(&self) -> bool { + self.comments.iter().any(|s| { + s.to_ascii_uppercase() + .starts_with("METADATA_BLOCK_PICTURE=") + }) + } + pub fn packet(&self, comments: &[String], capacity: usize) -> Result> { + let mut b = b"OpusTags".to_vec(); + put_string(&mut b, &self.vendor); + b.extend_from_slice(&(comments.len() as u32).to_le_bytes()); + for c in comments { + put_string(&mut b, c.as_bytes()); + } + b.extend_from_slice(&self.opaque); + if b.len() > capacity { + return Err(fail( + "metadata_capacity_exceeded", + format!( + "Metadata needs {} bytes; {} available. Run `opusab tags reserve FILE --padding-kib N` to grow the header without re-encoding.", + b.len(), + capacity + ), + )); + } + ensure!(capacity <= MAX_HEADER, "Metadata capacity exceeds 16 MiB"); + b.resize(capacity, 0); + Ok(b) + } + pub fn patched_comments( + &self, + patch: &MetadataPatch, + duration: Option, + ) -> Result> { + if patch.is_empty() { + return Ok(self.comments.clone()); + } + let mut m = self.metadata(); + patch.validate()?; + patch.apply(&mut m); + model::validate_metadata(&m, duration)?; + ensure!( + !(patch.cover.is_some() && patch.remove_cover), + "Cannot add and remove cover together" + ); + let mut tags = self.tags(); + // Only fields explicitly edited are replaced; preserve aliases and unknown fields otherwise. + macro_rules! set { ($field:ident, $key:expr, [$($alias:expr),*]) => { + if let Some(value) = &patch.$field { $(tags.remove($alias);)* tags.remove($key); if !value.is_empty() { tags.insert($key.into(), vec![value.clone()]); } } + }; } + set!(title, "TITLE", []); + set!(author, "ARTIST", ["AUTHOR", "ALBUMARTIST", "ALBUM_ARTIST"]); + set!(narrator, "NARRATOR", ["COMPOSER", "PERFORMER"]); + set!(series, "SERIES", []); + set!(series_part, "SERIES-PART", ["SERIES_PART", "PART"]); + set!(date, "DATE", ["YEAR"]); + set!(genre, "GENRE", []); + set!(description, "DESCRIPTION", ["COMMENT"]); + if let Some(v) = &patch.title { + tags.remove("ALBUM"); + if !v.is_empty() { + tags.insert("ALBUM".into(), vec![v.clone()]); + } + } + if let Some(chapters) = &patch.chapters { + tags.retain(|k, _| !k.starts_with("CHAPTER")); + for (i, c) in chapters.iter().enumerate() { + tags.insert( + format!("CHAPTER{i:03}"), + vec![model::time_string(c.start_ms)], + ); + tags.insert(format!("CHAPTER{i:03}NAME"), vec![c.title.clone()]); + } + } + if let Some(extra) = &patch.extra { + for (key, values) in extra { + let key = key.to_uppercase(); + tags.remove(&key); + if !values.is_empty() { + tags.insert(key, values.clone()); + } + } + } + for key in &patch.remove_tags { + ensure!( + !model::is_managed(key), + "Use the dedicated field to clear {key}" + ); + tags.remove(&key.to_uppercase()); + } + if patch.remove_cover { + tags.remove("METADATA_BLOCK_PICTURE"); + } + if let Some(path) = &patch.cover { + tags.insert("METADATA_BLOCK_PICTURE".into(), vec![encode_picture(path)?]); + } + let pictures = tags.remove("METADATA_BLOCK_PICTURE"); + let mut comments: Vec<_> = tags + .into_iter() + .flat_map(|(k, vs)| vs.into_iter().map(move |v| format!("{k}={v}"))) + .collect(); + if let Some(vs) = pictures { + comments.extend( + vs.into_iter() + .map(|v| format!("METADATA_BLOCK_PICTURE={v}")), + ); + } + // Comments without '=' are legal to retain, although not editable through the key/value API. + comments.extend(self.comments.iter().filter(|v| !v.contains('=')).cloned()); + Ok(comments) + } +} +fn put_string(b: &mut Vec, s: &[u8]) { + b.extend_from_slice(&(s.len() as u32).to_le_bytes()); + b.extend_from_slice(s); +} +pub fn metadata_from_tags(tags: &BTreeMap>) -> Metadata { + let value = |names: &[&str]| -> String { + names + .iter() + .find_map(|k| tags.get(*k).and_then(|v| v.first()).cloned()) + .unwrap_or_default() + }; + let mut chapters = Vec::new(); + for (k, vs) in tags { + if let Some(n) = k.strip_prefix("CHAPTER") + && n.len() == 3 + && n.bytes().all(|c| c.is_ascii_digit()) + && let Some(t) = vs.first().and_then(|s| model::parse_time(s).ok()) + { + chapters.push(Chapter { + start_ms: t, + title: value(&[&format!("{k}NAME")]), + }); + } + } + chapters.sort_by_key(|c| c.start_ms); + Metadata { + title: value(&["TITLE", "ALBUM"]), + author: value(&["ARTIST", "AUTHOR", "ALBUMARTIST", "ALBUM_ARTIST"]), + narrator: value(&["NARRATOR", "COMPOSER", "PERFORMER"]), + series: value(&["SERIES"]), + series_part: value(&["SERIES-PART", "SERIES_PART", "PART"]), + date: value(&["DATE", "YEAR"]), + genre: value(&["GENRE"]), + description: value(&["DESCRIPTION", "COMMENT"]), + cover: None, + chapters, + extra: tags + .iter() + .filter(|(k, _)| !model::is_managed(k)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + } +} +pub(crate) fn encode_picture(path: &Path) -> Result { + ensure!( + fs::metadata(path)?.len() <= 8 * 1024 * 1024, + "Cover must be at most 8 MiB" + ); + let data = fs::read(path)?; + let format = image::guess_format(&data)?; + let mime = match format { + image::ImageFormat::Png => "image/png", + image::ImageFormat::Jpeg => "image/jpeg", + _ => anyhow::bail!("Cover must be JPEG or PNG"), + }; + let (w, h) = image::ImageReader::new(std::io::Cursor::new(&data)) + .with_guessed_format()? + .into_dimensions()?; + let mut b = Vec::new(); + b.extend_from_slice(&3u32.to_be_bytes()); + b.extend_from_slice(&(mime.len() as u32).to_be_bytes()); + b.extend_from_slice(mime.as_bytes()); + b.extend_from_slice(&0u32.to_be_bytes()); + for n in [w, h, 24, 0, data.len() as u32] { + b.extend_from_slice(&n.to_be_bytes()); + } + b.extend(data); + Ok(B64.encode(b)) +} +pub fn duration_ms(file: &mut File, header: &Header) -> Result { + let len = file.metadata()?.len(); + let start = len.saturating_sub(131072); + file.seek(SeekFrom::Start(start))?; + let mut tail = Vec::new(); + file.read_to_end(&mut tail)?; + for pos in (0..tail.len().saturating_sub(26)).rev() { + if &tail[pos..pos + 4] != b"OggS" { + continue; + } + let mut r = std::io::Cursor::new(&tail); + r.set_position(pos as u64); + if let Ok(page) = read_page(&mut r) { + if page.bytes[5] & 4 == 0 { + continue; + } + ensure!( + page.serial() == header.head.serial(), + "Chained Opus files are not supported; convert to one logical stream first" + ); + let granule = u64::from_le_bytes(page.bytes[6..14].try_into().unwrap()); + ensure!( + granule != u64::MAX && granule >= header.pre_skip as u64, + "Invalid end granule" + ); + return Ok((granule - header.pre_skip as u64) * 1000 / 48000); + } + } + Err(fail("invalid_opus", "No valid final Ogg page found")) +} + +pub fn journal_path(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(".opusab-journal"); + path.with_file_name(name) +} +pub fn check_journal(path: &Path) -> Result<()> { + if journal_path(path).exists() { + return Err(fail( + "recovery_required", + format!( + "An interrupted metadata edit exists. Run `opusab tags recover {:?}` first.", + path + ), + )); + } + Ok(()) +} +#[derive(Debug, Serialize, Deserialize)] +struct JournalPage { + offset: u64, + before: String, + after: String, +} +#[derive(Debug, Serialize, Deserialize)] +struct Journal { + version: u32, + file_len: u64, + identity: String, + head_hash: String, + header_end: u64, + pages: Vec, +} +fn identity(file: &File) -> Result { + let m = file.metadata()?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + Ok(format!("{}:{}", m.dev(), m.ino())) + } + #[cfg(not(unix))] + { + Ok(format!("{:?}", m.created()?)) + } +} +fn check_identity(path: &Path, file: &File) -> Result<()> { + ensure!( + identity(&File::open(path)?)? == identity(file)?, + "The file was replaced by another process; retry the operation" + ); + Ok(()) +} +fn hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn sync_parent(_path: &Path) -> Result<()> { + #[cfg(unix)] + File::open(_path.parent().unwrap_or(Path::new(".")))?.sync_all()?; + Ok(()) +} +#[derive(Debug, Serialize, Deserialize)] +pub struct EditReport { + pub mode: String, + pub pages_written: usize, + pub bytes_written: u64, + pub audio_bytes_written: u64, + pub capacity_bytes: usize, + pub available_bytes: usize, +} + +pub fn edit(path: &Path, patch: &MetadataPatch) -> Result { + let path = path.canonicalize()?; + let mut file = OpenOptions::new().read(true).write(true).open(&path)?; + file.try_lock_exclusive().map_err(|_| { + fail( + "file_busy", + "The audiobook is being edited by another process", + ) + })?; + check_journal(&path)?; + check_identity(&path, &file)?; + let header = read_header(&mut file)?; + let duration = duration_ms(&mut file, &header)?; + let comments = header.patched_comments(patch, Some(duration))?; + let packet = header.packet(&comments, header.capacity)?; + let used = 8 + + 4 + + header.vendor.len() + + 4 + + comments.iter().map(|s| 4 + s.len()).sum::() + + header.opaque.len(); + let mut at = 0; + let mut changes = Vec::new(); + for page in &header.pages { + let mut new = page.clone(); + let n = page.body().len(); + new.bytes[page.header_len..].copy_from_slice(&packet[at..at + n]); + at += n; + new.checksum(); + if new.bytes != page.bytes { + changes.push((page.clone(), new)); + } + } + let mut report = EditReport { + mode: if changes.is_empty() { + "unchanged" + } else { + "in_place" + } + .into(), + pages_written: changes.len(), + bytes_written: 0, + audio_bytes_written: 0, + capacity_bytes: header.capacity, + available_bytes: header.capacity - used, + }; + if changes.is_empty() { + return Ok(report); + } + let journal = Journal { + version: 1, + file_len: file.metadata()?.len(), + identity: identity(&file)?, + head_hash: hash(&header.head.bytes), + header_end: header.end, + pages: changes + .iter() + .map(|(a, b)| JournalPage { + offset: a.offset, + before: B64.encode(&a.bytes), + after: B64.encode(&b.bytes), + }) + .collect(), + }; + let jp = journal_path(&path); + let mut temporary_journal = tempfile::NamedTempFile::new_in(path.parent().unwrap())?; + serde_json::to_writer(&mut temporary_journal, &journal)?; + temporary_journal.as_file().sync_all()?; + let jf = temporary_journal + .persist_noclobber(&jp) + .map_err(|e| e.error)?; + sync_parent(&jp)?; + for (_, page) in changes { + file.seek(SeekFrom::Start(page.offset))?; + file.write_all(&page.bytes)?; + report.bytes_written += page.bytes.len() as u64; + } + file.sync_all()?; + drop(jf); + fs::remove_file(&jp)?; + sync_parent(&jp)?; + Ok(report) +} +pub fn recover(path: &Path) -> Result { + let path = path.canonicalize()?; + let mut file = OpenOptions::new().read(true).write(true).open(&path)?; + file.try_lock_exclusive().map_err(|_| { + fail( + "file_busy", + "The audiobook is being edited by another process", + ) + })?; + let jp = journal_path(&path); + check_identity(&path, &file)?; + ensure!( + fs::metadata(&jp)?.len() < (MAX_HEADER * 4) as u64, + "Recovery journal is too large" + ); + let j: Journal = serde_json::from_reader(File::open(&jp)?)?; + ensure!( + j.version == 1 && j.file_len == file.metadata()?.len() && j.identity == identity(&file)?, + "Recovery journal belongs to a different file" + ); + let head = read_page(&mut file)?; + ensure!( + hash(&head.bytes) == j.head_hash && j.header_end <= MAX_HEADER as u64 + 65536, + "Invalid recovery header" + ); + let mut validated = Vec::new(); + let mut end = head.bytes.len() as u64; + for p in &j.pages { + let before = B64.decode(&p.before)?; + let after = B64.decode(&p.after)?; + ensure!( + before.len() == after.len() + && p.offset >= end + && p.offset + before.len() as u64 <= j.header_end, + "Invalid journal page range" + ); + let old = read_page(&mut std::io::Cursor::new(&before))?; + ensure!(old.serial() == head.serial(), "Recovery stream mismatch"); + end = p.offset + before.len() as u64; + validated.push((p.offset, before)); + } + let mut bytes = 0; + for (offset, before) in &validated { + file.seek(SeekFrom::Start(*offset))?; + file.write_all(before)?; + bytes += before.len() as u64; + } + file.sync_all()?; + fs::remove_file(jp)?; + sync_parent(&path)?; + let h = read_header(&mut file)?; + Ok(EditReport { + mode: "recovered".into(), + pages_written: validated.len(), + bytes_written: bytes, + audio_bytes_written: 0, + capacity_bytes: h.capacity, + available_bytes: h.capacity - h.used, + }) +} + +pub fn comment_pages(packet: &[u8], serial: u32) -> Vec { + let mut pages = Vec::new(); + let mut at = 0; + loop { + let remaining = packet.len() - at; + let n = remaining.min(65025); + let mut lacing = vec![255u8; n / 255]; + let done = remaining < 65025; + if done { + lacing.push((n % 255) as u8); + } + let mut bytes = vec![0; 27]; + bytes[..4].copy_from_slice(b"OggS"); + bytes[5] = if pages.is_empty() { 0 } else { 1 }; + bytes[6..14].copy_from_slice(&if done { 0u64 } else { u64::MAX }.to_le_bytes()); + bytes[14..18].copy_from_slice(&serial.to_le_bytes()); + bytes[18..22].copy_from_slice(&(pages.len() as u32 + 1).to_le_bytes()); + bytes[26] = lacing.len() as u8; + bytes.extend(lacing); + let header_len = bytes.len(); + bytes.extend_from_slice(&packet[at..at + n]); + let mut p = Page { + offset: 0, + bytes, + header_len, + }; + p.checksum(); + pages.push(p); + at += n; + if done { + break; + } + } + pages +} +/// Create a new padded file, copying compressed audio pages without decoding. +pub fn rewrite( + source: &Path, + destination: &Path, + patch: &MetadataPatch, + padding: usize, + overwrite: bool, +) -> Result { + ensure!(padding <= MAX_HEADER / 2, "Padding must be at most 8 MiB"); + let source = source.canonicalize()?; + let mut input = File::open(&source)?; + if destination.canonicalize().is_ok_and(|p| p == source) { + input.try_lock_exclusive().map_err(|_| { + fail( + "file_busy", + "The audiobook is being edited by another process", + ) + })?; + } else { + FileExt::try_lock_shared(&input).map_err(|_| { + fail( + "file_busy", + "The audiobook is being edited by another process", + ) + })?; + } + check_identity(&source, &input)?; + check_journal(&source)?; + let h = read_header(&mut input)?; + let duration = duration_ms(&mut input, &h)?; + let comments = h.patched_comments(patch, Some(duration))?; + let used = 8 + + 4 + + h.vendor.len() + + 4 + + comments.iter().map(|s| 4 + s.len()).sum::() + + h.opaque.len(); + let packet = h.packet(&comments, used + padding)?; + let pages = comment_pages(&packet, h.head.serial()); + let parent = destination + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut out = tempfile::NamedTempFile::new_in(parent)?; + fs::set_permissions(out.path(), input.metadata()?.permissions())?; + out.write_all(&h.head.bytes)?; + for p in &pages { + out.write_all(&p.bytes)?; + } + input.seek(SeekFrom::Start(h.end))?; + let delta = pages.len() as i64 - h.pages.len() as i64; + let mut audio_bytes = 0; + let mut count = 0; + let len = input.metadata()?.len(); + while input.stream_position()? < len { + if crate::backend::is_cancelled() { + return Err(crate::error::cancelled()); + } + let mut p = read_page(&mut input)?; + ensure!( + p.serial() == h.head.serial() && p.bytes[5] & 2 == 0, + "Chained/multiplexed Ogg files are not supported" + ); + let seq = p.sequence() as i64 + delta; + ensure!( + (0..=u32::MAX as i64).contains(&seq), + "Ogg page sequence overflow" + ); + p.bytes[18..22].copy_from_slice(&(seq as u32).to_le_bytes()); + p.checksum(); + out.write_all(&p.bytes)?; + audio_bytes += p.bytes.len() as u64; + count += 1; + } + out.as_file().sync_all()?; + if overwrite { + out.persist(destination).map_err(|e| e.error)?; + } else { + out.persist_noclobber(destination).map_err(|e| e.error)?; + } + sync_parent(destination)?; + Ok(EditReport { + mode: "repacked".into(), + pages_written: pages.len() + count + 1, + bytes_written: fs::metadata(destination)?.len(), + audio_bytes_written: audio_bytes, + capacity_bytes: packet.len(), + available_bytes: padding, + }) +} diff --git a/src/probe.rs b/src/probe.rs new file mode 100644 index 0000000..996dfb5 --- /dev/null +++ b/src/probe.rs @@ -0,0 +1,307 @@ +use crate::{ + backend, + error::fail, + model::{Book, Chapter, Source}, + ogg, +}; +use anyhow::{Context, Result, ensure}; +use serde_json::Value; +use std::{ + collections::BTreeMap, + fs::{self, File}, + path::{Path, PathBuf}, + process::Command, +}; + +pub fn supported(path: &Path) -> bool { + path.extension().is_some_and(|e| { + matches!( + e.to_string_lossy().to_ascii_lowercase().as_str(), + "mp3" + | "m4b" + | "m4a" + | "mp4" + | "opus" + | "ogg" + | "flac" + | "wav" + | "aif" + | "aiff" + | "aac" + ) + }) +} +pub fn book(paths: &[PathBuf]) -> Result { + ensure!(!paths.is_empty(), "Provide a file or folder"); + let mut sources = Vec::new(); + let mut warnings = Vec::new(); + let mut seen = std::collections::HashSet::new(); + let mut cover = None; + for path in paths { + let path = path + .canonicalize() + .with_context(|| format!("Cannot open {}", path.display()))?; + let directory = path.is_dir(); + let mut files = Vec::new(); + if directory { + for entry in walkdir::WalkDir::new(&path).follow_links(false) { + let entry = entry?; + if entry.file_type().is_file() && supported(entry.path()) { + files.push(entry.path().to_path_buf()); + } + } + files.sort_by(|a, b| natord::compare(&a.to_string_lossy(), &b.to_string_lossy())); + if cover.is_none() { + cover = folder_cover(&path)?; + } + } else { + files.push(path.clone()); + } + ensure!( + !files.is_empty(), + "No supported audio files found in {}", + path.display() + ); + let mut group = Vec::new(); + for file in files { + if backend::is_cancelled() { + return Err(crate::error::cancelled()); + } + ensure!( + seen.insert(file.clone()), + "Duplicate source: {}", + file.display() + ); + group.push(inspect(&file)?); + } + if directory && group.iter().all(|s| s.track.is_some()) { + group.sort_by(|a, b| { + (a.disc.unwrap_or(1), a.track) + .cmp(&(b.disc.unwrap_or(1), b.track)) + .then_with(|| { + natord::compare(&a.path.to_string_lossy(), &b.path.to_string_lossy()) + }) + }); + if group + .windows(2) + .any(|w| (w[0].disc, w[0].track) == (w[1].disc, w[1].track)) + { + warnings.push( + "Duplicate disc/track numbers; filenames break ties. Review file order.".into(), + ); + } + } else if directory && group.len() > 1 { + warnings.push("Some track numbers are missing; files use natural filename order. Review before converting.".into()); + } + sources.extend(group); + } + let mut metadata = sources[0].metadata.clone(); + if sources.len() > 1 { + // Probe stores ALBUM in a reserved per-source hint; it is removed before output. + if let Some(album) = metadata + .extra + .remove("OPUSAB_SOURCE_ALBUM") + .and_then(|v| v.into_iter().next()) + { + metadata.title = album; + } + metadata.extra.remove("TRACKNUMBER"); + metadata.extra.remove("DISCNUMBER"); + } + metadata.extra.remove("OPUSAB_SOURCE_ALBUM"); + metadata.cover = cover; + if metadata.title.is_empty() { + metadata.title = paths[0] + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .replace('_', " "); + } + let input_directory = if paths.len() == 1 && paths[0].is_dir() { + Some(paths[0].canonicalize()?) + } else { + None + }; + let mut result = Book { + schema_version: 1, + input_directory, + sources, + metadata, + duration_ms: 0, + warnings, + }; + result.rebuild_chapters(); + if result.sources.iter().any(|s| s.channels > 2) { + result.warnings.push( + "Multichannel source: choose --channels mono or stereo for phone compatibility.".into(), + ); + } + Ok(result) +} +fn folder_cover(path: &Path) -> Result> { + let mut files: Vec<_> = fs::read_dir(path)? + .filter_map(|e| e.ok().map(|v| v.path())) + .filter(|p| { + p.extension().is_some_and(|e| { + matches!( + e.to_string_lossy().to_ascii_lowercase().as_str(), + "jpg" | "jpeg" | "png" + ) + }) + }) + .collect(); + files.sort_by_key(|p| { + let n = p + .file_stem() + .unwrap_or_default() + .to_string_lossy() + .to_lowercase(); + ( + if n == "cover" { + 0 + } else if n == "folder" { + 1 + } else if n.contains("front") { + 2 + } else { + 3 + }, + n, + ) + }); + Ok(files.into_iter().next()) +} +fn numeric(v: &Value) -> f64 { + v.as_f64() + .or_else(|| v.as_str().and_then(|s| s.parse().ok())) + .unwrap_or(0.0) +} +fn tag_number(tags: &BTreeMap>, keys: &[&str]) -> Option { + keys.iter().find_map(|k| { + tags.get(*k) + .and_then(|vs| vs.first()) + .and_then(|v| v.split('/').next()?.parse().ok()) + }) +} +pub fn inspect(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut signature = [0u8; 4]; + use std::io::Read; + let _ = file.read(&mut signature)?; + if &signature == b"OggS" + && let Ok(h) = ogg::read_header(&mut file) + { + ogg::check_journal(path)?; + let duration_ms = ogg::duration_ms(&mut file, &h)?; + let size_bytes = file.metadata()?.len(); + return Ok(Source { + path: path.to_path_buf(), + codec: "opus".into(), + duration_ms, + sample_rate: 48000, + channels: h.channels, + bitrate_kbps: size_bytes + .saturating_sub(h.end) + .saturating_mul(8) + .checked_div(duration_ms) + .unwrap_or(0) as u32, + size_bytes, + disc: None, + track: None, + metadata: h.metadata(), + has_embedded_cover: h.has_cover(), + }); + } + let output = Command::new(backend::find("ffprobe")?) + .args([ + "-v", + "error", + "-show_format", + "-show_streams", + "-show_chapters", + "-of", + "json", + ]) + .arg(path) + .output()?; + if !output.status.success() { + return Err(fail( + "probe_failed", + format!( + "Cannot inspect {}: {}", + path.display(), + String::from_utf8_lossy(&output.stderr).trim() + ), + )); + } + let v: Value = serde_json::from_slice(&output.stdout)?; + let streams = v["streams"].as_array().context("No streams")?; + let audio = streams + .iter() + .find(|s| s["codec_type"] == "audio") + .context("No audio stream in input")?; + let duration = numeric(&audio["duration"]).max(numeric(&v["format"]["duration"])); + ensure!( + duration.is_finite() && duration > 0.0, + "Cannot determine duration of {}", + path.display() + ); + let mut tags: BTreeMap> = BTreeMap::new(); + for t in [&audio["tags"], &v["format"]["tags"]] { + if let Some(t) = t.as_object() { + for (k, v) in t { + if let Some(v) = v.as_str() { + tags.insert(k.to_ascii_uppercase(), vec![v.into()]); + } + } + } + } + let track = tag_number(&tags, &["TRACK", "TRACKNUMBER"]); + let disc = tag_number(&tags, &["DISC", "DISCNUMBER"]); + let mut metadata = ogg::metadata_from_tags(&tags); + if let Some(album) = tags.get("ALBUM") { + metadata + .extra + .insert("OPUSAB_SOURCE_ALBUM".into(), album.clone()); + } + for k in [ + "MAJOR_BRAND", + "MINOR_VERSION", + "COMPATIBLE_BRANDS", + "ENCODER", + "ENCODING_TOOL", + "HANDLER_NAME", + "VENDOR_ID", + "TLEN", + "DURATION", + "TRACK", + "DISC", + "LANGUAGE", + ] { + metadata.extra.remove(k); + } + if let Some(chapters) = v["chapters"].as_array() { + metadata.chapters = chapters + .iter() + .map(|c| Chapter { + start_ms: (numeric(&c["start_time"]) * 1000.0).round() as u64, + title: c["tags"]["title"].as_str().unwrap_or("").into(), + }) + .collect(); + } + Ok(Source { + path: path.to_path_buf(), + codec: audio["codec_name"].as_str().unwrap_or("unknown").into(), + duration_ms: (duration * 1000.0).round() as u64, + sample_rate: numeric(&audio["sample_rate"]) as u32, + channels: numeric(&audio["channels"]) as u32, + bitrate_kbps: (numeric(&audio["bit_rate"]) / 1000.0).round() as u32, + size_bytes: fs::metadata(path)?.len(), + disc, + track, + metadata, + has_embedded_cover: streams + .iter() + .any(|s| s["disposition"]["attached_pic"] == 1), + }) +} diff --git a/src/tui.rs b/src/tui.rs new file mode 100644 index 0000000..2b4de2d --- /dev/null +++ b/src/tui.rs @@ -0,0 +1,779 @@ +use crate::{ + app::{self, ConversionOptions, ConversionReport}, + backend::{self, Progress}, + model::{self, Book, Channels, Chapter, MetadataPatch, Preset}, + probe, +}; +use anyhow::{Result, ensure}; +use crossterm::{ + event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use ratatui::{ + prelude::*, + widgets::{Block, Borders, Clear, Paragraph, Row, Table, TableState, Tabs, Wrap}, +}; +use std::{io, path::PathBuf, sync::mpsc, thread, time::Duration}; +use unicode_width::UnicodeWidthStr; + +const ACCENT: Color = Color::Rgb(84, 211, 181); +const MUTED: Color = Color::Rgb(137, 150, 165); +const BG: Color = Color::Rgb(19, 24, 31); +const SELECT: Color = Color::Rgb(35, 57, 61); +const GOLD: Color = Color::Rgb(238, 183, 93); +const BOOK_FIELDS: [&str; 11] = [ + "Title", + "Author", + "Narrator", + "Series", + "Series number", + "Year / date", + "Genre", + "Description", + "Cover image", + "Output file", + "Remove cover", +]; +const SETTING_FIELDS: [&str; 6] = [ + "Bitrate (kbit/s)", + "Preset", + "Channels", + "Workers", + "Metadata reserve (KiB)", + "Replace existing output", +]; + +#[derive(Clone, Copy)] +enum EditTarget { + Book(usize), + ChapterName(usize), + ChapterTime(usize), + Setting(usize), +} +struct Editor { + title: String, + text: String, + cursor: usize, + target: EditTarget, +} +enum WorkerEvent { + Progress(Progress), + Done(Result), +} +struct State { + book: Book, + options: ConversionOptions, + tab: usize, + selected: [usize; 4], + editor: Option, + message: String, + error: bool, + dirty: bool, + quit_confirm: bool, + remove_cover: bool, + worker: Option>, + last_output: Option, +} +impl State { + fn new(book: Book) -> Self { + Self { + book, + options: ConversionOptions::default(), + tab: 0, + selected: [0; 4], + editor: None, + message: "Review metadata and chapters, then press F5 to save or convert.".into(), + error: false, + dirty: false, + quit_confirm: false, + remove_cover: false, + worker: None, + last_output: None, + } + } + fn count(&self) -> usize { + match self.tab { + 0 => BOOK_FIELDS.len(), + 1 => self.book.metadata.chapters.len(), + 2 => self.book.sources.len(), + _ => SETTING_FIELDS.len(), + } + } + fn book_values(&self) -> Vec { + let m = &self.book.metadata; + vec![ + m.title.clone(), + m.author.clone(), + m.narrator.clone(), + m.series.clone(), + m.series_part.clone(), + m.date.clone(), + m.genre.clone(), + m.description.clone(), + m.cover + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| { + if self.book.sources.iter().any(|s| s.has_embedded_cover) && !self.remove_cover + { + "Embedded cover · preserved".into() + } else { + "None".into() + } + }), + self.output_text(), + if self.remove_cover { "yes" } else { "no" }.into(), + ] + } + fn output_text(&self) -> String { + self.options + .output + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| { + app::plan_book(self.book.clone(), &self.options, MetadataPatch::default()) + .map(|p| p.output.display().to_string()) + .unwrap_or_else(|_| "Choose an output file".into()) + }) + } + fn setting_values(&self) -> Vec { + let r = model::recommend(&self.book, self.options.preset); + vec![ + self.options + .bitrate + .map(|v| v.to_string()) + .unwrap_or_else(|| format!("auto · suggested {} VBR", r.bitrate_kbps)), + format!("{:?}", self.options.preset).to_lowercase(), + format!("{:?}", self.options.channels).to_lowercase(), + if self.options.jobs == 0 { + "auto".into() + } else { + self.options.jobs.to_string() + }, + self.options.padding_kib.to_string(), + if self.options.overwrite { "yes" } else { "no" }.into(), + ] + } + fn open(&mut self, target: EditTarget) { + let (title, text) = match target { + EditTarget::Book(i) => { + let mut s = self.book_values()[i].clone(); + if i == 8 { + s = self + .book + .metadata + .cover + .as_ref() + .map(|p| p.display().to_string()) + .unwrap_or_default(); + } + (BOOK_FIELDS[i].into(), s) + } + EditTarget::ChapterName(i) => ( + "Chapter title".into(), + self.book.metadata.chapters[i].title.clone(), + ), + EditTarget::ChapterTime(i) => ( + "Chapter start · HH:MM:SS.mmm".into(), + model::time_string(self.book.metadata.chapters[i].start_ms), + ), + EditTarget::Setting(i) => { + let mut s = self.setting_values()[i].clone(); + if i == 0 && self.options.bitrate.is_none() { + s = "auto".into(); + } + let label = match i { + 1 => "Preset · speech / compact / mixed", + 2 => "Channels · keep / mono / stereo", + _ => SETTING_FIELDS[i], + }; + (label.into(), s) + } + }; + self.editor = Some(Editor { + cursor: text.len(), + title, + text, + target, + }); + } + fn commit_editor(&mut self) -> Result<()> { + let Some(e) = self.editor.as_ref() else { + return Ok(()); + }; + let text = e.text.clone(); + match e.target { + EditTarget::Book(i) => { + let m = &mut self.book.metadata; + match i { + 0 => m.title = text, + 1 => m.author = text, + 2 => m.narrator = text, + 3 => m.series = text, + 4 => m.series_part = text, + 5 => m.date = text, + 6 => m.genre = text, + 7 => m.description = text, + 8 => { + if text.trim().is_empty() { + m.cover = None; + } else { + let p = PathBuf::from(text); + ensure!(p.is_file(), "Cover file does not exist"); + m.cover = Some(p.canonicalize()?); + self.remove_cover = false; + } + } + 9 => { + self.options.output = if text.trim().is_empty() { + None + } else { + Some(PathBuf::from(text)) + } + } + 10 => { + self.remove_cover = parse_bool(&text)?; + if self.remove_cover { + m.cover = None; + } + } + _ => {} + } + } + EditTarget::ChapterName(i) => self.book.metadata.chapters[i].title = text, + EditTarget::ChapterTime(i) => { + let ms = model::parse_time(&text)?; + ensure!( + ms < self.book.duration_ms, + "Chapter starts beyond end of book" + ); + self.book.metadata.chapters[i].start_ms = ms; + self.book.metadata.chapters.sort_by_key(|c| c.start_ms); + } + EditTarget::Setting(i) => match i { + 0 => { + self.options.bitrate = if text == "auto" || text.is_empty() { + None + } else { + let n: u32 = text.parse()?; + ensure!((6..=510).contains(&n), "Bitrate must be 6..510"); + Some(n) + }; + } + 1 => { + self.options.preset = match text.to_lowercase().as_str() { + "speech" => Preset::Speech, + "compact" => Preset::Compact, + "mixed" => Preset::Mixed, + _ => anyhow::bail!("Choose speech, compact, or mixed"), + } + } + 2 => { + self.options.channels = match text.to_lowercase().as_str() { + "keep" => Channels::Keep, + "mono" => Channels::Mono, + "stereo" => Channels::Stereo, + _ => anyhow::bail!("Choose keep, mono, or stereo"), + } + } + 3 => { + let n = if text == "auto" { 0 } else { text.parse()? }; + ensure!(n <= 256, "Workers must be 1..256, or auto"); + self.options.jobs = n; + } + 4 => { + let n = text.parse()?; + ensure!(n <= 8192, "Reserve must be 0..8192 KiB"); + self.options.padding_kib = n; + } + 5 => self.options.overwrite = parse_bool(&text)?, + _ => {} + }, + } + self.editor = None; + self.dirty = true; + self.error = false; + self.message = "Edited. F5 applies your changes; q leaves the source untouched.".into(); + Ok(()) + } + fn save(&mut self) -> Result<()> { + model::validate_metadata(&self.book.metadata, Some(self.book.duration_ms))?; + let mut patch = MetadataPatch::from(&self.book.metadata); + patch.remove_cover = self.remove_cover; + let plan = app::plan_book(self.book.clone(), &self.options, patch)?; + backend::reset_cancel(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let mut progress = |p| { + let _ = tx.send(WorkerEvent::Progress(p)); + }; + let result = app::execute(&plan, &mut progress).map_err(|e| format!("{e:#}")); + let _ = tx.send(WorkerEvent::Done(result)); + }); + self.worker = Some(rx); + self.error = false; + self.message = "Starting… Ctrl+C cancels the operation.".into(); + Ok(()) + } + fn poll(&mut self) { + let messages: Vec<_> = self + .worker + .as_ref() + .map(|rx| rx.try_iter().collect()) + .unwrap_or_default(); + for message in messages { + match message { + WorkerEvent::Progress(p) => { + self.message = format!( + "{} · {:.0}s · Ctrl+C to cancel", + p.message, p.elapsed_seconds + ) + } + WorkerEvent::Done(result) => { + self.worker = None; + backend::reset_cancel(); + match result { + Ok(report) => { + self.last_output = Some(report.output.clone()); + match probe::book(std::slice::from_ref(&report.output)) { + Ok(book) => { + self.book = book; + self.options = ConversionOptions::default(); + self.selected = [0; 4]; + self.remove_cover = false; + self.dirty = false; + } + Err(e) => { + self.message = e.to_string(); + self.error = true; + continue; + } + } + self.error = false; + self.message = format!( + "Saved {} · {:.1}s · {} audio bytes rewritten", + report + .output + .file_name() + .unwrap_or_default() + .to_string_lossy(), + report.elapsed_seconds, + report.metadata.audio_bytes_written + ); + } + Err(error) => { + self.error = true; + self.message = error; + } + } + } + } + } + } + fn key(&mut self, key: KeyEvent) -> Result { + if key.kind == KeyEventKind::Release { + return Ok(false); + } + if self.worker.is_some() { + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + backend::cancel(); + self.message = "Cancelling…".into(); + } + return Ok(false); + } + if self.editor.is_some() { + if key.code == KeyCode::Esc { + self.editor = None; + return Ok(false); + } + if key.code == KeyCode::Enter { + self.commit_editor()?; + return Ok(false); + } + let e = self.editor.as_mut().unwrap(); + match key.code { + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + e.text.clear(); + e.cursor = 0; + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + e.text.insert(e.cursor, c); + e.cursor += c.len_utf8(); + } + KeyCode::Backspace if e.cursor > 0 => { + let previous = e.text[..e.cursor].char_indices().last().unwrap().0; + e.text.drain(previous..e.cursor); + e.cursor = previous; + } + KeyCode::Delete if e.cursor < e.text.len() => { + let next = e.cursor + e.text[e.cursor..].chars().next().unwrap().len_utf8(); + e.text.drain(e.cursor..next); + } + KeyCode::Left if e.cursor > 0 => { + e.cursor = e.text[..e.cursor].char_indices().last().unwrap().0 + } + KeyCode::Right if e.cursor < e.text.len() => { + e.cursor += e.text[e.cursor..].chars().next().unwrap().len_utf8() + } + KeyCode::Home => e.cursor = 0, + KeyCode::End => e.cursor = e.text.len(), + _ => {} + } + return Ok(false); + } + if self.quit_confirm { + self.quit_confirm = false; + return Ok(matches!(key.code, KeyCode::Char('y') | KeyCode::Char('Y'))); + } + match key.code { + KeyCode::Char('q')|KeyCode::Esc=>{if self.dirty{self.quit_confirm=true;}else{return Ok(true);}}, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL)=>{if self.dirty{self.quit_confirm=true;}else{return Ok(true);}}, + KeyCode::Tab=>self.tab=(self.tab+1)%4,KeyCode::BackTab=>self.tab=(self.tab+3)%4, + KeyCode::Char(c @ '1'..='4')=>self.tab=c as usize-'1' as usize, + KeyCode::Up|KeyCode::Char('k')=>self.selected[self.tab]=self.selected[self.tab].saturating_sub(1), + KeyCode::Down|KeyCode::Char('j')=>self.selected[self.tab]=(self.selected[self.tab]+1).min(self.count().saturating_sub(1)), + KeyCode::Home=>self.selected[self.tab]=0,KeyCode::End=>self.selected[self.tab]=self.count().saturating_sub(1), + KeyCode::Enter=>{let i=self.selected[self.tab];match self.tab{0=>self.open(EditTarget::Book(i)),1 if self.count()>0=>self.open(EditTarget::ChapterName(i)),3=>self.open(EditTarget::Setting(i)),_=>{}}}, + KeyCode::Char('t') if self.tab==1&&self.count()>0=>self.open(EditTarget::ChapterTime(self.selected[1])), + KeyCode::Char('a') if self.tab==1=>{let start=self.book.metadata.chapters.last().map(|c|c.start_ms+1000).unwrap_or(0);ensure!(start0=>{self.book.metadata.chapters.remove(self.selected[1]);self.selected[1]=self.selected[1].min(self.count().saturating_sub(1));self.dirty=true;}, + KeyCode::Char('r') if self.tab==1=>{self.book.rebuild_chapters();self.selected[1]=0;self.dirty=true;self.message="Chapters regenerated from the current source order.".into();}, + KeyCode::Char('J')|KeyCode::Char('K') if self.tab==2=>{let i=self.selected[2];let to=if key.code==KeyCode::Char('J'){(i+1).min(self.count()-1)}else{i.saturating_sub(1)};self.book.sources.swap(i,to);self.selected[2]=to;self.book.rebuild_chapters();self.dirty=true;self.message="Files reordered; chapter timings regenerated.".into();}, + KeyCode::F(5)=>self.save()?, + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL)=>self.save()?, + KeyCode::Char('?')=>self.message="Tab switches views · Enter edits · F5 saves/converts · Chapters: t time, a add, d delete, r regenerate · Files: Shift+J/K reorder".into(), + _=>{} + } + Ok(false) + } +} +fn parse_bool(s: &str) -> Result { + match s.to_lowercase().as_str() { + "yes" | "true" | "1" => Ok(true), + "no" | "false" | "0" => Ok(false), + _ => anyhow::bail!("Use yes or no"), + } +} +struct TerminalGuard; +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, crossterm::cursor::Show); + } +} +pub fn run(source: PathBuf) -> Result<()> { + let book = probe::book(&[source])?; + let mut state = State::new(book); + enable_raw_mode()?; + let guard = TerminalGuard; + execute!(io::stdout(), EnterAlternateScreen)?; + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + terminal.clear()?; + loop { + state.poll(); + terminal.draw(|f| draw(f, &state))?; + if event::poll(Duration::from_millis(100))? + && let Event::Key(key) = event::read()? + { + match state.key(key) { + Ok(true) => break, + Ok(false) => {} + Err(e) => { + state.error = true; + state.message = format!("{e:#}"); + } + } + } + } + drop(terminal); + drop(guard); + if let Some(path) = state.last_output { + println!("Saved {}", path.display()); + } + Ok(()) +} +fn panel(title: &str) -> Block<'_> { + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Rgb(57, 69, 82))) + .title(Line::from(format!(" {title} ")).fg(MUTED)) +} +fn draw(f: &mut Frame, state: &State) { + let area = f.area(); + f.render_widget( + Block::default().style(Style::default().bg(BG).fg(Color::Rgb(229, 235, 240))), + area, + ); + if area.width < 54 || area.height < 16 { + f.render_widget( + Paragraph::new("Please enlarge the terminal to at least 54 × 16.\nPress q to close.") + .wrap(Wrap { trim: false }), + area, + ); + return; + } + let areas = Layout::vertical([ + Constraint::Length(4), + Constraint::Length(3), + Constraint::Min(4), + Constraint::Length(4), + Constraint::Length(1), + ]) + .margin(1) + .split(area); + let title = Line::from(vec![ + Span::styled(" OPUSAB ", Style::default().fg(BG).bg(ACCENT).bold()), + Span::raw(" "), + Span::styled(&state.book.metadata.title, Style::default().bold()), + Span::styled( + if state.dirty { " • edited" } else { "" }, + Style::default().fg(GOLD), + ), + ]); + let summary = format!( + "{} · {} · {} files · {} chapters", + state.book.metadata.author, + model::time_string(state.book.duration_ms), + state.book.sources.len(), + state.book.metadata.chapters.len() + ); + let mode = if state.worker.is_some() { + "Working…" + } else if state.book.sources.len() == 1 + && state.book.sources[0].codec == "opus" + && state.options.bitrate.is_none() + && state.options.channels == Channels::Keep + && state.options.preset == Preset::Speech + { + "Existing Opus · audio kept · save edits in place" + } else { + "Prepare an audiobook · encode to one Opus file" + }; + f.render_widget( + Paragraph::new(vec![ + title, + Line::from(summary).fg(MUTED), + Line::from(mode).fg(ACCENT), + ]), + areas[0], + ); + f.render_widget( + Tabs::new(["1 Book", "2 Chapters", "3 Files", "4 Encoding"]) + .select(state.tab) + .style(Style::default().fg(MUTED)) + .highlight_style(Style::default().fg(ACCENT).bold()) + .block( + Block::default() + .borders(Borders::BOTTOM) + .border_style(Style::default().fg(MUTED)), + ), + areas[1], + ); + let mut selection = TableState::default().with_selected(if state.count() > 0 { + Some(state.selected[state.tab]) + } else { + None + }); + match state.tab { + 0 | 3 => { + let (labels, values) = if state.tab == 0 { + (BOOK_FIELDS.to_vec(), state.book_values()) + } else { + (SETTING_FIELDS.to_vec(), state.setting_values()) + }; + let rows = labels.into_iter().zip(values).map(|(k, v)| { + Row::new(vec![ + k.to_string(), + if v.is_empty() { "—".into() } else { v }, + ]) + .style(Style::default().fg(Color::Rgb(220, 229, 235))) + }); + let table = Table::new( + rows, + [ + Constraint::Length(if state.tab == 0 { 16 } else { 26 }), + Constraint::Min(20), + ], + ) + .row_highlight_style(Style::default().bg(SELECT).fg(ACCENT)) + .highlight_symbol("› ") + .block(panel("Enter to edit")); + f.render_stateful_widget(table, areas[2], &mut selection); + } + 1 => { + let rows = state + .book + .metadata + .chapters + .iter() + .enumerate() + .map(|(i, c)| { + Row::new(vec![ + (i + 1).to_string(), + model::time_string(c.start_ms), + c.title.clone(), + ]) + }); + let table = Table::new( + rows, + [ + Constraint::Length(5), + Constraint::Length(15), + Constraint::Min(12), + ], + ) + .header(Row::new(["#", "Start", "Title"]).style(Style::default().fg(MUTED))) + .row_highlight_style(Style::default().bg(SELECT).fg(ACCENT)) + .highlight_symbol("› ") + .block(panel( + "Enter rename · t time · a add · d delete · r regenerate", + )); + f.render_stateful_widget(table, areas[2], &mut selection); + } + 2 => { + let rows = state.book.sources.iter().enumerate().map(|(i, s)| { + Row::new(vec![ + (i + 1).to_string(), + s.path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + model::time_string(s.duration_ms), + format!("{} / {}k", s.codec, s.bitrate_kbps), + ]) + }); + let table = Table::new( + rows, + [ + Constraint::Length(4), + Constraint::Min(15), + Constraint::Length(14), + Constraint::Length(13), + ], + ) + .header(Row::new(["#", "File", "Duration", "Codec"]).style(Style::default().fg(MUTED))) + .row_highlight_style(Style::default().bg(SELECT).fg(ACCENT)) + .highlight_symbol("› ") + .block(panel( + "Shift+J / Shift+K reorder · chapters follow file order", + )); + f.render_stateful_widget(table, areas[2], &mut selection); + } + _ => {} + } + let msg = if state.quit_confirm { + "Discard unsaved edits? y = discard and quit · any other key = keep editing" + } else { + &state.message + }; + f.render_widget( + Paragraph::new(msg) + .style(Style::default().fg(if state.error { + Color::LightRed + } else if state.quit_confirm { + GOLD + } else { + MUTED + })) + .wrap(Wrap { trim: false }) + .block(panel(if state.worker.is_some() { + "Converting" + } else { + "Status" + })), + areas[3], + ); + f.render_widget( + Paragraph::new("↑↓ move Tab view Enter edit F5 / Ctrl+S save q quit ? help") + .style(Style::default().fg(ACCENT)), + areas[4], + ); + if let Some(e) = &state.editor { + let rect = Rect::new(area.x + 3, area.y + area.height / 2 - 2, area.width - 6, 5); + f.render_widget(Clear, rect); + let max = rect.width.saturating_sub(4) as usize; + let mut start = 0; + while e.text[start..e.cursor].width() >= max && start < e.cursor { + start += e.text[start..].chars().next().unwrap().len_utf8(); + } + let visible = &e.text[start..]; + f.render_widget( + Paragraph::new(vec![ + Line::from(visible), + Line::from("Enter save · Esc cancel · Ctrl+U clear").fg(MUTED), + ]) + .style(Style::default().bg(BG).fg(Color::White)) + .block(panel(&e.title)), + rect, + ); + f.set_cursor_position(( + rect.x + 1 + e.text[start..e.cursor].width() as u16, + rect.y + 1, + )); + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn state() -> State { + let book = Book { + schema_version: 1, + input_directory: None, + sources: vec![], + metadata: crate::model::Metadata { + title: "Example".into(), + chapters: vec![Chapter { + start_ms: 0, + title: "Opening".into(), + }], + ..Default::default() + }, + duration_ms: 60000, + warnings: vec![], + }; + let mut s = State::new(book); + s.options.output = Some(PathBuf::from("/tmp/book.opus")); + s + } + #[test] + fn unicode_edit_and_cancel() { + let mut s = state(); + s.open(EditTarget::ChapterName(0)); + s.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)) + .unwrap(); + s.key(KeyEvent::new(KeyCode::Char('Ж'), KeyModifiers::NONE)) + .unwrap(); + s.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)) + .unwrap(); + assert_eq!(s.book.metadata.chapters[0].title, "Ж"); + s.open(EditTarget::ChapterName(0)); + s.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)) + .unwrap(); + assert!(s.dirty); + } + #[test] + fn render_small_and_large() { + let s = state(); + for (w, h) in [(54, 16), (80, 24), (120, 40)] { + let b = ratatui::backend::TestBackend::new(w, h); + let mut t = Terminal::new(b).unwrap(); + t.draw(|f| draw(f, &s)).unwrap(); + let text = t + .backend() + .buffer() + .content + .iter() + .map(|c| c.symbol()) + .collect::(); + assert!(text.contains("OPUSAB")); + assert!(text.contains("Example")); + } + } +} diff --git a/tests/backend.rs b/tests/backend.rs new file mode 100644 index 0000000..f4ef3b7 --- /dev/null +++ b/tests/backend.rs @@ -0,0 +1,31 @@ +//! Optional integration test against the real audio backend. +use opusab::{app, model::Chapter, probe}; +use std::fs; + +#[test] +#[ignore = "requires freaccmd and ffprobe; run cargo test --test backend -- --ignored"] +fn parallel_encoder_retains_fractional_chapter_times_and_manual_names() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("01 chapter's café.opus"); + let b = dir.path().join("02 chapter.opus"); + fs::write(&a, include_bytes!("fixtures/tone.opus")).unwrap(); + fs::write(&b, include_bytes!("fixtures/tone.opus")).unwrap(); + let output = dir.path().join("merged.opus"); + let options = app::ConversionOptions { + output: Some(output.clone()), + jobs: 2, + ..Default::default() + }; + let mut plan = app::make_plan(&[a, b], &options).unwrap(); + plan.book.metadata.chapters[1].title = "A custom name".into(); + app::execute(&plan, &mut |_| {}).unwrap(); + let book = probe::book(&[output]).unwrap(); + assert!(book.duration_ms.abs_diff(500) <= 20); + assert_eq!( + book.metadata.chapters[1], + Chapter { + start_ms: 250, + title: "A custom name".into(), + } + ); +} diff --git a/tests/cli.rs b/tests/cli.rs new file mode 100644 index 0000000..4152dfc --- /dev/null +++ b/tests/cli.rs @@ -0,0 +1,135 @@ +use opusab::{ + app::{self, ConversionOptions}, + model::{Chapter, MetadataPatch}, + probe, +}; +use std::{fs, process::Command}; +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_opusab")) +} +#[test] +fn metadata_commands_need_no_backend() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("book.opus"); + fs::write(&p, include_bytes!("fixtures/tone.opus")).unwrap(); + let out = bin() + .env("PATH", "") + .args(["tags", "show"]) + .arg(&p) + .arg("--json") + .output() + .unwrap(); + assert!(out.status.success()); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert_eq!(v["schema_version"], 1); + assert_eq!(v["metadata"]["title"], "Tone"); +} + +#[cfg(unix)] +#[test] +fn installed_symlink_finds_its_adjacent_runtime() { + let dir = tempfile::tempdir().unwrap(); + let bundle = dir.path().join("application"); + fs::create_dir_all(bundle.join("runtime/bin")).unwrap(); + let exe = bundle.join("opusab"); + fs::copy(env!("CARGO_BIN_EXE_opusab"), &exe).unwrap(); + for name in ["freaccmd", "ffprobe", "ffmpeg"] { + fs::write(bundle.join("runtime/bin").join(name), b"fixture").unwrap(); + } + let link = dir.path().join("installed-opusab"); + std::os::unix::fs::symlink(exe, &link).unwrap(); + let output = Command::new(link) + .env_remove("OPUSAB_RUNTIME") + .env_remove("OPUSAB_FREACCMD") + .env_remove("OPUSAB_FFPROBE") + .env_remove("OPUSAB_FFMPEG") + .env("PATH", "") + .args(["doctor", "--json"]) + .output() + .unwrap(); + assert!(output.status.success()); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + for tool in value["tools"].as_array().unwrap() { + assert!( + std::path::Path::new(tool["path"].as_str().unwrap()) + .starts_with(bundle.canonicalize().unwrap()) + ); + } +} +#[test] +fn agent_errors_are_json_and_do_not_open_tui() { + let out = bin() + .args(["convert", "--jobs", "invalid", "--json"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2)); + let e: serde_json::Value = serde_json::from_slice(&out.stderr).unwrap(); + assert_eq!(e["error"]["code"], "invalid_arguments"); + let out = bin().args(["some-book.m4b", "--json"]).output().unwrap(); + let e: serde_json::Value = serde_json::from_slice(&out.stderr).unwrap(); + assert_eq!(e["error"]["code"], "terminal_required"); +} +#[test] +fn existing_opus_keeps_audio_unless_explicitly_reencoded() { + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("book.opus"); + fs::write(&p, include_bytes!("fixtures/tone.opus")).unwrap(); + let plan = app::make_plan(std::slice::from_ref(&p), &ConversionOptions::default()).unwrap(); + assert_eq!(plan.operation, "metadata"); + assert_eq!(plan.output, p.canonicalize().unwrap()); + let options = ConversionOptions { + bitrate: Some(32), + ..Default::default() + }; + let plan = app::make_plan(std::slice::from_ref(&p), &options).unwrap(); + assert_eq!(plan.operation, "encode"); + assert_ne!(plan.output, p); + let options = ConversionOptions { + bitrate: Some(32), + output: Some(p.clone()), + overwrite: true, + ..Default::default() + }; + assert!(app::make_plan(&[p], &options).is_err()); +} +#[test] +fn json_patch_rejects_typos_and_resolves_cover_relative_to_json() { + let dir = tempfile::tempdir().unwrap(); + let patch = dir.path().join("metadata.json"); + fs::write(&patch, r#"{"autor":"typo"}"#).unwrap(); + assert!(app::read_patch(&patch).is_err()); + fs::write( + &patch, + r#"{"schema_version":1,"metadata":{"title":"New","cover":"cover.png"}}"#, + ) + .unwrap(); + let p = app::read_patch(&patch).unwrap(); + assert_eq!(p.cover.unwrap(), dir.path().join("cover.png")); +} +#[test] +fn generated_and_edited_chapter_times_keep_millisecond_precision() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.opus"); + let b = dir.path().join("b.opus"); + fs::write(&a, include_bytes!("fixtures/tone.opus")).unwrap(); + fs::write(&b, include_bytes!("fixtures/tone.opus")).unwrap(); + let mut book = probe::book(&[a, b]).unwrap(); + assert_eq!(book.metadata.chapters[1].start_ms, 250); + book.metadata.chapters = vec![ + Chapter { + start_ms: 0, + title: "One".into(), + }, + Chapter { + start_ms: 110, + title: "Manual timing".into(), + }, + ]; + let plan = app::plan_book( + book, + &ConversionOptions::default(), + MetadataPatch::default(), + ) + .unwrap(); + assert_eq!(plan.book.metadata.chapters[1].start_ms, 110); +} diff --git a/tests/fixtures/tone.opus b/tests/fixtures/tone.opus new file mode 100644 index 0000000..ec37d0e Binary files /dev/null and b/tests/fixtures/tone.opus differ diff --git a/tests/metadata.rs b/tests/metadata.rs new file mode 100644 index 0000000..e808b4b --- /dev/null +++ b/tests/metadata.rs @@ -0,0 +1,250 @@ +use fs2::FileExt; +use opusab::{ + model::{Chapter, MetadataPatch}, + ogg, +}; +use std::{ + fs::{self, File}, + io::{Seek, SeekFrom, Write}, + path::Path, +}; + +fn fixture(dir: &Path) -> std::path::PathBuf { + let p = dir.join("book.opus"); + fs::write(&p, include_bytes!("fixtures/tone.opus")).unwrap(); + p +} +fn header(p: &Path) -> ogg::Header { + ogg::read_header(&mut File::open(p).unwrap()).unwrap() +} +fn audio_packets(p: &Path) -> Vec> { + let mut file = File::open(p).unwrap(); + let h = ogg::read_header(&mut file).unwrap(); + file.seek(SeekFrom::Start(h.end)).unwrap(); + let mut result = Vec::new(); + let mut packet = Vec::new(); + while file.stream_position().unwrap() < file.metadata().unwrap().len() { + let page = ogg::read_page(&mut file).unwrap(); + let mut at = 0; + for n in &page.bytes[27..page.header_len] { + packet.extend_from_slice(&page.body()[at..at + *n as usize]); + at += *n as usize; + if *n < 255 { + result.push(std::mem::take(&mut packet)); + } + } + } + assert!(packet.is_empty()); + result +} +#[test] +fn in_place_keeps_inode_length_offsets_and_audio_bytes() { + let dir = tempfile::tempdir().unwrap(); + let p = fixture(dir.path()); + ogg::rewrite( + &p, + &p, + &MetadataPatch::default(), + ogg::DEFAULT_PADDING, + true, + ) + .unwrap(); + let before = fs::read(&p).unwrap(); + let h = header(&p); + #[cfg(unix)] + let inode = { + use std::os::unix::fs::MetadataExt; + fs::metadata(&p).unwrap().ino() + }; + let patch = MetadataPatch { + title: Some("Прощай, оружие — café 🎧".into()), + author: Some("Ernest Hemingway".into()), + chapters: Some(vec![ + Chapter { + start_ms: 0, + title: "Start".into(), + }, + Chapter { + start_ms: 100, + title: "Second".into(), + }, + ]), + ..Default::default() + }; + let report = ogg::edit(&p, &patch).unwrap(); + let after = fs::read(&p).unwrap(); + assert_eq!(before.len(), after.len()); + assert_eq!(h.end, header(&p).end); + assert_eq!(before[h.end as usize..], after[h.end as usize..]); + assert_eq!(report.audio_bytes_written, 0); + assert!(report.bytes_written <= h.end); + assert_eq!(header(&p).metadata().title, patch.title.unwrap()); + assert_eq!(header(&p).metadata().chapters.len(), 2); + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + assert_eq!(inode, fs::metadata(&p).unwrap().ino()); + } + assert!(!ogg::journal_path(&p).exists()); +} +#[test] +fn no_room_is_an_error_without_any_write() { + let dir = tempfile::tempdir().unwrap(); + let p = fixture(dir.path()); + let before = fs::read(&p).unwrap(); + let e = ogg::edit( + &p, + &MetadataPatch { + description: Some("x".repeat(100000)), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(e.to_string().contains("Metadata needs")); + assert_eq!(before, fs::read(&p).unwrap()); + assert!(!ogg::journal_path(&p).exists()); +} +#[test] +fn reserve_preserves_compressed_packets_and_duration() { + let dir = tempfile::tempdir().unwrap(); + let p = fixture(dir.path()); + let packets = audio_packets(&p); + let duration = ogg::duration_ms(&mut File::open(&p).unwrap(), &header(&p)).unwrap(); + ogg::rewrite(&p, &p, &MetadataPatch::default(), 300000, true).unwrap(); + assert_eq!(packets, audio_packets(&p)); + assert_eq!( + duration, + ogg::duration_ms(&mut File::open(&p).unwrap(), &header(&p)).unwrap() + ); + assert_eq!(header(&p).capacity - header(&p).used, 300000); +} +#[test] +fn page_lacing_handles_exact_multiples_and_empty_last_segment() { + for n in [0, 1, 254, 255, 256, 65024, 65025, 65026, 130050] { + let packet = vec![42; n]; + let pages = ogg::comment_pages(&packet, 123); + let mut roundtrip = Vec::new(); + for (i, p) in pages.iter().enumerate() { + assert_eq!(p.sequence(), i as u32 + 1); + let parsed = ogg::read_page(&mut std::io::Cursor::new(&p.bytes)).unwrap(); + roundtrip.extend_from_slice(parsed.body()); + if i == pages.len() - 1 { + assert!(*p.bytes[27..p.header_len].last().unwrap() < 255); + } else { + assert!(p.bytes[27..p.header_len].iter().all(|b| *b == 255)); + } + } + assert_eq!(packet, roundtrip); + } +} +#[test] +fn custom_repeated_tags_survive_other_edits() { + let dir = tempfile::tempdir().unwrap(); + let p = fixture(dir.path()); + ogg::rewrite(&p, &p, &MetadataPatch::default(), 4096, true).unwrap(); + let mut extra = std::collections::BTreeMap::new(); + extra.insert( + "MY_CUSTOM_TAG".into(), + vec!["first".into(), "second".into()], + ); + ogg::edit( + &p, + &MetadataPatch { + extra: Some(extra.clone()), + ..Default::default() + }, + ) + .unwrap(); + ogg::edit( + &p, + &MetadataPatch { + title: Some("New title".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(header(&p).tags()["MY_CUSTOM_TAG"], extra["MY_CUSTOM_TAG"]); +} +#[test] +fn empty_patch_is_no_op() { + let dir = tempfile::tempdir().unwrap(); + let p = fixture(dir.path()); + let before = fs::read(&p).unwrap(); + let result = ogg::edit(&p, &MetadataPatch::default()).unwrap(); + assert_eq!(result.bytes_written, 0); + assert_eq!(fs::read(p).unwrap(), before); +} +#[test] +fn simultaneous_edits_fail_instead_of_racing() { + let dir = tempfile::tempdir().unwrap(); + let p = fixture(dir.path()); + let file = File::open(&p).unwrap(); + file.lock_exclusive().unwrap(); + let e = ogg::edit(&p, &MetadataPatch::default()).unwrap_err(); + assert!(e.to_string().contains("another process")); + let e = ogg::rewrite(&p, &p, &MetadataPatch::default(), 4096, true).unwrap_err(); + assert!(e.to_string().contains("another process")); +} +#[test] +fn corrupt_page_and_malformed_tags_are_rejected() { + let dir = tempfile::tempdir().unwrap(); + let p = fixture(dir.path()); + let h = header(&p); + let mut bytes = fs::read(&p).unwrap(); + bytes[h.pages[0].offset as usize + h.pages[0].header_len + 9] ^= 1; + fs::write(&p, bytes).unwrap(); + assert!(ogg::read_header(&mut File::open(&p).unwrap()).is_err()); +} +#[test] +fn interrupted_edit_can_restore_header_without_touching_audio() { + use base64::{Engine, engine::general_purpose::STANDARD as B64}; + use sha2::{Digest, Sha256}; + let dir = tempfile::tempdir().unwrap(); + let p = fixture(dir.path()); + ogg::rewrite(&p, &p, &MetadataPatch::default(), 4096, true).unwrap(); + let before = fs::read(&p).unwrap(); + let h = header(&p); + let mut file = fs::OpenOptions::new() + .read(true) + .write(true) + .open(&p) + .unwrap(); + #[cfg(unix)] + let identity = { + use std::os::unix::fs::MetadataExt; + let m = file.metadata().unwrap(); + format!("{}:{}", m.dev(), m.ino()) + }; + #[cfg(not(unix))] + let identity = format!("{:?}", file.metadata().unwrap().created().unwrap()); + let page = &h.pages[0]; + let journal = serde_json::json!({"version":1,"file_len":before.len(),"identity":identity,"head_hash":format!("{:x}",Sha256::digest(&h.head.bytes)),"header_end":h.end,"pages":[{"offset":page.offset,"before":B64.encode(&page.bytes),"after":B64.encode(&page.bytes)}]}); + fs::write(ogg::journal_path(&p), serde_json::to_vec(&journal).unwrap()).unwrap(); + file.seek(SeekFrom::Start(page.offset + page.header_len as u64 + 20)) + .unwrap(); + file.write_all(b"torn write").unwrap(); + file.sync_all().unwrap(); + drop(file); + assert!( + ogg::edit(&p, &MetadataPatch::default()) + .unwrap_err() + .to_string() + .contains("interrupted") + ); + let report = ogg::recover(&p).unwrap(); + assert_eq!(report.audio_bytes_written, 0); + assert_eq!(fs::read(&p).unwrap(), before); + assert!(!ogg::journal_path(&p).exists()); +} +#[test] +fn truncated_audio_does_not_replace_destination() { + let dir = tempfile::tempdir().unwrap(); + let p = fixture(dir.path()); + let h = header(&p); + let f = fs::OpenOptions::new().write(true).open(&p).unwrap(); + f.set_len(h.end + 20).unwrap(); + let dest = dir.path().join("existing.opus"); + fs::write(&dest, b"keep me").unwrap(); + assert!(ogg::rewrite(&p, &dest, &MetadataPatch::default(), 10, true).is_err()); + assert_eq!(fs::read(dest).unwrap(), b"keep me"); +}