commit dddac108fe9bfb9bb0234c5cb8f64423a89541fc Author: 44r0n7 <44r0n7+gitea@pm.me> Date: Wed Dec 31 22:07:42 2025 -0500 Initial vid-repair scaffold diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c5aad6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.local/ +/target/ +**/*.log diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9f2990a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,967 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +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.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + +[[package]] +name = "fs-err" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +dependencies = [ + "autocfg", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.10.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "proc-macro2" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[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 = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.148" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21f182278bf2d2bcb3c88b1b08a37df029d71ce3d3ae26168e3c653b213b99d4" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vid-repair" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "rayon", + "serde", + "serde_json", + "toml", + "vid-repair-core", +] + +[[package]] +name = "vid-repair-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "directories", + "fs-err", + "globset", + "notify", + "rayon", + "regex", + "serde", + "serde_json", + "tempfile", + "thiserror", + "toml", + "walkdir", +] + +[[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 = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[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.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "zmij" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac060176f7020d62c3bcc1cdbcec619d54f48b07ad1963a3f80ce7a0c17755f" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8f0166d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[workspace] +resolver = "2" +members = [ + "vid-repair", + "vid-repair-core", +] + +[workspace.package] +edition = "2021" +license = "MIT" + +[workspace.dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +directories = "5" +fs-err = "2" +globset = "0.4" +notify = "6" +rayon = "1" +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tempfile = "3" +thiserror = "1" +toml = "0.8" +walkdir = "2" diff --git a/rulesets/core.toml b/rulesets/core.toml new file mode 100644 index 0000000..330bc98 --- /dev/null +++ b/rulesets/core.toml @@ -0,0 +1,170 @@ +[[rule]] +id = "MOOV_ATOM_NOT_FOUND" +domain = "container.mp4" +severity = "severe" +confidence = 0.9 +fix_tier = "reencode" +stop_scan = true +patterns = ["(?i)moov atom not found"] +notes = "MP4/MOV metadata is missing; file may be incomplete." + +[[rule]] +id = "COULD_NOT_FIND_CODEC_PARAMS" +domain = "probe" +severity = "medium" +confidence = 0.6 +fix_tier = "none" +stop_scan = false +patterns = ["(?i)could not find codec parameters"] +notes = "Insufficient probe data; consider higher analyzeduration/probesize." + +[[rule]] +id = "INVALID_DATA_FOUND" +domain = "decode" +severity = "high" +confidence = 0.7 +fix_tier = "reencode" +stop_scan = false +patterns = ["(?i)Invalid data found when processing input"] +notes = "Decoder encountered invalid data; may indicate corruption." + +[[rule]] +id = "FILE_ENDED_PREMATURELY" +domain = "decode" +severity = "high" +confidence = 0.7 +fix_tier = "reencode" +stop_scan = false +patterns = ["(?i)File ended prematurely"] +notes = "File appears truncated." + +[[rule]] +id = "INVALID_NAL_UNIT_SIZE" +domain = "codec.h264" +severity = "severe" +confidence = 0.85 +fix_tier = "reencode" +stop_scan = true +patterns = ["(?i)Invalid NAL unit size", "(?i)Error splitting the input into NAL units"] +notes = "H.264/HEVC bitstream corruption detected." + +[[rule]] +id = "MISSING_PICTURE_ACCESS_UNIT" +domain = "codec.h264" +severity = "high" +confidence = 0.7 +fix_tier = "reencode" +stop_scan = false +patterns = ["(?i)missing picture in access unit"] +notes = "Missing picture in access unit; possible corruption." + +[[rule]] +id = "PPS_ID_OUT_OF_RANGE" +domain = "codec.hevc" +severity = "high" +confidence = 0.75 +fix_tier = "reencode" +stop_scan = false +patterns = ["(?i)PPS id out of range", "(?i)Error parsing NAL unit"] +notes = "HEVC parameter set corruption detected." + +[[rule]] +id = "SEI_TRUNCATED" +domain = "codec.h264" +severity = "low" +confidence = 0.5 +fix_tier = "none" +stop_scan = false +patterns = ["(?i)SEI type .* truncated"] +notes = "SEI message truncated; often benign unless paired with decode errors." + +[[rule]] +id = "NON_MONOTONOUS_DTS" +domain = "timestamp" +severity = "medium" +confidence = 0.6 +fix_tier = "remux" +stop_scan = false +patterns = ["(?i)Non-monotonous DTS", "(?i)non monotonically increasing dts"] +notes = "Timestamp discontinuity detected." + +[[rule]] +id = "DTS_DISCONTINUITY" +domain = "timestamp" +severity = "medium" +confidence = 0.6 +fix_tier = "remux" +stop_scan = false +patterns = ["(?i)DTS discontinuity"] +notes = "Timestamp discontinuity detected." + +[[rule]] +id = "PES_PACKET_SIZE_MISMATCH" +domain = "transport.ts" +severity = "medium" +confidence = 0.6 +fix_tier = "remux" +stop_scan = false +patterns = ["(?i)PES packet size mismatch", "(?i)Packet corrupt"] +notes = "Transport stream corruption detected." + +[[rule]] +id = "CONTINUITY_COUNTER_ERROR" +domain = "transport.ts" +severity = "low" +confidence = 0.4 +fix_tier = "none" +stop_scan = false +patterns = ["(?i)continuity counter error"] +notes = "Continuity counter errors can be benign in segmented streams." + +[[rule]] +id = "AAC_ADTS_HEADER_ERROR" +domain = "codec.aac" +severity = "medium" +confidence = 0.7 +fix_tier = "reencode" +stop_scan = false +patterns = ["(?i)Error parsing ADTS frame header", "(?i)Error decoding AAC frame header"] +notes = "AAC bitstream errors detected." + +[[rule]] +id = "MP3_HEADER_MISSING" +domain = "codec.mp3" +severity = "medium" +confidence = 0.6 +fix_tier = "reencode" +stop_scan = false +patterns = ["(?i)Header missing"] +notes = "MP3 framing errors detected." + +[[rule]] +id = "AC3_FRAME_SYNC_ERROR" +domain = "codec.ac3" +severity = "medium" +confidence = 0.6 +fix_tier = "reencode" +stop_scan = false +patterns = ["(?i)frame sync error"] +notes = "AC-3 frame sync error detected." + +[[rule]] +id = "EBML_HEADER_PARSING_FAILED" +domain = "container.mkv" +severity = "high" +confidence = 0.7 +fix_tier = "reencode" +stop_scan = false +patterns = ["(?i)EBML header parsing failed"] +notes = "Matroska/WebM header parsing failed; file may be truncated." + +[[rule]] +id = "FASTSTART_RECOMMENDED" +domain = "container.mp4" +severity = "low" +confidence = 0.4 +fix_tier = "remux" +stop_scan = false +action = "faststart" +patterns = ["(?i)faststart"] +notes = "MP4 likely has moov atom at end; faststart remux recommended." diff --git a/vid-repair-core/.gitignore b/vid-repair-core/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/vid-repair-core/.gitignore @@ -0,0 +1 @@ +/target diff --git a/vid-repair-core/Cargo.toml b/vid-repair-core/Cargo.toml new file mode 100644 index 0000000..0d2e48e --- /dev/null +++ b/vid-repair-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "vid-repair-core" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = { workspace = true } +directories = { workspace = true } +fs-err = { workspace = true } +globset = { workspace = true } +notify = { workspace = true } +rayon = { workspace = true } +regex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +toml = { workspace = true } +walkdir = { workspace = true } diff --git a/vid-repair-core/src/config/defaults.rs b/vid-repair-core/src/config/defaults.rs new file mode 100644 index 0000000..637bd4d --- /dev/null +++ b/vid-repair-core/src/config/defaults.rs @@ -0,0 +1,47 @@ +pub const DEFAULT_CONFIG: &str = r#"# Vid-Repair configuration +# Lines beginning with '#' are comments. +# Values here are defaults; CLI flags override config. + +ffmpeg_path = "ffmpeg" +ffprobe_path = "ffprobe" + +[scan] +# quick|standard|deep - deep runs full decode (slowest, most thorough) +depth = "deep" +# If quick/standard find suspicious signals, auto-escalate to deep. +auto_escalate = true +# Follow symlinks while scanning directories. +follow_symlinks = false +# Recurse into subdirectories when scanning directories. +recursive = true +# Extensions to include (lowercase, no dot). Add/remove as needed. +include_ext = ["mp4","m4v","mov","mkv","avi","wmv","flv","webm","mpg","mpeg","m2ts","mts","ts","3gp","3g2","ogv","vob","f4v"] +# Glob patterns to exclude. +exclude = ["**/.git/**", "**/node_modules/**"] + +[repair] +# safe|aggressive - aggressive allows re-encode when corruption is found. +policy = "safe" +# Empty means in-place replacement with temp + atomic rename. +output_dir = "" +# If true (and in-place), rename original to *.original.* after successful fix. +keep_original = false + +[report] +# Emit JSON instead of human-readable text. +json = false +# Pretty-print JSON output. +pretty = true + +[performance] +# 0 = auto (num CPU threads). Higher values spawn more concurrent scans. +jobs = 0 + +[watch] +# Enable watch mode (monitor and process files as they settle). +enabled = false +# Wait for no changes for this many seconds before processing. +settle_seconds = 10 +# Ignore these extensions during watch (lowercase, no dot). +ignore_ext = ["part","crdownload","partial","tmp","download"] +"#; diff --git a/vid-repair-core/src/config/mod.rs b/vid-repair-core/src/config/mod.rs new file mode 100644 index 0000000..ed01729 --- /dev/null +++ b/vid-repair-core/src/config/mod.rs @@ -0,0 +1,299 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use directories::ProjectDirs; +use fs_err as fs; +use serde::{Deserialize, Serialize}; + +mod defaults; + +pub use defaults::DEFAULT_CONFIG; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + #[serde(default)] + pub ffmpeg_path: String, + #[serde(default)] + pub ffprobe_path: String, + #[serde(default)] + pub scan: ScanConfig, + #[serde(default)] + pub repair: RepairConfig, + #[serde(default)] + pub report: ReportConfig, + #[serde(default)] + pub performance: PerformanceConfig, + #[serde(default)] + pub watch: WatchConfig, +} + +impl Default for Config { + fn default() -> Self { + Self { + ffmpeg_path: "ffmpeg".to_string(), + ffprobe_path: "ffprobe".to_string(), + scan: ScanConfig::default(), + repair: RepairConfig::default(), + report: ReportConfig::default(), + performance: PerformanceConfig::default(), + watch: WatchConfig::default(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScanConfig { + #[serde(default)] + pub depth: ScanDepth, + #[serde(default)] + pub auto_escalate: bool, + #[serde(default)] + pub follow_symlinks: bool, + #[serde(default)] + pub recursive: bool, + #[serde(default)] + pub include_ext: Vec, + #[serde(default)] + pub exclude: Vec, +} + +impl Default for ScanConfig { + fn default() -> Self { + Self { + depth: ScanDepth::Deep, + auto_escalate: true, + follow_symlinks: false, + recursive: true, + include_ext: vec![ + "mp4", "m4v", "mov", "mkv", "avi", "wmv", "flv", "webm", "mpg", + "mpeg", "m2ts", "mts", "ts", "3gp", "3g2", "ogv", "vob", "f4v", + ] + .into_iter() + .map(|s| s.to_string()) + .collect(), + exclude: vec!["**/.git/**".to_string(), "**/node_modules/**".to_string()], + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ScanDepth { + Quick, + Standard, + Deep, +} + +impl Default for ScanDepth { + fn default() -> Self { + ScanDepth::Deep + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepairConfig { + #[serde(default)] + pub policy: FixPolicy, + #[serde(default)] + pub output_dir: String, + #[serde(default)] + pub keep_original: bool, +} + +impl Default for RepairConfig { + fn default() -> Self { + Self { + policy: FixPolicy::Safe, + output_dir: String::new(), + keep_original: false, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum FixPolicy { + Safe, + Aggressive, +} + +impl Default for FixPolicy { + fn default() -> Self { + FixPolicy::Safe + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReportConfig { + #[serde(default)] + pub json: bool, + #[serde(default)] + pub pretty: bool, +} + +impl Default for ReportConfig { + fn default() -> Self { + Self { + json: false, + pretty: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PerformanceConfig { + #[serde(default)] + pub jobs: usize, +} + +impl Default for PerformanceConfig { + fn default() -> Self { + Self { jobs: 0 } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WatchConfig { + #[serde(default)] + pub enabled: bool, + #[serde(default)] + pub settle_seconds: u64, + #[serde(default)] + pub ignore_ext: Vec, +} + +impl Default for WatchConfig { + fn default() -> Self { + Self { + enabled: false, + settle_seconds: 10, + ignore_ext: vec!["part", "crdownload", "partial", "tmp", "download"] + .into_iter() + .map(|s| s.to_string()) + .collect(), + } + } +} + +#[derive(Debug, Default, Clone)] +pub struct ConfigOverrides { + pub ffmpeg_path: Option, + pub ffprobe_path: Option, + pub scan_depth: Option, + pub scan_recursive: Option, + pub policy: Option, + pub output_dir: Option, + pub keep_original: Option, + pub json: Option, + pub jobs: Option, + pub watch: Option, +} + +impl Config { + pub fn apply_overrides(&mut self, overrides: &ConfigOverrides) { + if let Some(value) = &overrides.ffmpeg_path { + self.ffmpeg_path = value.clone(); + } + if let Some(value) = &overrides.ffprobe_path { + self.ffprobe_path = value.clone(); + } + if let Some(value) = overrides.scan_depth { + self.scan.depth = value; + } + if let Some(value) = overrides.scan_recursive { + self.scan.recursive = value; + } + if let Some(value) = overrides.policy { + self.repair.policy = value; + } + if let Some(value) = &overrides.output_dir { + self.repair.output_dir = value.clone(); + } + if let Some(value) = overrides.keep_original { + self.repair.keep_original = value; + } + if let Some(value) = overrides.json { + self.report.json = value; + } + if let Some(value) = overrides.jobs { + self.performance.jobs = value; + } + if let Some(value) = overrides.watch { + self.watch.enabled = value; + } + } + + pub fn load_or_init(path: Option) -> Result<(Self, PathBuf)> { + let path = match path { + Some(path) => path, + None => default_config_path()?, + }; + + if !path.exists() { + init_config_at(&path, false)?; + } + + let raw = fs::read_to_string(&path) + .with_context(|| format!("Failed to read config file at {}", path.display()))?; + let mut config: Config = toml::from_str(&raw) + .with_context(|| format!("Failed to parse config file at {}", path.display()))?; + + config.normalize(); + + Ok((config, path)) + } + + pub fn normalize(&mut self) { + self.scan.include_ext = self + .scan + .include_ext + .iter() + .map(|ext| ext.trim_start_matches('.').to_lowercase()) + .collect(); + + self.watch.ignore_ext = self + .watch + .ignore_ext + .iter() + .map(|ext| ext.trim_start_matches('.').to_lowercase()) + .collect(); + + if self.watch.settle_seconds == 0 { + self.watch.settle_seconds = 10; + } + } +} + +pub fn default_config_path() -> Result { + let proj_dirs = ProjectDirs::from("cc", "44r0n", "vid-repair") + .context("Unable to resolve default config directory")?; + Ok(proj_dirs.config_dir().join("config.toml")) +} + +pub fn init_config_at(path: &Path, force: bool) -> Result<()> { + if path.exists() && !force { + return Ok(()); + } + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create config directory {}", parent.display()))?; + } + + fs::write(path, DEFAULT_CONFIG) + .with_context(|| format!("Failed to write default config to {}", path.display()))?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_parses() { + let config: Config = toml::from_str(DEFAULT_CONFIG).expect("default config parse"); + assert_eq!(config.scan.depth, ScanDepth::Deep); + assert!(!config.scan.include_ext.is_empty()); + } +} diff --git a/vid-repair-core/src/fix/executor.rs b/vid-repair-core/src/fix/executor.rs new file mode 100644 index 0000000..cb9dd32 --- /dev/null +++ b/vid-repair-core/src/fix/executor.rs @@ -0,0 +1,202 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result}; +use fs_err as fs; +use tempfile::NamedTempFile; + +use crate::config::Config; +use crate::fix::{FixKind, FixOutcome, FixPlan}; +use crate::scan::scan_file; +use crate::rules::RuleSet; + +pub fn apply_fix(path: &Path, plan: &FixPlan, config: &Config, ruleset: &RuleSet) -> Result { + if plan.actions.is_empty() { + return Ok(FixOutcome { + plan: plan.clone(), + applied: false, + success: false, + message: plan + .blocked_reason + .clone() + .unwrap_or_else(|| "No fix actions available".to_string()), + output_path: None, + re_scan_required: false, + }); + } + + let action = &plan.actions[0]; + let output = prepare_output_path(path, config)?; + + run_ffmpeg_fix(path, &output.temp_path, action.kind, config)?; + + let verification = scan_file(&output.temp_path, config, ruleset) + .with_context(|| format!("Failed to verify output {}", output.temp_path.display()))?; + + if has_severe_issues(&verification) { + return Ok(FixOutcome { + plan: plan.clone(), + applied: true, + success: false, + message: "Verification failed; repaired file still has severe issues".to_string(), + output_path: Some(output.temp_path.display().to_string()), + re_scan_required: true, + }); + } + + finalize_output(path, &output, config)?; + + Ok(FixOutcome { + plan: plan.clone(), + applied: true, + success: true, + message: "Fix applied successfully".to_string(), + output_path: Some(output.final_path.display().to_string()), + re_scan_required: false, + }) +} + +fn run_ffmpeg_fix(path: &Path, output: &Path, kind: FixKind, config: &Config) -> Result<()> { + let mut cmd = Command::new(&config.ffmpeg_path); + cmd.arg("-y").arg("-v").arg("error").arg("-i").arg(path); + + match kind { + FixKind::Remux => { + cmd.arg("-c").arg("copy"); + } + FixKind::Faststart => { + cmd.arg("-c").arg("copy"); + cmd.arg("-movflags").arg("+faststart"); + } + FixKind::Reencode => { + cmd.arg("-c:v").arg("libx264"); + cmd.arg("-c:a").arg("aac"); + cmd.arg("-movflags").arg("+faststart"); + } + } + + cmd.arg(output); + + let output = cmd + .output() + .with_context(|| format!("Failed to run ffmpeg fix for {}", path.display()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("ffmpeg fix failed: {}", stderr.trim()); + } + + Ok(()) +} + +fn has_severe_issues(report: &crate::scan::ScanOutcome) -> bool { + report + .issues + .iter() + .any(|issue| matches!(issue.severity, crate::rules::Severity::High | crate::rules::Severity::Severe)) +} + +struct OutputPaths { + temp_path: PathBuf, + final_path: PathBuf, +} + +fn prepare_output_path(path: &Path, config: &Config) -> Result { + if config.repair.output_dir.is_empty() { + let parent = path + .parent() + .context("Input file has no parent directory")?; + let temp = NamedTempFile::new_in(parent) + .with_context(|| format!("Failed to create temp file in {}", parent.display()))?; + let temp_path = temp.path().to_path_buf(); + temp.keep()?; + Ok(OutputPaths { + temp_path, + final_path: path.to_path_buf(), + }) + } else { + let output_dir = PathBuf::from(&config.repair.output_dir); + fs::create_dir_all(&output_dir).with_context(|| { + format!("Failed to create output directory {}", output_dir.display()) + })?; + + let file_name = path + .file_name() + .context("Input file has no filename")? + .to_os_string(); + + let final_path = output_dir.join(file_name); + let temp = NamedTempFile::new_in(&output_dir).with_context(|| { + format!("Failed to create temp file in {}", output_dir.display()) + })?; + let temp_path = temp.path().to_path_buf(); + temp.keep()?; + + Ok(OutputPaths { + temp_path, + final_path, + }) + } +} + +fn finalize_output(input: &Path, output: &OutputPaths, config: &Config) -> Result<()> { + if output.final_path == output.temp_path { + return Ok(()); + } + + if output.final_path == input { + if config.repair.keep_original { + let backup = next_original_path(input)?; + fs::rename(input, &backup).with_context(|| { + format!("Failed to rename original {}", input.display()) + })?; + } + + fs::rename(&output.temp_path, input).with_context(|| { + format!("Failed to move repaired file into place for {}", input.display()) + })?; + + return Ok(()); + } + + fs::rename(&output.temp_path, &output.final_path).with_context(|| { + format!( + "Failed to move repaired file to {}", + output.final_path.display() + ) + })?; + + Ok(()) +} + +fn next_original_path(path: &Path) -> Result { + let parent = path + .parent() + .context("Input file has no parent directory")?; + let stem = path + .file_stem() + .context("Input file has no stem")? + .to_string_lossy(); + let ext = path.extension().map(|ext| ext.to_string_lossy()); + + for idx in 0..1000 { + let candidate = if idx == 0 { + if let Some(ext) = &ext { + format!("{}.original.{}", stem, ext) + } else { + format!("{}.original", stem) + } + } else if let Some(ext) = &ext { + format!("{}.original.{}.{}", stem, idx, ext) + } else { + format!("{}.original.{}", stem, idx) + }; + + let path = parent.join(candidate); + if !path.exists() { + return Ok(path); + } + } + + anyhow::bail!("Unable to find available .original name for {}", path.display()); +} diff --git a/vid-repair-core/src/fix/mod.rs b/vid-repair-core/src/fix/mod.rs new file mode 100644 index 0000000..9ee687c --- /dev/null +++ b/vid-repair-core/src/fix/mod.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +use crate::config::FixPolicy; +use crate::rules::FixTier; + +pub mod executor; +pub mod planner; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FixAction { + pub kind: FixKind, + pub command: Vec, + pub destructive: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FixKind { + Remux, + Faststart, + Reencode, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FixPlan { + pub policy: FixPolicy, + pub recommended: Option, + pub actions: Vec, + pub blocked_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FixOutcome { + pub plan: FixPlan, + pub applied: bool, + pub success: bool, + pub message: String, + pub output_path: Option, + pub re_scan_required: bool, +} + +pub fn highest_fix_tier(tiers: &[FixTier]) -> FixTier { + tiers + .iter() + .copied() + .max_by_key(|tier| tier.rank()) + .unwrap_or(FixTier::None) +} diff --git a/vid-repair-core/src/fix/planner.rs b/vid-repair-core/src/fix/planner.rs new file mode 100644 index 0000000..682a92a --- /dev/null +++ b/vid-repair-core/src/fix/planner.rs @@ -0,0 +1,67 @@ +use crate::config::FixPolicy; +use crate::fix::{FixAction, FixKind, FixOutcome, FixPlan}; +use crate::rules::FixTier; +use crate::scan::Issue; + +pub fn plan_fix(issues: &[Issue], policy: FixPolicy) -> FixPlan { + let mut recommended = None; + + let mut has_faststart = false; + let mut has_remux = false; + let mut has_reencode = false; + + for issue in issues { + if let Some(action) = &issue.action { + if action.eq_ignore_ascii_case("faststart") { + has_faststart = true; + } + } + + match issue.fix_tier { + FixTier::Remux => has_remux = true, + FixTier::Reencode => has_reencode = true, + FixTier::None => {} + } + } + + if has_faststart { + recommended = Some(FixKind::Faststart); + } else if has_remux { + recommended = Some(FixKind::Remux); + } else if has_reencode { + recommended = Some(FixKind::Reencode); + } + + let mut actions = Vec::new(); + let mut blocked_reason = None; + + if let Some(kind) = recommended { + if kind == FixKind::Reencode && policy == FixPolicy::Safe { + blocked_reason = Some("Re-encode required but policy is safe".to_string()); + } else { + actions.push(FixAction { + kind, + command: Vec::new(), + destructive: true, + }); + } + } + + FixPlan { + policy, + recommended, + actions, + blocked_reason, + } +} + +pub fn plan_outcome(plan: FixPlan) -> FixOutcome { + FixOutcome { + plan, + applied: false, + success: false, + message: "Fix plan generated".to_string(), + output_path: None, + re_scan_required: false, + } +} diff --git a/vid-repair-core/src/fs/collect.rs b/vid-repair-core/src/fs/collect.rs new file mode 100644 index 0000000..8cb75de --- /dev/null +++ b/vid-repair-core/src/fs/collect.rs @@ -0,0 +1,69 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use globset::{Glob, GlobSetBuilder}; +use walkdir::WalkDir; + +use crate::config::Config; + +pub fn collect_files(paths: &[PathBuf], config: &Config) -> Result> { + let mut builder = GlobSetBuilder::new(); + for pattern in &config.scan.exclude { + if let Ok(glob) = Glob::new(pattern) { + builder.add(glob); + } + } + let exclude_set = builder.build()?; + + let mut files = Vec::new(); + + for input in paths { + if input.is_file() { + if should_include(input, config, &exclude_set) { + files.push(input.clone()); + } + continue; + } + + if input.is_dir() { + if config.scan.recursive { + for entry in WalkDir::new(input) + .follow_links(config.scan.follow_symlinks) + .into_iter() + .filter_map(|entry| entry.ok()) + { + if entry.file_type().is_file() { + let path = entry.path(); + if should_include(path, config, &exclude_set) { + files.push(path.to_path_buf()); + } + } + } + } else { + for entry in std::fs::read_dir(input)? { + let entry = entry?; + let path = entry.path(); + if path.is_file() && should_include(&path, config, &exclude_set) { + files.push(path); + } + } + } + } + } + + Ok(files) +} + +fn should_include(path: &Path, config: &Config, exclude_set: &globset::GlobSet) -> bool { + if exclude_set.is_match(path) { + return false; + } + + let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + let ext = ext.to_lowercase(); + if ext.is_empty() { + return false; + } + + config.scan.include_ext.iter().any(|e| e == &ext) +} diff --git a/vid-repair-core/src/fs/mod.rs b/vid-repair-core/src/fs/mod.rs new file mode 100644 index 0000000..b34af08 --- /dev/null +++ b/vid-repair-core/src/fs/mod.rs @@ -0,0 +1,3 @@ +mod collect; + +pub use collect::collect_files; diff --git a/vid-repair-core/src/lib.rs b/vid-repair-core/src/lib.rs new file mode 100644 index 0000000..9a88715 --- /dev/null +++ b/vid-repair-core/src/lib.rs @@ -0,0 +1,14 @@ +pub mod config; +pub mod fix; +pub mod fs; +pub mod report; +pub mod rules; +pub mod scan; +pub mod watch; + +pub use config::{Config, ConfigOverrides}; +pub use config::FixPolicy; +pub use fix::{FixOutcome, FixPlan}; +pub use report::{Report, ScanReport}; +pub use rules::{RuleSet, Severity}; +pub use scan::{ScanOutcome, ScanRequest}; diff --git a/vid-repair-core/src/report/json.rs b/vid-repair-core/src/report/json.rs new file mode 100644 index 0000000..8dc1d61 --- /dev/null +++ b/vid-repair-core/src/report/json.rs @@ -0,0 +1,10 @@ +use anyhow::Result; +use serde::Serialize; + +pub fn render_json(value: &T, pretty: bool) -> Result { + if pretty { + Ok(serde_json::to_string_pretty(value)?) + } else { + Ok(serde_json::to_string(value)?) + } +} diff --git a/vid-repair-core/src/report/mod.rs b/vid-repair-core/src/report/mod.rs new file mode 100644 index 0000000..506f37c --- /dev/null +++ b/vid-repair-core/src/report/mod.rs @@ -0,0 +1,7 @@ +mod json; +mod text; +mod types; + +pub use json::render_json; +pub use text::{render_fix_line, render_scan_line, render_summary}; +pub use types::{Report, ScanReport}; diff --git a/vid-repair-core/src/report/text.rs b/vid-repair-core/src/report/text.rs new file mode 100644 index 0000000..fc3968a --- /dev/null +++ b/vid-repair-core/src/report/text.rs @@ -0,0 +1,54 @@ +use crate::fix::FixOutcome; +use crate::rules::Severity; +use crate::scan::ScanOutcome; + +pub fn render_scan_line(scan: &ScanOutcome) -> String { + if scan.issues.is_empty() { + format!("[OK] {}", scan.path.display()) + } else { + let max = max_severity(scan); + format!( + "[ISSUES] {} ({} issues, max {:?})", + scan.path.display(), + scan.issues.len(), + max + ) + } +} + +pub fn render_fix_line(scan: &ScanOutcome, fix: &FixOutcome) -> String { + if fix.success { + format!("[FIXED] {}", scan.path.display()) + } else if fix.applied { + format!("[FAILED] {} - {}", scan.path.display(), fix.message) + } else { + format!("[SKIPPED] {} - {}", scan.path.display(), fix.message) + } +} + +pub fn render_summary(scans: &[ScanOutcome], fixes: Option<&[FixOutcome]>) -> String { + let total = scans.len(); + let issues = scans.iter().filter(|scan| !scan.issues.is_empty()).count(); + + let mut line = format!("Summary: {} files, {} with issues", total, issues); + + if let Some(fixes) = fixes { + let fixed = fixes.iter().filter(|fix| fix.success).count(); + let failed = fixes.iter().filter(|fix| fix.applied && !fix.success).count(); + let skipped = fixes.iter().filter(|fix| !fix.applied).count(); + line.push_str(&format!( + ", {} fixed, {} failed, {} skipped", + fixed, failed, skipped + )); + } + + line +} + +fn max_severity(scan: &ScanOutcome) -> Severity { + scan.issues + .iter() + .map(|issue| issue.severity) + .max_by_key(|sev| sev.rank()) + .unwrap_or(Severity::Info) +} diff --git a/vid-repair-core/src/report/types.rs b/vid-repair-core/src/report/types.rs new file mode 100644 index 0000000..3bc06fc --- /dev/null +++ b/vid-repair-core/src/report/types.rs @@ -0,0 +1,15 @@ +use serde::{Deserialize, Serialize}; + +use crate::fix::FixOutcome; +use crate::scan::ScanOutcome; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScanReport { + pub scan: ScanOutcome, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Report { + pub scan: ScanOutcome, + pub fix: Option, +} diff --git a/vid-repair-core/src/rules/loader.rs b/vid-repair-core/src/rules/loader.rs new file mode 100644 index 0000000..ee9b25f --- /dev/null +++ b/vid-repair-core/src/rules/loader.rs @@ -0,0 +1,78 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use regex::Regex; + +use super::matcher::CompiledRule; +use super::model::{Rule, RuleFile}; + +pub fn load_rules_from_dir(dir: &Path) -> Result> { + if !dir.exists() { + return Ok(Vec::new()); + } + + let mut entries: Vec = fs::read_dir(dir) + .with_context(|| format!("Failed to read ruleset dir {}", dir.display()))? + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter(|path| path.extension().map(|ext| ext == "toml").unwrap_or(false)) + .collect(); + + entries.sort(); + + let mut rules = Vec::new(); + + for path in entries { + let raw = fs::read_to_string(&path) + .with_context(|| format!("Failed to read ruleset {}", path.display()))?; + let file: RuleFile = toml::from_str(&raw) + .with_context(|| format!("Failed to parse ruleset {}", path.display()))?; + rules.extend(file.rules); + } + + Ok(rules) +} + +pub fn compile_rules(rules: Vec) -> Result> { + let mut compiled = Vec::new(); + + for rule in rules { + let patterns = rule + .patterns + .iter() + .map(|pattern| Regex::new(pattern)) + .collect::, _>>() + .with_context(|| format!("Invalid regex in rule {}", rule.id))?; + + compiled.push(CompiledRule { rule, patterns }); + } + + Ok(compiled) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_rule_file() { + let toml = r#" +[[rule]] +id = "TEST_RULE" +domain = "test" +severity = "low" +confidence = 0.8 +fix_tier = "none" +stop_scan = false +patterns = ["(?i)foo"] +notes = "test" +"#; + let file: RuleFile = toml::from_str(toml).expect("rule file parse"); + assert_eq!(file.rules.len(), 1); + + let compiled = compile_rules(file.rules).expect("compile rules"); + assert_eq!(compiled.len(), 1); + assert_eq!(compiled[0].rule.id, "TEST_RULE"); + } +} diff --git a/vid-repair-core/src/rules/matcher.rs b/vid-repair-core/src/rules/matcher.rs new file mode 100644 index 0000000..f6c5c63 --- /dev/null +++ b/vid-repair-core/src/rules/matcher.rs @@ -0,0 +1,78 @@ +use std::collections::HashSet; + +use regex::Regex; + +use super::model::{FixTier, Rule, Severity}; + +#[derive(Debug, Clone)] +pub struct CompiledRule { + pub rule: Rule, + pub patterns: Vec, +} + +#[derive(Debug, Clone)] +pub struct RuleMatch { + pub rule_id: String, + pub domain: String, + pub severity: Severity, + pub confidence: f32, + pub fix_tier: FixTier, + pub stop_scan: bool, + pub notes: Option, + pub action: Option, + pub evidence: Vec, +} + +#[derive(Debug, Default, Clone)] +pub struct RuleContext { + pub tags: HashSet, +} + +impl RuleContext { + pub fn with_tag(mut self, tag: impl Into) -> Self { + self.tags.insert(tag.into()); + self + } +} + +impl CompiledRule { + pub fn matches(&self, lines: &[String], context: &RuleContext) -> Option { + if !self.rule.requires.is_empty() + && !self.rule.requires.iter().all(|req| context.tags.contains(req)) + { + return None; + } + + if !self.rule.excludes.is_empty() + && self.rule.excludes.iter().any(|ex| context.tags.contains(ex)) + { + return None; + } + + let mut evidence = Vec::new(); + + for line in lines { + if self.patterns.iter().any(|re| re.is_match(line)) { + if evidence.len() < 3 { + evidence.push(line.clone()); + } + } + } + + if evidence.is_empty() { + return None; + } + + Some(RuleMatch { + rule_id: self.rule.id.clone(), + domain: self.rule.domain.clone(), + severity: self.rule.severity, + confidence: self.rule.confidence, + fix_tier: self.rule.fix_tier, + stop_scan: self.rule.stop_scan, + notes: self.rule.notes.clone(), + action: self.rule.action.clone(), + evidence, + }) + } +} diff --git a/vid-repair-core/src/rules/mod.rs b/vid-repair-core/src/rules/mod.rs new file mode 100644 index 0000000..5fa8930 --- /dev/null +++ b/vid-repair-core/src/rules/mod.rs @@ -0,0 +1,109 @@ +use std::path::PathBuf; + +use anyhow::Result; + +use crate::scan::ProbeData; + +mod loader; +mod matcher; +mod model; + +pub use matcher::{RuleContext, RuleMatch}; +pub use model::{FixTier, Severity}; + +#[derive(Debug, Clone)] +pub struct RuleSet { + pub rules: Vec, +} + +impl RuleSet { + pub fn load() -> Result { + let mut candidates = Vec::new(); + + if let Ok(current) = std::env::current_dir() { + candidates.push(current.join("rulesets")); + } + + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + candidates.push(parent.join("rulesets")); + } + } + + for dir in candidates { + let rules = loader::load_rules_from_dir(&dir)?; + if !rules.is_empty() { + let compiled = loader::compile_rules(rules)?; + return Ok(Self { rules: compiled }); + } + } + + Ok(Self { rules: Vec::new() }) + } + + pub fn match_lines(&self, lines: &[String], context: &RuleContext) -> Vec { + let mut matches = Vec::new(); + for rule in &self.rules { + if let Some(hit) = rule.matches(lines, context) { + matches.push(hit); + } + } + matches + } + + pub fn best_match<'a>(&self, matches: &'a [RuleMatch]) -> Option<&'a RuleMatch> { + matches.iter().max_by(|a, b| { + a.severity + .rank() + .cmp(&b.severity.rank()) + .then_with(|| a.confidence.partial_cmp(&b.confidence).unwrap_or(std::cmp::Ordering::Equal)) + }) + } +} + +pub fn build_context(probe: &ProbeData) -> RuleContext { + let mut context = RuleContext::default(); + + if let Some(format) = &probe.format_name { + context = context.with_tag(format!("container:{}", format.to_lowercase())); + } + + for stream in &probe.streams { + if let Some(codec_type) = &stream.codec_type { + context = context.with_tag(format!("stream:{}", codec_type.to_lowercase())); + } + if let Some(codec) = &stream.codec_name { + context = context.with_tag(format!("codec:{}", codec.to_lowercase())); + } + } + + context +} + +pub fn ruleset_dir_for_display() -> Result { + if let Ok(current) = std::env::current_dir() { + let dir = current.join("rulesets"); + if dir.exists() { + return Ok(dir); + } + } + + if let Ok(exe) = std::env::current_exe() { + if let Some(parent) = exe.parent() { + return Ok(parent.join("rulesets")); + } + } + + Err(anyhow::anyhow!("No ruleset directory found")) +} + +pub fn ensure_ruleset_loaded(ruleset: &RuleSet) -> Result<()> { + if ruleset.rules.is_empty() { + let dir = ruleset_dir_for_display().unwrap_or_else(|_| PathBuf::from("rulesets")); + return Err(anyhow::anyhow!( + "No rulesets found. Expected TOML files in {}", + dir.display() + )); + } + Ok(()) +} diff --git a/vid-repair-core/src/rules/model.rs b/vid-repair-core/src/rules/model.rs new file mode 100644 index 0000000..d31bd74 --- /dev/null +++ b/vid-repair-core/src/rules/model.rs @@ -0,0 +1,72 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Info, + Low, + Medium, + High, + Severe, +} + +impl Severity { + pub fn rank(self) -> u8 { + match self { + Severity::Info => 0, + Severity::Low => 1, + Severity::Medium => 2, + Severity::High => 3, + Severity::Severe => 4, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum FixTier { + None, + Remux, + Reencode, +} + +impl FixTier { + pub fn rank(self) -> u8 { + match self { + FixTier::None => 0, + FixTier::Remux => 1, + FixTier::Reencode => 2, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Rule { + pub id: String, + pub domain: String, + pub severity: Severity, + #[serde(default = "default_confidence")] + pub confidence: f32, + pub fix_tier: FixTier, + #[serde(default)] + pub stop_scan: bool, + pub patterns: Vec, + #[serde(default)] + pub notes: Option, + #[serde(default)] + pub action: Option, + #[serde(default)] + pub requires: Vec, + #[serde(default)] + pub excludes: Vec, +} + +fn default_confidence() -> f32 { + 0.5 +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RuleFile { + #[serde(rename = "rule")] + pub rules: Vec, +} diff --git a/vid-repair-core/src/scan/decode.rs b/vid-repair-core/src/scan/decode.rs new file mode 100644 index 0000000..cc375f9 --- /dev/null +++ b/vid-repair-core/src/scan/decode.rs @@ -0,0 +1,72 @@ +use std::io::{BufRead, BufReader}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::{Arc, atomic::{AtomicBool, Ordering}}; + +use anyhow::{Context, Result}; + +use crate::rules::RuleSet; + +#[derive(Debug)] +pub struct DecodeOutput { + pub lines: Vec, + pub early_stop: bool, +} + +pub fn run_decode(path: &Path, ffmpeg_path: &str, ruleset: &RuleSet) -> Result { + let mut child = Command::new(ffmpeg_path) + .arg("-v") + .arg("error") + .arg("-i") + .arg(path) + .arg("-f") + .arg("null") + .arg("-") + .stderr(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .with_context(|| format!("Failed to run ffmpeg decode for {}", path.display()))?; + + let stderr = child.stderr.take().context("Failed to capture ffmpeg stderr")?; + let reader = BufReader::new(stderr); + + let early_stop = Arc::new(AtomicBool::new(false)); + let early_stop_flag = early_stop.clone(); + + let mut lines = Vec::new(); + + for line in reader.lines() { + let line = line.unwrap_or_default(); + if line.is_empty() { + continue; + } + + lines.push(line.clone()); + + if should_stop(&line, ruleset) { + early_stop_flag.store(true, Ordering::SeqCst); + let _ = child.kill(); + break; + } + } + + let _ = child.wait(); + + Ok(DecodeOutput { + lines, + early_stop: early_stop.load(Ordering::SeqCst), + }) +} + +fn should_stop(line: &str, ruleset: &RuleSet) -> bool { + for rule in &ruleset.rules { + if !rule.rule.stop_scan { + continue; + } + + if rule.patterns.iter().any(|re| re.is_match(line)) { + return true; + } + } + false +} diff --git a/vid-repair-core/src/scan/ffprobe.rs b/vid-repair-core/src/scan/ffprobe.rs new file mode 100644 index 0000000..974b8e6 --- /dev/null +++ b/vid-repair-core/src/scan/ffprobe.rs @@ -0,0 +1,83 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result}; +use serde_json::Value; + +use super::types::{ProbeData, StreamInfo}; + +pub fn run_ffprobe(path: &Path, ffprobe_path: &str) -> Result { + let output = Command::new(ffprobe_path) + .arg("-v") + .arg("error") + .arg("-print_format") + .arg("json") + .arg("-show_format") + .arg("-show_streams") + .arg(path) + .output() + .with_context(|| format!("Failed to run ffprobe on {}", path.display()))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "ffprobe failed for {}: {}", + path.display(), + stderr.trim() + ); + } + + let raw: Value = serde_json::from_slice(&output.stdout) + .with_context(|| format!("Failed to parse ffprobe output for {}", path.display()))?; + + let format_name = raw + .get("format") + .and_then(|fmt| fmt.get("format_name")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let duration = raw + .get("format") + .and_then(|fmt| fmt.get("duration")) + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().ok()); + + let streams = raw + .get("streams") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .map(|stream| StreamInfo { + codec_type: stream + .get("codec_type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + codec_name: stream + .get("codec_name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + width: stream.get("width").and_then(|v| v.as_u64()).map(|v| v as u32), + height: stream + .get("height") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + sample_rate: stream + .get("sample_rate") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + channels: stream + .get("channels") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), + }) + .collect::>() + }) + .unwrap_or_default(); + + Ok(ProbeData { + format_name, + duration, + streams, + raw, + }) +} diff --git a/vid-repair-core/src/scan/mod.rs b/vid-repair-core/src/scan/mod.rs new file mode 100644 index 0000000..07a91f7 --- /dev/null +++ b/vid-repair-core/src/scan/mod.rs @@ -0,0 +1,48 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::config::Config; +use crate::rules::{build_context, RuleMatch, RuleSet}; + +mod decode; +mod ffprobe; +mod types; + +pub use types::{Issue, ProbeData, ScanOutcome, ScanRequest}; + +pub fn scan_file(path: &Path, config: &Config, ruleset: &RuleSet) -> Result { + let probe = ffprobe::run_ffprobe(path, &config.ffprobe_path)?; + + let decode = decode::run_decode(path, &config.ffmpeg_path, ruleset)?; + + let context = build_context(&probe); + let matches = ruleset.match_lines(&decode.lines, &context); + + let issues = matches + .iter() + .map(|hit| issue_from_match(hit)) + .collect::>(); + + Ok(ScanOutcome { + path: path.to_path_buf(), + probe, + issues, + decode_errors: decode.lines, + early_stop: decode.early_stop, + }) +} + +fn issue_from_match(hit: &RuleMatch) -> Issue { + Issue { + code: hit.rule_id.clone(), + severity: hit.severity, + fix_tier: hit.fix_tier, + message: hit + .notes + .clone() + .unwrap_or_else(|| hit.rule_id.clone()), + evidence: hit.evidence.clone(), + action: hit.action.clone(), + } +} diff --git a/vid-repair-core/src/scan/types.rs b/vid-repair-core/src/scan/types.rs new file mode 100644 index 0000000..1f58cad --- /dev/null +++ b/vid-repair-core/src/scan/types.rs @@ -0,0 +1,47 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::rules::{FixTier, Severity}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StreamInfo { + pub codec_type: Option, + pub codec_name: Option, + pub width: Option, + pub height: Option, + pub sample_rate: Option, + pub channels: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProbeData { + pub format_name: Option, + pub duration: Option, + pub streams: Vec, + pub raw: serde_json::Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Issue { + pub code: String, + pub severity: Severity, + pub fix_tier: FixTier, + pub message: String, + pub evidence: Vec, + pub action: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScanOutcome { + pub path: PathBuf, + pub probe: ProbeData, + pub issues: Vec, + pub decode_errors: Vec, + pub early_stop: bool, +} + +#[derive(Debug, Clone)] +pub struct ScanRequest { + pub path: PathBuf, +} diff --git a/vid-repair-core/src/watch/mod.rs b/vid-repair-core/src/watch/mod.rs new file mode 100644 index 0000000..4b4aba7 --- /dev/null +++ b/vid-repair-core/src/watch/mod.rs @@ -0,0 +1,109 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +use anyhow::Result; +use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind}; + +use crate::config::Config; + +#[derive(Debug)] +struct WatchEntry { + last_event: Instant, + size: u64, + mtime: std::time::SystemTime, +} + +pub fn watch_paths(paths: &[PathBuf], config: &Config, mut handler: F) -> Result<()> +where + F: FnMut(PathBuf), +{ + let (tx, rx) = mpsc::channel(); + let mut watcher: RecommendedWatcher = Watcher::new(tx, notify::Config::default())?; + + for path in paths { + let mode = if path.is_dir() { + RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive + }; + watcher.watch(path, mode)?; + } + + let mut entries: HashMap = HashMap::new(); + let settle = Duration::from_secs(config.watch.settle_seconds); + + loop { + match rx.recv_timeout(Duration::from_secs(1)) { + Ok(event) => { + if let Ok(event) = event { + if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) { + for path in event.paths { + if should_ignore(&path, config) { + continue; + } + if let Ok(metadata) = std::fs::metadata(&path) { + if !metadata.is_file() { + continue; + } + let entry = WatchEntry { + last_event: Instant::now(), + size: metadata.len(), + mtime: metadata.modified().unwrap_or_else(|_| std::time::SystemTime::UNIX_EPOCH), + }; + entries.insert(path, entry); + } + } + } + } + } + Err(RecvTimeoutError::Timeout) => { + // tick + } + Err(RecvTimeoutError::Disconnected) => break, + } + + let ready: Vec = entries + .iter() + .filter_map(|(path, entry)| { + if entry.last_event.elapsed() < settle { + return None; + } + + let metadata = std::fs::metadata(path).ok()?; + if !metadata.is_file() { + return None; + } + let size = metadata.len(); + let mtime = metadata.modified().ok()?; + if size == entry.size && mtime == entry.mtime { + Some(path.clone()) + } else { + None + } + }) + .collect(); + + for path in ready { + entries.remove(&path); + handler(path); + } + } + + Ok(()) +} + +fn should_ignore(path: &Path, config: &Config) -> bool { + let ext = path.extension().and_then(|ext| ext.to_str()).unwrap_or(""); + let ext = ext.to_lowercase(); + if ext.is_empty() { + return true; + } + + if config.watch.ignore_ext.iter().any(|e| e == &ext) { + return true; + } + + !config.scan.include_ext.iter().any(|e| e == &ext) +} diff --git a/vid-repair/.gitignore b/vid-repair/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/vid-repair/.gitignore @@ -0,0 +1 @@ +/target diff --git a/vid-repair/Cargo.toml b/vid-repair/Cargo.toml new file mode 100644 index 0000000..6dd826a --- /dev/null +++ b/vid-repair/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "vid-repair" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +toml = { workspace = true } +rayon = { workspace = true } +vid-repair-core = { path = "../vid-repair-core" } diff --git a/vid-repair/src/main.rs b/vid-repair/src/main.rs new file mode 100644 index 0000000..ca406c2 --- /dev/null +++ b/vid-repair/src/main.rs @@ -0,0 +1,489 @@ +use std::path::PathBuf; + +use anyhow::Result; +use clap::{Parser, Subcommand, ValueEnum}; +use rayon::prelude::*; +use rayon::ThreadPoolBuilder; + +use vid_repair_core::config::{Config, ConfigOverrides, FixPolicy, ScanDepth}; +use vid_repair_core::fix::{self, FixOutcome}; +use vid_repair_core::report::{render_fix_line, render_json, render_scan_line, render_summary}; +use vid_repair_core::rules::{ensure_ruleset_loaded, RuleSet}; +use vid_repair_core::scan::{scan_file, ScanOutcome}; +use vid_repair_core::{fs, watch}; + +#[derive(Parser, Debug)] +#[command(name = "vid-repair")] +#[command(about = "Scan and repair video files using ffmpeg/ffprobe", long_about = None)] +struct Cli { + /// Path to config file (defaults to XDG config location) + #[arg(long)] + config: Option, + + /// Emit JSON output + #[arg(long)] + json: bool, + + /// Number of parallel jobs (0 = auto) + #[arg(long)] + jobs: Option, + + /// Override ffmpeg binary path + #[arg(long)] + ffmpeg_path: Option, + + /// Override ffprobe binary path + #[arg(long)] + ffprobe_path: Option, + + #[command(subcommand)] + command: Commands, +} + +#[derive(Debug, Clone)] +struct CommonArgs { + config: Option, + json: bool, + jobs: Option, + ffmpeg_path: Option, + ffprobe_path: Option, +} + +#[derive(Subcommand, Debug)] +enum Commands { + Scan(ScanArgs), + Fix(FixArgs), + Report(ScanArgs), + Config(ConfigArgs), +} + +#[derive(ValueEnum, Debug, Clone, Copy)] +enum ScanDepthArg { + Quick, + Standard, + Deep, +} + +impl From for ScanDepth { + fn from(value: ScanDepthArg) -> Self { + match value { + ScanDepthArg::Quick => ScanDepth::Quick, + ScanDepthArg::Standard => ScanDepth::Standard, + ScanDepthArg::Deep => ScanDepth::Deep, + } + } +} + +#[derive(ValueEnum, Debug, Clone, Copy)] +enum FixPolicyArg { + Safe, + Aggressive, +} + +impl From for FixPolicy { + fn from(value: FixPolicyArg) -> Self { + match value { + FixPolicyArg::Safe => FixPolicy::Safe, + FixPolicyArg::Aggressive => FixPolicy::Aggressive, + } + } +} + +#[derive(Parser, Debug)] +struct ScanArgs { + /// Paths to scan (files or directories) + #[arg(value_name = "PATH")] + paths: Vec, + + /// Watch for changes and scan when files settle + #[arg(long)] + watch: bool, + + /// Override scan depth (quick|standard|deep) + #[arg(long)] + scan_depth: Option, + + /// Override recursive scanning + #[arg(long)] + recursive: bool, +} + +#[derive(Parser, Debug)] +struct FixArgs { + /// Paths to fix (files or directories) + #[arg(value_name = "PATH")] + paths: Vec, + + /// Dry-run: show plan without applying changes + #[arg(long)] + dry_run: bool, + + /// Watch for changes and fix when files settle + #[arg(long)] + watch: bool, + + /// Override scan depth (quick|standard|deep) + #[arg(long)] + scan_depth: Option, + + /// Override recursive scanning + #[arg(long)] + recursive: bool, + + /// Override repair policy (safe|aggressive) + #[arg(long)] + policy: Option, + + /// Output directory for repaired files (empty = in-place) + #[arg(long)] + output_dir: Option, + + /// Keep original file (rename to *.original.* after successful fix) + #[arg(long)] + keep_original: bool, +} + +#[derive(Parser, Debug)] +struct ConfigArgs { + #[command(subcommand)] + command: ConfigCommand, +} + +#[derive(Subcommand, Debug)] +enum ConfigCommand { + Init { + /// Overwrite existing config + #[arg(long)] + force: bool, + }, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + let Cli { + config, + json, + jobs, + ffmpeg_path, + ffprobe_path, + command, + } = cli; + + let common = CommonArgs { + config, + json, + jobs, + ffmpeg_path, + ffprobe_path, + }; + + match command { + Commands::Config(args) => handle_config(args, common.config.clone()), + Commands::Scan(args) => handle_scan(args, &common), + Commands::Report(args) => handle_report(args, &common), + Commands::Fix(args) => handle_fix(args, &common), + } +} + +fn handle_config(args: ConfigArgs, path: Option) -> Result<()> { + match args.command { + ConfigCommand::Init { force } => { + let path = path.unwrap_or_else(|| vid_repair_core::config::default_config_path().unwrap()); + vid_repair_core::config::init_config_at(&path, force)?; + println!("Config written to {}", path.display()); + Ok(()) + } + } +} + +fn handle_scan(args: ScanArgs, common: &CommonArgs) -> Result<()> { + let (mut config, _config_path) = Config::load_or_init(common.config.clone())?; + let mut overrides = ConfigOverrides::default(); + + overrides.ffmpeg_path = common.ffmpeg_path.clone(); + overrides.ffprobe_path = common.ffprobe_path.clone(); + if common.json { + overrides.json = Some(true); + } + if let Some(jobs) = common.jobs { + overrides.jobs = Some(jobs); + } + overrides.scan_depth = args.scan_depth.map(ScanDepth::from); + if args.recursive { + overrides.scan_recursive = Some(true); + } + overrides.watch = Some(args.watch); + + config.apply_overrides(&overrides); + config.normalize(); + + let ruleset = RuleSet::load()?; + ensure_ruleset_loaded(&ruleset)?; + + if args.paths.is_empty() { + anyhow::bail!("No input paths provided"); + } + + if config.watch.enabled { + return watch_scan(args.paths, &config, &ruleset); + } + + let files = fs::collect_files(&args.paths, &config)?; + if files.is_empty() { + println!("No matching files found."); + return Ok(()); + } + + let scans = run_scans(files, &config, &ruleset)?; + + if config.report.json { + let json = render_json(&scans, config.report.pretty)?; + println!("{}", json); + } else { + for scan in &scans { + println!("{}", render_scan_line(scan)); + } + println!("{}", render_summary(&scans, None)); + } + + Ok(()) +} + +fn handle_report(args: ScanArgs, common: &CommonArgs) -> Result<()> { + let mut common = common.clone(); + common.json = true; + handle_scan(args, &common) +} + +fn handle_fix(args: FixArgs, common: &CommonArgs) -> Result<()> { + let (mut config, _config_path) = Config::load_or_init(common.config.clone())?; + let mut overrides = ConfigOverrides::default(); + + overrides.ffmpeg_path = common.ffmpeg_path.clone(); + overrides.ffprobe_path = common.ffprobe_path.clone(); + if common.json { + overrides.json = Some(true); + } + overrides.jobs = common.jobs; + overrides.scan_depth = args.scan_depth.map(ScanDepth::from); + if args.recursive { + overrides.scan_recursive = Some(true); + } + overrides.policy = args.policy.map(FixPolicy::from); + overrides.output_dir = args.output_dir; + overrides.keep_original = Some(args.keep_original); + overrides.watch = Some(args.watch); + + config.apply_overrides(&overrides); + config.normalize(); + + let ruleset = RuleSet::load()?; + ensure_ruleset_loaded(&ruleset)?; + + if args.paths.is_empty() { + anyhow::bail!("No input paths provided"); + } + + if config.watch.enabled { + return watch_fix(args.paths, &config, &ruleset, args.dry_run); + } + + let files = fs::collect_files(&args.paths, &config)?; + if files.is_empty() { + println!("No matching files found."); + return Ok(()); + } + + let (scans, fixes) = run_fixes(files, &config, &ruleset, args.dry_run)?; + + if config.report.json { + let payload = serde_json::json!({ "scans": scans, "fixes": fixes }); + let json = render_json(&payload, config.report.pretty)?; + println!("{}", json); + } else { + for (scan, fix) in scans.iter().zip(fixes.iter()) { + println!("{}", render_fix_line(scan, fix)); + } + println!("{}", render_summary(&scans, Some(&fixes))); + } + + Ok(()) +} + +fn run_scans(files: Vec, config: &Config, ruleset: &RuleSet) -> Result> { + let jobs = if config.performance.jobs == 0 { + None + } else { + Some(config.performance.jobs) + }; + + let scans = if let Some(jobs) = jobs { + let pool = ThreadPoolBuilder::new().num_threads(jobs).build()?; + pool.install(|| { + files + .par_iter() + .filter_map(|path| match scan_file(path, config, ruleset) { + Ok(scan) => Some(scan), + Err(err) => { + eprintln!("[ERROR] {}: {}", path.display(), err); + None + } + }) + .collect::>() + }) + } else { + files + .iter() + .filter_map(|path| match scan_file(path, config, ruleset) { + Ok(scan) => Some(scan), + Err(err) => { + eprintln!("[ERROR] {}: {}", path.display(), err); + None + } + }) + .collect::>() + }; + + Ok(scans) +} + +fn run_fixes( + files: Vec, + config: &Config, + ruleset: &RuleSet, + dry_run: bool, +) -> Result<(Vec, Vec)> { + let jobs = if config.performance.jobs == 0 { + None + } else { + Some(config.performance.jobs) + }; + + if let Some(jobs) = jobs { + let pool = ThreadPoolBuilder::new().num_threads(jobs).build()?; + pool.install(|| process_fix_batch_parallel(files, config, ruleset, dry_run)) + } else { + process_fix_batch(files, config, ruleset, dry_run) + } +} + +fn process_fix_batch( + files: Vec, + config: &Config, + ruleset: &RuleSet, + dry_run: bool, +) -> Result<(Vec, Vec)> { + let mut scans = Vec::new(); + let mut fixes = Vec::new(); + + for path in files { + let scan = match scan_file(&path, config, ruleset) { + Ok(scan) => scan, + Err(err) => { + eprintln!("[ERROR] {}: {}", path.display(), err); + continue; + } + }; + let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy); + let outcome = if dry_run { + fix::planner::plan_outcome(plan) + } else { + match fix::executor::apply_fix(&path, &plan, config, ruleset) { + Ok(outcome) => outcome, + Err(err) => FixOutcome { + plan: plan.clone(), + applied: true, + success: false, + message: format!("Fix failed: {}", err), + output_path: None, + re_scan_required: true, + }, + } + }; + scans.push(scan); + fixes.push(outcome); + } + + Ok((scans, fixes)) +} + +fn process_fix_batch_parallel( + files: Vec, + config: &Config, + ruleset: &RuleSet, + dry_run: bool, +) -> Result<(Vec, Vec)> { + let results = files + .par_iter() + .filter_map(|path| { + let scan = match scan_file(path, config, ruleset) { + Ok(scan) => scan, + Err(err) => { + eprintln!("[ERROR] {}: {}", path.display(), err); + return None; + } + }; + let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy); + let outcome = if dry_run { + fix::planner::plan_outcome(plan) + } else { + match fix::executor::apply_fix(path, &plan, config, ruleset) { + Ok(outcome) => outcome, + Err(err) => FixOutcome { + plan: plan.clone(), + applied: true, + success: false, + message: format!("Fix failed: {}", err), + output_path: None, + re_scan_required: true, + }, + } + }; + Some((scan, outcome)) + }) + .collect::>(); + + let (scans, fixes): (Vec<_>, Vec<_>) = results.into_iter().unzip(); + Ok((scans, fixes)) +} + +fn watch_scan(paths: Vec, config: &Config, ruleset: &RuleSet) -> Result<()> { + println!("Watch mode enabled. Waiting for files to settle..."); + watch::watch_paths(&paths, config, |path| { + match scan_file(&path, config, ruleset) { + Ok(scan) => { + println!("{}", render_scan_line(&scan)); + } + Err(err) => { + eprintln!("[ERROR] {}: {}", path.display(), err); + } + } + }) +} + +fn watch_fix(paths: Vec, config: &Config, ruleset: &RuleSet, dry_run: bool) -> Result<()> { + println!("Watch mode enabled. Waiting for files to settle..."); + watch::watch_paths(&paths, config, |path| { + match scan_file(&path, config, ruleset) { + Ok(scan) => { + let plan = fix::planner::plan_fix(&scan.issues, config.repair.policy); + let outcome = if dry_run { + fix::planner::plan_outcome(plan) + } else { + match fix::executor::apply_fix(&path, &plan, config, ruleset) { + Ok(outcome) => outcome, + Err(err) => { + eprintln!("[ERROR] Fix failed {}: {}", path.display(), err); + return; + } + } + }; + println!("{}", render_fix_line(&scan, &outcome)); + } + Err(err) => { + eprintln!("[ERROR] {}: {}", path.display(), err); + } + } + }) +}