diff options
Diffstat (limited to '')
182 files changed, 12417 insertions, 8714 deletions
diff --git a/.cargo/config.toml b/.cargo/config.toml index 338862f..94d197f 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -8,8 +8,5 @@ # You should have received a copy of the License along with this program. # If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -[env] -PYO3_PYTHON = "/nix/store/7xzk119acyws2c4ysygdv66l0grxkr39-python3-3.11.9-env/bin/python3" - [build] rustflags = ["-Clink-arg=-fuse-ld=mold", "-Ctarget-cpu=native"] diff --git a/.envrc b/.envrc index 5537ab5..adb00cc 100644 --- a/.envrc +++ b/.envrc @@ -20,5 +20,8 @@ PATH_add ./target/release PATH_add ./target/profiling export DATABASE_URL="sqlite://$root/target/database.sqlx" + +# Plugins are not supported. +export YTDLP_NO_PLUGINS=1 + export PYO3_PYTHON=python3 -export YT_STORE_INFO_JSON=yes diff --git a/Cargo.lock b/Cargo.lock index 4a17021..c55090c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,44 +3,10 @@ version = 4 [[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "adler32" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.3", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -52,12 +18,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -68,9 +28,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.19" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -83,9 +43,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -98,29 +58,29 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.3" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.9" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "arrayref" @@ -135,12 +95,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "ascii" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" - -[[package]] name = "atoi" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -150,34 +104,16 @@ dependencies = [ ] [[package]] -name = "atomic" -version = "0.6.0" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" -dependencies = [ - "bytemuck", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "base64" @@ -193,14 +129,14 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bindgen" -version = "0.72.0" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f72209734318d0b619a5e0f5129918b848c416e122a3c4ce054e03cb87b726f" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools", "log", "prettyplease", "proc-macro2", @@ -219,20 +155,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" dependencies = [ - "serde", -] - -[[package]] -name = "blake2" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" -dependencies = [ - "digest", + "serde_core", ] [[package]] @@ -245,7 +172,8 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq 0.3.1", + "constant_time_eq", + "serde", ] [[package]] @@ -258,27 +186,10 @@ dependencies = [ ] [[package]] -name = "bstr" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" -dependencies = [ - "memchr", - "regex-automata", - "serde", -] - -[[package]] name = "bumpalo" -version = "3.18.1" +version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" - -[[package]] -name = "bytemuck" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "byteorder" @@ -288,61 +199,17 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.5.0" -dependencies = [ - "serde", -] - -[[package]] -name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", - "libbz2-rs-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "caseless" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6fd507454086c8edfd769ca6ada439193cdb209c7681712ef6275cccbfe5d8" -dependencies = [ - "unicode-normalization", -] - -[[package]] -name = "castaway" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" -dependencies = [ - "rustversion", -] +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "cc" -version = "1.2.27" +version = "1.2.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" +checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -357,23 +224,16 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", @@ -403,9 +263,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.40" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -413,9 +273,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.40" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -424,10 +284,22 @@ dependencies = [ ] [[package]] +name = "clap_complete" +version = "4.5.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39615915e2ece2550c0149addac32fb5bd312c657f43845bb9088cb9c8a7c992" +dependencies = [ + "clap", + "clap_lex", + "is_executable", + "shlex", +] + +[[package]] name = "clap_derive" -version = "4.5.40" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", @@ -437,18 +309,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" - -[[package]] -name = "clipboard-win" -version = "5.4.0" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" -dependencies = [ - "error-code", -] +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "colorchoice" @@ -457,18 +320,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] -name = "compact_str" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] +name = "colors" +version = "1.9.0" [[package]] name = "concurrent-queue" @@ -492,12 +345,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" [[package]] -name = "constant_time_eq" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" - -[[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -524,9 +371,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] @@ -538,15 +385,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] -name = "crc32fast" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" -dependencies = [ - "cfg-if", -] - -[[package]] name = "crossbeam" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -603,28 +441,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "crunchy" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" - -[[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", ] [[package]] -name = "csv-core" -version = "0.1.12" +name = "curl" +version = "0.4.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" +checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc" dependencies = [ - "memchr", + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "schannel", + "socket2", + "windows-sys 0.59.0", +] + +[[package]] +name = "curl-sys" +version = "0.4.84+curl-8.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc4294dc41b882eaff37973c2ec3ae203d0091341ee68fbadd1d06e0c18a73b" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", + "windows-sys 0.59.0", ] [[package]] @@ -639,6 +492,12 @@ dependencies = [ ] [[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -651,27 +510,6 @@ dependencies = [ ] [[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] name = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -683,30 +521,12 @@ dependencies = [ ] [[package]] -name = "dns-lookup" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5766087c2235fec47fafa4cfecc81e494ee679d0fd4a59887ea0919bfb0e4fc" -dependencies = [ - "cfg-if", - "libc", - "socket2", - "windows-sys 0.48.0", -] - -[[package]] name = "dotenvy" version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] -name = "dyn-clone" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" - -[[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -716,38 +536,12 @@ dependencies = [ ] [[package]] -name = "endian-type" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" - -[[package]] -name = "env_filter" -version = "0.1.3" +name = "encoding_rs" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - -[[package]] -name = "env_logger" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", + "cfg-if", ] [[package]] @@ -758,21 +552,15 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] -name = "error-code" -version = "3.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" - -[[package]] name = "etcetera" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -785,9 +573,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.0" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -795,50 +583,16 @@ dependencies = [ ] [[package]] -name = "exitcode" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de853764b47027c2e862a995c34978ffa63c1501f2e15f987ba11bd4f9bba193" - -[[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix", - "windows-sys 0.59.0", -] - -[[package]] -name = "filetime" -version = "0.2.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" -dependencies = [ - "cfg-if", - "libc", - "libredox", - "windows-sys 0.59.0", -] - -[[package]] -name = "flate2" -version = "1.1.2" +name = "find-msvc-tools" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" -dependencies = [ - "crc32fast", - "libz-rs-sys", - "miniz_oxide", -] +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "flume" @@ -852,6 +606,12 @@ dependencies = [ ] [[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -874,9 +634,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -992,25 +752,6 @@ dependencies = [ ] [[package]] -name = "gethostname" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" -dependencies = [ - "rustix", - "windows-targets 0.52.6", -] - -[[package]] -name = "getopts" -version = "0.2.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" -dependencies = [ - "unicode-width", -] - -[[package]] name = "getrandom" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1018,48 +759,51 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasip2", ] [[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] -name = "half" -version = "2.6.0" +name = "h2" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" dependencies = [ - "cfg-if", - "crunchy", + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] name = "hashbrown" -version = "0.15.4" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", @@ -1067,12 +811,18 @@ dependencies = [ ] [[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] name = "hashlink" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown", + "hashbrown 0.15.5", ] [[package]] @@ -1094,12 +844,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hexf-parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" - -[[package]] name = "hkdf" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1119,18 +863,137 @@ dependencies = [ [[package]] name = "home" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", ] [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1138,7 +1001,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -1152,9 +1015,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -1165,9 +1028,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -1178,11 +1041,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -1193,42 +1055,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -1238,9 +1096,9 @@ dependencies = [ [[package]] name = "idna" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", @@ -1259,12 +1117,21 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.9.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", ] [[package]] @@ -1273,7 +1140,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "inotify-sys", "libc", ] @@ -1288,48 +1155,52 @@ dependencies = [ ] [[package]] -name = "is-macro" -version = "0.3.7" +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", + "memchr", + "serde", ] [[package]] name = "is-terminal" -version = "0.4.16" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] -name = "is_terminal_polyfill" -version = "1.70.1" +name = "is_executable" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "baabb8b4867b26294d818bf3f651a454b6901431711abb96e296245888d6e8c4" +dependencies = [ + "windows-sys 0.60.2", +] [[package]] -name = "itertools" -version = "0.13.0" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.14.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] @@ -1341,59 +1212,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] -name = "jiff" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde", -] - -[[package]] -name = "jiff-static" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] -name = "junction" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72bbdfd737a243da3dfc1f99ee8d6e166480f17ab4ac84d7c34aacd73fc7bd16" -dependencies = [ - "scopeguard", - "windows-sys 0.52.0", -] - -[[package]] -name = "keccak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" -dependencies = [ - "cpufeatures", -] - -[[package]] name = "kqueue" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1423,80 +1251,19 @@ dependencies = [ ] [[package]] -name = "lexical-parse-float" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de6f9cb01fb0b08060209a057c048fcbab8717b4c1ecd2eac66ebfe39a65b0f2" -dependencies = [ - "lexical-parse-integer", - "lexical-util", - "static_assertions", -] - -[[package]] -name = "lexical-parse-integer" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72207aae22fc0a121ba7b6d479e42cbfea549af1479c3f3a4f12c70dd66df12e" -dependencies = [ - "lexical-util", - "static_assertions", -] - -[[package]] -name = "lexical-util" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a82e24bf537fd24c177ffbbdc6ebcc8d54732c35b50a3f28cc3f4e4c949a0b3" -dependencies = [ - "static_assertions", -] - -[[package]] -name = "lexopt" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa0e2a1fcbe2f6be6c42e342259976206b383122fc152e872795338b5a3f3a7" - -[[package]] -name = "libbz2-rs-sys" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0864a00c8d019e36216b69c2c4ce50b83b7bd966add3cf5ba554ec44f8bebcf5" - -[[package]] name = "libc" -version = "0.2.173" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8cfeafaffdbc32176b64fb251369d52ea9f0a8fbc6f8759edffef7b525d64bb" - -[[package]] -name = "libffi" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebfd30a67b482a08116e753d0656cb626548cf4242543e5cc005be7639d99838" -dependencies = [ - "libc", - "libffi-sys", -] - -[[package]] -name = "libffi-sys" -version = "3.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f003aa318c9f0ee69eb0ada7c78f5c9d2fedd2ceb274173b5c7ff475eee584a3" -dependencies = [ - "cc", -] +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "libloading" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-targets 0.53.2", + "windows-link", ] [[package]] @@ -1507,7 +1274,7 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libmpv2" -version = "1.5.0" +version = "1.9.0" dependencies = [ "crossbeam", "libmpv2-sys", @@ -1517,18 +1284,18 @@ dependencies = [ [[package]] name = "libmpv2-sys" -version = "1.5.0" +version = "1.9.0" dependencies = [ "bindgen", ] [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "libc", "redox_syscall", ] @@ -1545,131 +1312,43 @@ dependencies = [ ] [[package]] -name = "libz-rs-sys" -version = "0.5.1" +name = "libz-sys" +version = "1.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" dependencies = [ - "zlib-rs", + "cc", + "libc", + "pkg-config", + "vcpkg", ] [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "lz4_flex" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75761162ae2b0e580d7e7c390558127e5f01b4194debd6221fd8c207fc80e3f5" -dependencies = [ - "twox-hash", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - -[[package]] -name = "mac_address" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" -dependencies = [ - "nix", - "winapi", -] - -[[package]] -name = "malachite-base" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c738d3789301e957a8f7519318fcbb1b92bb95863b28f6938ae5a05be6259f34" -dependencies = [ - "hashbrown", - "itertools 0.14.0", - "libm", - "ryu", -] - -[[package]] -name = "malachite-bigint" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f46b904a4725706c5ad0133b662c20b388a3ffb04bda5154029dcb0cd28ae34" -dependencies = [ - "malachite-base", - "malachite-nz", - "num-integer", - "num-traits", - "paste", -] - -[[package]] -name = "malachite-nz" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1707c9a1fa36ce21749b35972bfad17bbf34cf5a7c96897c0491da321e387d3b" -dependencies = [ - "itertools 0.14.0", - "libm", - "malachite-base", - "wide", -] - -[[package]] -name = "malachite-q" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d764801aa4e96bbb69b389dcd03b50075345131cd63ca2e380bca71cc37a3675" -dependencies = [ - "itertools 0.14.0", - "malachite-base", - "malachite-nz", -] - -[[package]] -name = "maplit" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "md-5" @@ -1683,18 +1362,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" - -[[package]] -name = "memmap2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" -dependencies = [ - "libc", -] +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memoffset" @@ -1706,61 +1376,44 @@ dependencies = [ ] [[package]] -name = "minimal-lexical" -version = "0.2.1" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "miniz_oxide" -version = "0.8.9" +name = "minimal-lexical" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", -] - -[[package]] -name = "mt19937" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7151a832e54d2d6b2c827a20e5bcdd80359281cd2c354e725d4b82e7c471de" -dependencies = [ - "rand_core 0.9.3", + "wasi", + "windows-sys 0.61.2", ] [[package]] -name = "nibble_vec" -version = "0.1.0" +name = "native-tls" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" dependencies = [ - "smallvec", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "cfg_aliases", "libc", - "memoffset", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", ] [[package]] @@ -1775,12 +1428,11 @@ dependencies = [ [[package]] name = "notify" -version = "8.0.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fee8403b3d66ac7b26aee6e40a897d85dc5ce26f44da36b8b73e987cc52e943" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.9.1", - "filetime", + "bitflags 2.10.0", "inotify", "kqueue", "libc", @@ -1788,7 +1440,7 @@ dependencies = [ "mio", "notify-types", "walkdir", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1798,22 +1450,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" [[package]] -name = "nucleo-matcher" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" -dependencies = [ - "memchr", - "unicode-segmentation", -] - -[[package]] name = "num-bigint-dig" -version = "0.8.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "byteorder", "lazy_static", "libm", "num-integer", @@ -1825,15 +1466,6 @@ dependencies = [ ] [[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] name = "num-integer" version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1864,45 +1496,6 @@ dependencies = [ ] [[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "num_enum" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" -dependencies = [ - "num_enum_derive", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - -[[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1910,17 +1503,17 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl" -version = "0.10.73" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg-if", "foreign-types", "libc", @@ -1948,9 +1541,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.109" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -1959,28 +1552,6 @@ dependencies = [ ] [[package]] -name = "optional" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "978aa494585d3ca4ad74929863093e87cac9790d81fe7aba2b3dc2890643a0fc" - -[[package]] -name = "owo-colors" -version = "4.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26995317201fa17f3656c36716aed4a7c81743a9634ac4c99c0eeda495db0cec" - -[[package]] -name = "page_size" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" -dependencies = [ - "libc", - "winapi", -] - -[[package]] name = "parking" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1988,9 +1559,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -1998,24 +1569,18 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] name = "pem-rfc7468" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2026,89 +1591,49 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - -[[package]] -name = "pest" -version = "2.8.0" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198db74531d58c70a361c42201efde7e2591e976d518caf7662a47dc5720e7b6" -dependencies = [ - "memchr", - "thiserror 2.0.12", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d725d9cfd79e87dccc9341a2ef39d1b6f6353d68c4b33c177febbe1a402c97c5" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db7d01726be8ab66ab32f9df467ae8b1148906685bbe75c82d1e65d7f5b3f841" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pest_meta" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9f832470494906d1fca5329f8ab5791cc60beb230c74815dff541cbd2b5ca0" -dependencies = [ - "once_cell", - "pest", - "sha2", -] +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ + "phf_macros", "phf_shared", + "serde", ] [[package]] -name = "phf_codegen" -version = "0.11.3" +name = "phf_generator" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "phf_generator", + "fastrand", "phf_shared", ] [[package]] -name = "phf_generator" -version = "0.11.3" +name = "phf_macros" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ + "phf_generator", "phf_shared", - "rand", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "phf_shared" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ "siphasher", ] @@ -2153,36 +1678,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] -name = "pmutil" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52a40bc70c2c58040d2d8b167ba9a5ff59fc9dab7ad44771cfde3dcfde7a09c6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] name = "portable-atomic" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - -[[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -2197,10 +1702,20 @@ dependencies = [ ] [[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + +[[package]] name = "prettyplease" -version = "0.2.34" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn", @@ -2208,57 +1723,99 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.95" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] -name = "pymath" -version = "0.0.2" +name = "pyo3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b66ab66a8610ce209d8b36cd0fecc3a15c494f715e0cb26f0586057f293abc9" +checksum = "37a6df7eab65fc7bee654a421404947e10a0f7085b6951bf2ea395f4659fb0cf" dependencies = [ + "indoc", "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", ] [[package]] -name = "quote" -version = "1.0.40" +name = "pyo3-build-config" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "f77d387774f6f6eec64a004eac0ed525aab7fa1966d94b42f743797b3e395afb" dependencies = [ - "proc-macro2", + "target-lexicon", ] [[package]] -name = "r-efi" -version = "5.2.0" +name = "pyo3-ffi" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +checksum = "2dd13844a4242793e02df3e2ec093f540d948299a6a77ea9ce7afd8623f542be" +dependencies = [ + "libc", + "pyo3-build-config", +] [[package]] -name = "radium" -version = "1.1.0" +name = "pyo3-macros" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db0b76288902db304c864a12046b73d2d895cc34a4bb8137baaeebe9978a072c" +checksum = "eaf8f9f1108270b90d3676b8679586385430e5c0bb78bb5f043f95499c821a71" dependencies = [ - "cfg-if", + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", ] [[package]] -name = "radix_trie" -version = "0.2.1" +name = "pyo3-macros-backend" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +checksum = "70a3b2274450ba5288bc9b8c1b69ff569d1d61189d4bff38f8d22e03d17f932b" dependencies = [ - "endian-type", - "nibble_vec", + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + +[[package]] +name = "pyo3-pylogger" +version = "1.9.0" +dependencies = [ + "log", + "phf", + "pyo3", ] [[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 = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2266,7 +1823,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -2276,7 +1833,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -2289,39 +1846,19 @@ dependencies = [ ] [[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.3", -] - -[[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.1", -] - -[[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 1.0.69", + "bitflags 2.10.0", ] [[package]] name = "regex" -version = "1.11.1" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -2331,9 +1868,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.9" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -2342,36 +1879,69 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] -name = "result-like" -version = "0.5.0" +name = "reqwest" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abf7172fef6a7d056b5c26bf6c826570267562d51697f4982ff3ba4aec68a9df" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ - "result-like-derive", + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] -name = "result-like-derive" -version = "0.5.0" +name = "ring" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d6574c02e894d66370cfc681e5d68fedbc9a548fb55b30a96b3f0ae22d0fe5" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ - "pmutil", - "proc-macro2", - "quote", - "syn", + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", ] [[package]] name = "rsa" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +checksum = "40a0376c50d0358279d9d643e4bf7b7be212f1f4ff1da9070a7b54d22ef75c88" dependencies = [ "const-oid", "digest", @@ -2380,7 +1950,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core 0.6.4", + "rand_core", "signature", "spki", "subtle", @@ -2388,73 +1958,6 @@ dependencies = [ ] [[package]] -name = "ruff_python_ast" -version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?tag=0.11.0#2cd25ef6410fb5fca96af1578728a3d828d2d53a" -dependencies = [ - "aho-corasick", - "bitflags 2.9.1", - "compact_str", - "is-macro", - "itertools 0.14.0", - "memchr", - "ruff_python_trivia", - "ruff_source_file", - "ruff_text_size", - "rustc-hash", -] - -[[package]] -name = "ruff_python_parser" -version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?tag=0.11.0#2cd25ef6410fb5fca96af1578728a3d828d2d53a" -dependencies = [ - "bitflags 2.9.1", - "bstr", - "compact_str", - "memchr", - "ruff_python_ast", - "ruff_python_trivia", - "ruff_text_size", - "rustc-hash", - "static_assertions", - "unicode-ident", - "unicode-normalization", - "unicode_names2", -] - -[[package]] -name = "ruff_python_trivia" -version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?tag=0.11.0#2cd25ef6410fb5fca96af1578728a3d828d2d53a" -dependencies = [ - "itertools 0.14.0", - "ruff_source_file", - "ruff_text_size", - "unicode-ident", -] - -[[package]] -name = "ruff_source_file" -version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?tag=0.11.0#2cd25ef6410fb5fca96af1578728a3d828d2d53a" -dependencies = [ - "memchr", - "ruff_text_size", -] - -[[package]] -name = "ruff_text_size" -version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?tag=0.11.0#2cd25ef6410fb5fca96af1578728a3d828d2d53a" - -[[package]] -name = "rustc-demangle" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" - -[[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2462,383 +1965,55 @@ checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" -version = "1.0.7" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustpython" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "cfg-if", - "dirs-next", - "env_logger", - "lexopt", - "libc", - "log", - "ruff_python_parser", - "rustpython-compiler", - "rustpython-pylib", - "rustpython-stdlib", - "rustpython-vm", - "rustyline", + "windows-sys 0.61.2", ] [[package]] -name = "rustpython-codegen" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "ahash", - "bitflags 2.9.1", - "indexmap", - "itertools 0.14.0", - "log", - "malachite-bigint", - "memchr", - "num-complex", - "num-traits", - "ruff_python_ast", - "ruff_source_file", - "ruff_text_size", - "rustpython-compiler-core", - "rustpython-compiler-source", - "rustpython-literal", - "rustpython-wtf8", - "thiserror 2.0.12", - "unicode_names2", -] - -[[package]] -name = "rustpython-common" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "ascii", - "bitflags 2.9.1", - "bstr", - "cfg-if", - "getrandom 0.3.3", - "itertools 0.14.0", - "libc", - "lock_api", - "malachite-base", - "malachite-bigint", - "malachite-q", - "memchr", - "num-traits", - "once_cell", - "parking_lot", - "radium", - "rustpython-literal", - "rustpython-wtf8", - "siphasher", - "unicode_names2", - "widestring", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustpython-compiler" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "ruff_python_ast", - "ruff_python_parser", - "ruff_source_file", - "ruff_text_size", - "rustpython-codegen", - "rustpython-compiler-core", - "rustpython-compiler-source", - "thiserror 2.0.12", -] - -[[package]] -name = "rustpython-compiler-core" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "bitflags 2.9.1", - "itertools 0.14.0", - "lz4_flex", - "malachite-bigint", - "num-complex", - "ruff_source_file", - "rustpython-wtf8", -] - -[[package]] -name = "rustpython-compiler-source" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "ruff_source_file", - "ruff_text_size", -] - -[[package]] -name = "rustpython-derive" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "proc-macro2", - "rustpython-compiler", - "rustpython-derive-impl", - "syn", -] - -[[package]] -name = "rustpython-derive-impl" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "itertools 0.14.0", - "maplit", - "proc-macro2", - "quote", - "rustpython-compiler-core", - "rustpython-doc", - "syn", - "syn-ext", - "textwrap", -] - -[[package]] -name = "rustpython-doc" -version = "0.3.0" -source = "git+https://github.com/RustPython/__doc__?tag=0.3.0#8b62ce5d796d68a091969c9fa5406276cb483f79" +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", ] [[package]] -name = "rustpython-literal" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "hexf-parse", - "is-macro", - "lexical-parse-float", - "num-traits", - "rustpython-wtf8", - "unic-ucd-category", -] - -[[package]] -name = "rustpython-pylib" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "glob", -] - -[[package]] -name = "rustpython-sre_engine" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "bitflags 2.9.1", - "num_enum", - "optional", - "rustpython-wtf8", -] - -[[package]] -name = "rustpython-stdlib" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" -dependencies = [ - "adler32", - "ahash", - "ascii", - "base64", - "blake2", - "bzip2", - "cfg-if", - "crc32fast", - "crossbeam-utils", - "csv-core", - "digest", - "dns-lookup", - "dyn-clone", - "flate2", - "foreign-types-shared", - "gethostname", - "hex", - "indexmap", - "itertools 0.14.0", - "junction", - "libc", - "libz-rs-sys", - "lzma-sys", - "mac_address", - "malachite-bigint", - "md-5", - "memchr", - "memmap2", - "mt19937", - "nix", - "num-complex", - "num-integer", - "num-traits", - "num_enum", - "openssl", - "openssl-probe", - "openssl-sys", - "page_size", - "parking_lot", - "paste", - "pymath", - "rand_core 0.9.3", - "rustix", - "rustpython-common", - "rustpython-derive", - "rustpython-vm", - "schannel", - "sha-1", - "sha2", - "sha3", - "socket2", - "system-configuration", - "termios", - "ucd", - "unic-char-property", - "unic-normal", - "unic-ucd-age", - "unic-ucd-bidi", - "unic-ucd-category", - "unic-ucd-ident", - "unicode-casing", - "unicode_names2", - "uuid", - "widestring", - "windows-sys 0.59.0", - "xml-rs", - "xz2", -] - -[[package]] -name = "rustpython-vm" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" dependencies = [ - "ahash", - "ascii", - "bitflags 2.9.1", - "bstr", - "caseless", - "cfg-if", - "chrono", - "constant_time_eq 0.4.2", - "crossbeam-utils", - "errno", - "exitcode", - "getrandom 0.3.3", - "glob", - "half", - "hex", - "indexmap", - "is-macro", - "itertools 0.14.0", - "junction", - "libc", - "libffi", - "libloading", - "log", - "malachite-bigint", - "memchr", - "memoffset", - "nix", - "num-complex", - "num-integer", - "num-traits", - "num_cpus", - "num_enum", - "once_cell", - "optional", - "parking_lot", - "paste", - "result-like", - "ruff_python_ast", - "ruff_python_parser", - "ruff_source_file", - "ruff_text_size", - "rustix", - "rustpython-codegen", - "rustpython-common", - "rustpython-compiler", - "rustpython-compiler-core", - "rustpython-compiler-source", - "rustpython-derive", - "rustpython-literal", - "rustpython-sre_engine", - "rustyline", - "schannel", - "static_assertions", - "strum", - "strum_macros", - "thiserror 2.0.12", - "thread_local", - "timsort", - "uname", - "unic-ucd-bidi", - "unic-ucd-category", - "unic-ucd-ident", - "unicode-casing", - "unicode_names2", - "which", - "widestring", - "windows", - "windows-sys 0.59.0", - "winreg", + "zeroize", ] [[package]] -name = "rustpython-wtf8" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#4e094eaa55d95e55b22cb0f579f9551d21d379cd" +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ - "ascii", - "bstr", - "itertools 0.14.0", - "memchr", + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] name = "rustversion" -version = "1.0.21" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" - -[[package]] -name = "rustyline" -version = "15.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" -dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "clipboard-win", - "fd-lock", - "home", - "libc", - "log", - "memchr", - "nix", - "radix_trie", - "unicode-segmentation", - "unicode-width", - "utf8parse", - "windows-sys 0.59.0", -] +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" @@ -2847,15 +2022,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - -[[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2866,11 +2032,11 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2881,9 +2047,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "sdl2" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b498da7d14d1ad6c839729bd4ad6fc11d90a57583605f3b4df2cd709a9cd380" +checksum = "2d42407afc6a8ab67e36f92e80b8ba34cbdc55aaeed05249efe9a2e8d0e9feef" dependencies = [ "bitflags 1.3.2", "lazy_static", @@ -2893,9 +2059,9 @@ dependencies = [ [[package]] name = "sdl2-sys" -version = "0.37.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951deab27af08ed9c6068b7b0d05a93c91f0a8eb16b6b816a5e73452a43521d3" +checksum = "3ff61407fc75d4b0bbc93dc7e4d6c196439965fbef8e4a4f003a36095823eac0" dependencies = [ "cfg-if", "libc", @@ -2903,19 +2069,52 @@ dependencies = [ ] [[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] name = "serde" -version = "1.0.219" +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 = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2924,23 +2123,24 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] name = "serde_spanned" -version = "0.6.9" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -2956,17 +2156,6 @@ dependencies = [ ] [[package]] -name = "sha-1" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] name = "sha1" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2989,16 +2178,6 @@ dependencies = [ ] [[package]] -name = "sha3" -version = "0.10.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" -dependencies = [ - "digest", - "keccak", -] - -[[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3006,9 +2185,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" dependencies = [ "libc", ] @@ -3020,7 +2199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -3031,12 +2210,9 @@ checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" -version = "0.4.9" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" @@ -3049,12 +2225,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.5.10" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3096,7 +2272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ "base64", - "bytes 1.10.1", + "bytes", "crc", "crossbeam-queue", "either", @@ -3105,7 +2281,7 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown", + "hashbrown 0.15.5", "hashlink", "indexmap", "log", @@ -3116,7 +2292,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.12", + "thiserror", "tokio", "tokio-stream", "tracing", @@ -3169,9 +2345,9 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags 2.9.1", + "bitflags 2.10.0", "byteorder", - "bytes 1.10.1", + "bytes", "crc", "digest", "dotenvy", @@ -3198,7 +2374,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror", "tracing", "whoami", ] @@ -3211,7 +2387,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags 2.9.1", + "bitflags 2.10.0", "byteorder", "crc", "dotenvy", @@ -3235,7 +2411,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.12", + "thiserror", "tracing", "whoami", ] @@ -3259,22 +2435,16 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.12", + "thiserror", "tracing", "url", ] [[package]] name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - -[[package]] -name = "static_assertions" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stderrlog" @@ -3307,25 +2477,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "strum" -version = "0.27.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64def088c51c9510a8579e3c5d67c65349dcf755e5479ad3d010aa6454e2c32" - -[[package]] -name = "strum_macros" -version = "0.27.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77a8c5abcaf0f9ce05d62342b7d298c346515365c36b673df4ebe3ced01fde8" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn", -] - -[[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3333,9 +2484,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.103" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -3343,14 +2494,12 @@ dependencies = [ ] [[package]] -name = "syn-ext" -version = "0.5.0" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b126de4ef6c2a628a68609dd00733766c3b015894698a438ebdf374933fc31d1" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ - "proc-macro2", - "quote", - "syn", + "futures-core", ] [[package]] @@ -3370,7 +2519,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "core-foundation", "system-configuration-sys", ] @@ -3386,16 +2535,22 @@ dependencies = [ ] [[package]] +name = "target-lexicon" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" + +[[package]] name = "tempfile" -version = "3.20.0" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3408,62 +2563,27 @@ dependencies = [ ] [[package]] -name = "termios" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" -dependencies = [ - "libc", -] - -[[package]] name = "termsize" -version = "1.5.0" +version = "1.9.0" dependencies = [ "libc", "winapi", ] [[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -3480,16 +2600,10 @@ dependencies = [ ] [[package]] -name = "timsort" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "639ce8ef6d2ba56be0383a94dd13b92138d58de44c62618303bb798fa92bdc00" - -[[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -3497,9 +2611,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -3512,26 +2626,25 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", - "bytes 1.10.1", + "bytes", "libc", "mio", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", @@ -3539,240 +2652,177 @@ dependencies = [ ] [[package]] -name = "tokio-stream" -version = "0.1.17" +name = "tokio-native-tls" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ - "futures-core", - "pin-project-lite", + "native-tls", "tokio", ] [[package]] -name = "toml" -version = "0.8.23" +name = "tokio-rustls" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" 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", + "rustls", + "tokio", ] [[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "tracing" -version = "0.1.41" +name = "tokio-stream" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" dependencies = [ - "log", + "futures-core", "pin-project-lite", - "tracing-attributes", - "tracing-core", + "tokio", ] [[package]] -name = "tracing-attributes" -version = "0.1.29" +name = "tokio-util" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ - "proc-macro2", - "quote", - "syn", + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", ] [[package]] -name = "tracing-core" -version = "0.1.34" +name = "toml" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ - "once_cell", + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] -name = "trinitry" -version = "0.2.2" +name = "toml_datetime" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f814008587cd653ef1f92f9caf321e86a6f53899ec118fd50eaed55974863a40" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ - "pest", - "pest_derive", + "serde_core", ] [[package]] -name = "twox-hash" -version = "1.6.3" +name = "toml_parser" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fee6b57c6a41524a810daee9286c02d7752c4253064d0b05472833a438f675" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "cfg-if", - "static_assertions", + "winnow", ] [[package]] -name = "typenum" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" - -[[package]] -name = "ucd" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4fa6e588762366f1eb4991ce59ad1b93651d0b769dfb4e4d1c5c4b943d1159" - -[[package]] -name = "ucd-trie" -version = "0.1.7" +name = "toml_writer" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] -name = "uname" -version = "0.1.1" +name = "tower" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ - "libc", + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", ] [[package]] -name = "unic-char-property" -version = "0.9.0" +name = "tower-http" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-normal" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09d64d33589a94628bc2aeb037f35c2e25f3f049c7348b5aa5580b48e6bba62" -dependencies = [ - "unic-ucd-normal", + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", ] [[package]] -name = "unic-ucd-age" -version = "0.9.0" +name = "tower-layer" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8cfdfe71af46b871dc6af2c24fcd360e2f3392ee4c5111877f2947f311671c" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" [[package]] -name = "unic-ucd-bidi" -version = "0.9.0" +name = "tower-service" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1d568b51222484e1f8209ce48caa6b430bf352962b877d592c29ab31fb53d8c" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] -name = "unic-ucd-category" -version = "0.9.0" +name = "tracing" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8d4591f5fcfe1bd4453baaf803c40e1b1e69ff8455c47620440b46efef91c0" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ - "matches", - "unic-char-property", - "unic-char-range", - "unic-ucd-version", + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", ] [[package]] -name = "unic-ucd-hangul" -version = "0.9.0" +name = "tracing-attributes" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1dc690e19010e1523edb9713224cba5ef55b54894fe33424439ec9a40c0054" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ - "unic-ucd-version", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "unic-ucd-ident" -version = "0.9.0" +name = "tracing-core" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", + "once_cell", ] [[package]] -name = "unic-ucd-normal" -version = "0.9.0" +name = "try-lock" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86aed873b8202d22b13859dda5fe7c001d271412c31d411fd9b827e030569410" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-hangul", - "unic-ucd-version", -] +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "unic-ucd-version" -version = "0.9.0" +name = "typenum" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-bidi" @@ -3781,71 +2831,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] -name = "unicode-casing" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "623f59e6af2a98bdafeb93fa277ac8e1e40440973001ca15cf4ae1541cd16d56" - -[[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-normalization" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] name = "unicode-properties" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] -name = "unicode_names2" -version = "1.3.0" +name = "unindent" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" -dependencies = [ - "phf", - "unicode_names2_generator", -] +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" [[package]] -name = "unicode_names2_generator" -version = "1.3.0" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" -dependencies = [ - "getopts", - "log", - "phf_codegen", - "rand", -] +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" dependencies = [ "form_urlencoded", "idna", @@ -3867,23 +2895,12 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uu_fmt" -version = "1.5.0" +version = "1.9.0" dependencies = [ "unicode-width", ] [[package]] -name = "uuid" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" -dependencies = [ - "atomic", - "js-sys", - "wasm-bindgen", -] - -[[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3912,18 +2929,27 @@ dependencies = [ ] [[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] @@ -3934,35 +2960,35 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" +name = "wasm-bindgen-futures" +version = "0.4.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3970,65 +2996,47 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] [[package]] -name = "which" -version = "7.0.3" +name = "web-sys" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" dependencies = [ - "either", - "env_home", - "rustix", - "winsafe", + "js-sys", + "wasm-bindgen", ] [[package]] name = "whoami" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ - "redox_syscall", + "libredox", "wasite", ] [[package]] -name = "wide" -version = "0.7.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b5576b9a81633f3e8df296ce0063042a73507636cbe956c61133dd7034ab22" -dependencies = [ - "bytemuck", - "safe_arch", -] - -[[package]] -name = "widestring" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" - -[[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4046,11 +3054,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4060,29 +3068,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core 0.52.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", @@ -4093,9 +3082,9 @@ dependencies = [ [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -4104,9 +3093,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -4115,24 +3104,35 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.1.3" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] @@ -4165,6 +3165,24 @@ dependencies = [ ] [[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" @@ -4197,18 +3215,19 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "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]] @@ -4225,9 +3244,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -4243,9 +3262,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -4261,9 +3280,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -4273,9 +3292,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -4291,9 +3310,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -4309,9 +3328,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -4327,9 +3346,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -4345,49 +3364,27 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.55.0" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" [[package]] -name = "winsafe" -version = "0.0.19" +name = "wit-bindgen" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.1", -] +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "xdg" @@ -4396,27 +3393,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fb433233f2df9344722454bc7e96465c9d03bff9d77c248f9e7523fe79585b5" [[package]] -name = "xml-rs" -version = "0.8.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a62ce76d9b56901b19a74f19431b0d8b3bc7ca4ad685a746dfd78ca8f4fc6bda" - -[[package]] -name = "xz2" -version = "0.1.7" +name = "yansi" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -4424,9 +3411,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", @@ -4436,30 +3423,32 @@ dependencies = [ [[package]] name = "yt" -version = "1.5.0" +version = "1.9.0" dependencies = [ "anyhow", "blake3", - "bytes 1.5.0", "chrono", "chrono-humanize", "clap", + "clap_complete", + "colors", "futures", "libmpv2", "log", "notify", - "nucleo-matcher", - "owo-colors", + "pretty_assertions", "regex", + "reqwest", "serde", "serde_json", + "shlex", "sqlx", "stderrlog", "tempfile", "termsize", "tokio", + "tokio-util", "toml", - "trinitry", "url", "uu_fmt", "xdg", @@ -4468,30 +3457,32 @@ dependencies = [ [[package]] name = "yt_dlp" -version = "1.5.0" +version = "1.9.0" dependencies = [ - "indexmap", + "curl", "log", - "rustpython", + "pyo3", + "pyo3-pylogger", + "serde", "serde_json", - "thiserror 2.0.12", + "thiserror", "url", ] [[package]] name = "zerocopy" -version = "0.8.25" +version = "0.8.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" +checksum = "4ea879c944afe8a2b25fef16bb4ba234f47c694565e97383b36f3a878219065c" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.25" +version = "0.8.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" +checksum = "cf955aa904d6040f70dc8e9384444cb1030aed272ba3cb09bbc4ab9e7c1f34f5" dependencies = [ "proc-macro2", "quote", @@ -4521,15 +3512,15 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -4538,9 +3529,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -4549,17 +3540,11 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", "syn", ] - -[[package]] -name = "zlib-rs" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a" diff --git a/Cargo.toml b/Cargo.toml index 470eb58..71b3da2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,17 +11,17 @@ [workspace] resolver = "2" members = [ - "crates/bytes", - "crates/yt_dlp", + "crates/colors", "crates/libmpv2", "crates/libmpv2/libmpv2-sys", "crates/termsize", "crates/yt", + "crates/yt_dlp", ] [workspace.package] edition = "2024" -version = "1.5.0" +version = "1.9.0" rust-version = "1.85.0" authors = ["Benedikt Peetz <benedikt.peetz@b-peetz.de>"] repository = "https://git.vhack.eu/soispha/clients/yt" @@ -31,17 +31,18 @@ description = "A fully featured command line YouTube client" [workspace.dependencies] # Own Crates yt_dlp = { path = "./crates/yt_dlp" } -bytes = { path = "./crates/bytes" } libmpv2 = { path = "./crates/libmpv2" } termsize = { path = "./crates/termsize" } uu_fmt = { path = "./crates/fmt" } +colors = { path = "./crates/colors" } # Shared -log = "0.4.27" -serde = { version = "1.0.219", features = ["derive"] } -serde_json = "1.0.140" -url = { version = "2.5.4", features = ["serde"] } -tokio = { version = "1.45.1", features = [ +pyo3 = { version = "0.27.1", features = ["macros"], default-features = false } +log = { version = "0.4.28", features = ["kv"] } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.145" +url = { version = "2.5.7", features = ["serde"] } +tokio = { version = "1.48.0", features = [ "rt-multi-thread", "macros", "process", @@ -59,10 +60,6 @@ codegen-units = 1 panic = "abort" split-debuginfo = "off" -[profile.dev] -# Otherwise, yt_dlp is just too slow -opt-level = 2 - [workspace.lints.rust] # rustc lint groups https://doc.rust-lang.org/rustc/lints/groups.html warnings = "warn" diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt new file mode 100644 index 0000000..137069b --- /dev/null +++ b/LICENSES/Apache-2.0.txt @@ -0,0 +1,73 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/NEWS.md b/NEWS.md index 4e57d7b..87eaf1f 100644 --- a/NEWS.md +++ b/NEWS.md @@ -14,6 +14,240 @@ If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. - - - +## [v1.9.0](https://git.foss-syndicate.org/soispha/clients/yt/compare/6f0b086f7830f27a7d938bc03db0c73295d745e2..v1.9.0) - 2025-11-30 +#### Features +- (**yt/commands/subs**) Implement disabling subscriptions - ([6f0b086](https://git.foss-syndicate.org/soispha/clients/yt/commit/6f0b086f7830f27a7d938bc03db0c73295d745e2)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Bug Fixes +- (**treewide**) Avoid using deprecated functions or patterns - ([8158bcf](https://git.foss-syndicate.org/soispha/clients/yt/commit/8158bcf6da8163fd35f26b59a08fc7f5a9abce11)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- (**yt/commands/download**) Fix typo - ([3f165f5](https://git.foss-syndicate.org/soispha/clients/yt/commit/3f165f5a118a097758942d534f2e32d6aa889981)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- (**yt/commands/download/progress_hook**) Show title, if already downloaded - ([52955b8](https://git.foss-syndicate.org/soispha/clients/yt/commit/52955b88a26b0dcd344e7619bdb5bef1082f1806)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- (**yt/commands/select/add**) Correct add behaviour - ([7855ca1](https://git.foss-syndicate.org/soispha/clients/yt/commit/7855ca1efa7d6c107c859ea05498f4d79eb8fe46)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- (**yt/commands/status**) Show active subscriptions number - ([2ab1e54](https://git.foss-syndicate.org/soispha/clients/yt/commit/2ab1e54e36f0b9caeb7a0258c75476d3b53b61fd)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Build system +- (**devenv**) Include deno - ([dfe0173](https://git.foss-syndicate.org/soispha/clients/yt/commit/dfe017331b5f90db682ecedeb18817a2e68b0379)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- (**package**) Also include `deno` in the buildInputs - ([7ba761c](https://git.foss-syndicate.org/soispha/clients/yt/commit/7ba761c4af3a204837521535707025b79461c31b)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- (**package**) Use `ffmpeg-headless` instead of `ffmpeg` - ([9f82f36](https://git.foss-syndicate.org/soispha/clients/yt/commit/9f82f3691336f616379c00ea14e3b5b91bd44fc2)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- (**treewide**) Update - ([668bcef](https://git.foss-syndicate.org/soispha/clients/yt/commit/668bcef7684093859f0d8931f89cd015260d99ee)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- (**update.sh**) Also work, when the cwd directory changed - ([6d052bc](https://git.foss-syndicate.org/soispha/clients/yt/commit/6d052bc60730818881da4c54b7ec27dca1862fae)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) + +- - - + +## [v1.8.0](https://git.foss-syndicate.org/soispha/clients/yt/compare/4276f312926c0b166967066ca06887d42e362561..v1.8.0) - 2025-07-24 +#### Bug Fixes +- **(crates/yt)** Correct imports - ([ac99460](https://git.foss-syndicate.org/soispha/clients/yt/commit/ac99460ed65126d32160ac12641f211d9162db91)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt)** **Always** honor the `config.global.display_colors` config setting - ([e2e88fd](https://git.foss-syndicate.org/soispha/clients/yt/commit/e2e88fdabe9bfb3ed236983e6e737b9790d50cd2)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt)** Add stuff that was missed - ([f09ad6c](https://git.foss-syndicate.org/soispha/clients/yt/commit/f09ad6c85f8d4f3de73de895f370b5773b63177a)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/cli)** Use the correct `--plackback-speed` option name - ([bda2fd6](https://git.foss-syndicate.org/soispha/clients/yt/commit/bda2fd6e886ae9f699708fd6d6848d9a11bba423)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/commands)** Add the actual `implm` wrapper - ([1fdae07](https://git.foss-syndicate.org/soispha/clients/yt/commit/1fdae07aa66b298db23cb5f85b41da4a4f0c539b)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/commands/database)** Correctly format the default for `--kind` - ([51bec16](https://git.foss-syndicate.org/soispha/clients/yt/commit/51bec161d856735bb24545055b9414a0fb8ef9b6)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/commands/select)** Allow configuring the `yt select url` opener - ([994971a](https://git.foss-syndicate.org/soispha/clients/yt/commit/994971a56d50e8bb6d01583ac71fee90463e855d)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/config)** Avoid module name re-use in `watch` config - ([dc09734](https://git.foss-syndicate.org/soispha/clients/yt/commit/dc097347be5077b56a70f79c6f06b56a919232ff)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/config)** Ensure that the download_dir is created - ([59608b3](https://git.foss-syndicate.org/soispha/clients/yt/commit/59608b3ee2473cc2d2b35b286168c932f0c78f2d)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/db/insert/maintenance)** Re-init - ([ee48fa7](https://git.foss-syndicate.org/soispha/clients/yt/commit/ee48fa727afd1927f20b5d0491344f4afb03bd2e)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/db/insert/playlist)** Account for playlist_len == 0 - ([2c0e68f](https://git.foss-syndicate.org/soispha/clients/yt/commit/2c0e68f39296e5ab180e28d90c3076cf71dfc080)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/downloader)** Correctly treat the download as blocking - ([507c961](https://git.foss-syndicate.org/soispha/clients/yt/commit/507c9611232e7b820789ec776159c703acd499ab)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/select)** Correctly open the persistent file in `select split` - ([e209cce](https://git.foss-syndicate.org/soispha/clients/yt/commit/e209cceacac0d6ee4051d8bb3dce0ad97f2f474d)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/storage/db/videos/comments)** Don't always associate a reply with its base - ([98c3984](https://git.foss-syndicate.org/soispha/clients/yt/commit/98c3984e1e15a3b98d1eeb191809d1b1ae7be119)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/storage/migrate)** Merge the `videos` and `video_options` tables - ([e76c029](https://git.foss-syndicate.org/soispha/clients/yt/commit/e76c029e3392283fe0c230bba01a236b71089bbe)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/update)** Remove the `--grouped` update support - ([32cb3cd](https://git.foss-syndicate.org/soispha/clients/yt/commit/32cb3cd0ac414490e9bd614f8faa59a5bb9ca4e2)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/{commands/playlist,videos/format_video})** Correctly calculate watch percent - ([ca62bbb](https://git.foss-syndicate.org/soispha/clients/yt/commit/ca62bbb3e2455d4d832b4b359e7247deebf7f5c1)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(nix/package)** Update to include the newest changes - ([a201b66](https://git.foss-syndicate.org/soispha/clients/yt/commit/a201b665bb05ec90622d9850863367e27881e036)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(treewide)** Use `json_try_get!` instead of `json.get(..).map(|| ..)` - ([a9fddbe](https://git.foss-syndicate.org/soispha/clients/yt/commit/a9fddbeebf428eb57c60afab96fbbd38629a636e)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Build system +- **(.cargo/cargo.toml)** Remove pointless `PYO3_PATH` - ([4276f31](https://git.foss-syndicate.org/soispha/clients/yt/commit/4276f312926c0b166967066ca06887d42e362561)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(.envrc)** Set the `PYO3_PYTHON` variable again - ([2e4261a](https://git.foss-syndicate.org/soispha/clients/yt/commit/2e4261a0d28f11abc0bf4df55a9775ad045cd028)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(Cargo.lock)** Update - ([cf8ca00](https://git.foss-syndicate.org/soispha/clients/yt/commit/cf8ca007dadf71af9163d40f3ca741992ad80a52)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(Cargo.toml)** Update to include new changes - ([72445e1](https://git.foss-syndicate.org/soispha/clients/yt/commit/72445e192409d6d628f9af91fa08f3a69c02e459)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/Cargo.toml)** Remove now pointless `owo-colors` dep - ([211e0bd](https://git.foss-syndicate.org/soispha/clients/yt/commit/211e0bd88d3902e5226022974e3263f34e3a1aa3)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(treewide)** Update - ([58dfb43](https://git.foss-syndicate.org/soispha/clients/yt/commit/58dfb436de0ae70540574195dc113f07e97483b4)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **({update.sh,crates/{libmpv2,yt_dlp}/update.sh})** Remove pointless update instructions - ([ab8605a](https://git.foss-syndicate.org/soispha/clients/yt/commit/ab8605a62fc88255dbcc079a16102f17dbe5e704)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Documentation +- **(contrib/external_commands_script.sh)** Init - ([ebc1fff](https://git.foss-syndicate.org/soispha/clients/yt/commit/ebc1fff767e4a94de1f9f4db27dc955d0748a4dd)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/libmpv2)** Correctly format doc-test - ([d6e9378](https://git.foss-syndicate.org/soispha/clients/yt/commit/d6e937812c26368c594b1e0be036f167be2e7e5d)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Features +- **(crates/yt)** Separate all commands from their implementation code - ([c4524db](https://git.foss-syndicate.org/soispha/clients/yt/commit/c4524db090d2d31af8bc3e7ec64c1ea9f5ec72aa)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt)** Support a `--format` argument for most commands with output - ([d6e1711](https://git.foss-syndicate.org/soispha/clients/yt/commit/d6e17110dae3f1afe35415065e9a08d0f90f2592)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/commands/cache)** Init - ([a902e8e](https://git.foss-syndicate.org/soispha/clients/yt/commit/a902e8e273262b08a7dbbd3c611d7153d4fa9b4e)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/commands/database)** Init, to show the txn_log - ([761560f](https://git.foss-syndicate.org/soispha/clients/yt/commit/761560fe7b3d2e5cbc1fd942ea7bb84d440459fe)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/commands/show)** Also provide thumbnail and info screen - ([5ccf617](https://git.foss-syndicate.org/soispha/clients/yt/commit/5ccf61730945e98f36a3e8621b22cfd3be4ab4eb)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/commands/watch/mpv_commands)** Hook-up the new show commands - ([99d4f68](https://git.foss-syndicate.org/soispha/clients/yt/commit/99d4f688868ee664470b13a0d61ac65832263bab)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/select)** Print the currently processed line as progress - ([d8000c8](https://git.foss-syndicate.org/soispha/clients/yt/commit/d8000c8591a4886023aaf52b9298147c67449932)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/storage)** Migrate inserts to operations and use methods - ([e4d6fc0](https://git.foss-syndicate.org/soispha/clients/yt/commit/e4d6fc04f60cf7b8173df7f261428b25d009ba39)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/storage/db/insert)** Track all inserted operations - ([f6eb32a](https://git.foss-syndicate.org/soispha/clients/yt/commit/f6eb32ae50a21d0d3b0ed0e992f3871d59966743)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/subscribe)** Support a `--no-check` argument - ([45a4507](https://git.foss-syndicate.org/soispha/clients/yt/commit/45a45074e1afe0254d7e732d03f5ba29e6b53030)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/update)** Make the concurrency configurable - ([6095c67](https://git.foss-syndicate.org/soispha/clients/yt/commit/6095c678c42c20810eac0dd6f4fa371199f3ad7a)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/watch)** Make the time between watch progress saves configurable - ([7dc1f2d](https://git.foss-syndicate.org/soispha/clients/yt/commit/7dc1f2d302b176dc11d2680d7a0fd8d710da6e23)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/watch)** Support `--headless` and `--provide-ipc-socket` flags - ([8f6d3d0](https://git.foss-syndicate.org/soispha/clients/yt/commit/8f6d3d07f192c3be5348493358c82b697ee26392)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Miscellaneous Chores +- **(crates/yt/Cargo.toml)** Add `pretty-assertions` for tests - ([9b018e2](https://git.foss-syndicate.org/soispha/clients/yt/commit/9b018e276a216e64ae1ebb446ab49a67b7960bff)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(treewide)** Add missing license headers - ([71351b8](https://git.foss-syndicate.org/soispha/clients/yt/commit/71351b83af2cfb142ad536936f613a66059244f6)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Performance Improvements +- **(crates/yt/db/extractor_hash/realize)** Allow passing in a `all_hashes` - ([c254ed0](https://git.foss-syndicate.org/soispha/clients/yt/commit/c254ed0c7d098cf3224e004a4b13a11632e432a3)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Refactoring +- **(crates/bytes)** Move into yt - ([66d56d7](https://git.foss-syndicate.org/soispha/clients/yt/commit/66d56d7a2b64c5ed13860d809d9bf35d86292df2)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/colors)** Don't expose the custom colours module - ([57520fd](https://git.foss-syndicate.org/soispha/clients/yt/commit/57520fd2c02ec4e09d0714b870de6968ac7f6378)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt)** Allow `missing_panic_docs` and use expect - ([e60cf47](https://git.foss-syndicate.org/soispha/clients/yt/commit/e60cf473b3ba1b5c0295d69e93e7d266f62ed60a)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt)** Use the new storage layer - ([95ccd01](https://git.foss-syndicate.org/soispha/clients/yt/commit/95ccd01d26c6664c9917332d4f19c949dfb905cd)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt)** Make every `pub` item `pub(crate)` - ([c3abafd](https://git.foss-syndicate.org/soispha/clients/yt/commit/c3abafd4878df886dc8765a048cb0b70f282f1f3)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/commands)** Restrict visibility to itself - ([ed9956b](https://git.foss-syndicate.org/soispha/clients/yt/commit/ed9956b784b087f1610f472954339990f79eec49)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/comments)** Remove dead code - ([860798a](https://git.foss-syndicate.org/soispha/clients/yt/commit/860798a28518b8dccd28433b34102961a0e09045)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/config)** Always use imported paths in config dec - ([4632928](https://git.foss-syndicate.org/soispha/clients/yt/commit/46329283ef91d023c07aecd856889d496dc69471)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/config)** Use a macro to generate the config parsing code - ([9753705](https://git.foss-syndicate.org/soispha/clients/yt/commit/97537059b44f5ed336a915a1ba805be215cf6566)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/constants)** Remove empty module - ([2b61c8b](https://git.foss-syndicate.org/soispha/clients/yt/commit/2b61c8bbb32524ac63ff8e7e814091707a03ef4e)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/db/insert::Commitable)** Make `Debug` a dependency - ([7ec9b54](https://git.foss-syndicate.org/soispha/clients/yt/commit/7ec9b549510db0e96d5d0b9019ee0e6689e3952d)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/download/progress_hook)** Use `json_{get,cast}` and owu-colors - ([3c11d06](https://git.foss-syndicate.org/soispha/clients/yt/commit/3c11d068e438da22375a9d0af9abb81c738ebcbb)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/src/ansi_escape_codes)** Use better name for `erase_in_display_from_cursor` - ([3737b65](https://git.foss-syndicate.org/soispha/clients/yt/commit/3737b653725c2e6ad5323cf6caafcd6e59a938f6)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt_dlp)** Port to `pyo3` again - ([82277ca](https://git.foss-syndicate.org/soispha/clients/yt/commit/82277ca7513eff82365ed54fe9836aae5bd45fe1)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Style +- **(treewide)** Format - ([19d3c02](https://git.foss-syndicate.org/soispha/clients/yt/commit/19d3c02396ac1c00742e50e60a0a840fbfade1ac)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Tests +- **(crates/libmpv2)** Avoid compiling a doc-test - ([8f2dc82](https://git.foss-syndicate.org/soispha/clients/yt/commit/8f2dc825461ad3006c3f8bdfcb9f3b5048fdb861)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt)** Add basic integration tests - ([234b910](https://git.foss-syndicate.org/soispha/clients/yt/commit/234b9105e097fb63f636bc05ac2f471c001c3aac)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/testenv/run/run_piped)** Finalize the second command after the first one - ([1ffbeef](https://git.foss-syndicate.org/soispha/clients/yt/commit/1ffbeefff7f3817e2dec72ec06c2f139dbaca7ac)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/tests/_testenv)** Store `Child`s instead of PIDs - ([7cc99ec](https://git.foss-syndicate.org/soispha/clients/yt/commit/7cc99ec385857dc6e33072f9e7865ee2a93a8d69)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/tests/_testenv::init)** Use appropriate atomic u64 type - ([4e09daf](https://git.foss-syndicate.org/soispha/clients/yt/commit/4e09dafa7a213ce2fcafe7d810cf3ae1f5f9bdb0)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(crates/yt/tests/watch/focus_switch.rs)** This test simply lacks its purpose - ([889e0b1](https://git.foss-syndicate.org/soispha/clients/yt/commit/889e0b10424542246de14b6a3dcadf56cd46424b)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) + +- - - + +## [v1.7.1](https://git.foss-syndicate.org/soispha/clients/yt/compare/69d1f92c9ff5e76c0c2b91641962f9e21afe2ded..v1.7.1) - 2025-06-28 +#### Bug Fixes +- **(yt/download/progress_hook)** Remove superfluous apostrophes - ([92e3367](https://git.foss-syndicate.org/soispha/clients/yt/commit/92e3367fbc93b67b2db9d7296630d299294e4c13)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/select/cmds/add)** Use the correct names for the download type - ([cd03c0b](https://git.foss-syndicate.org/soispha/clients/yt/commit/cd03c0b9501c596c3abcd41e07105a3def20e2dd)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/select/cmds/add)** Don't print the title as value, cast it first - ([247dabc](https://git.foss-syndicate.org/soispha/clients/yt/commit/247dabc7905d9deecc86ac11404b5665042c60f1)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/select/split)** Also use persist the selection file - ([72d33c1](https://git.foss-syndicate.org/soispha/clients/yt/commit/72d33c13a8a715a5a12d804464d887c2376701ad)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/subscribe)** Don't hard-error on failed subscribe, if it was not specified - ([c5ad75c](https://git.foss-syndicate.org/soispha/clients/yt/commit/c5ad75c9176990da906c9ef3086e8efe25037fd9)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/{subscribe,storage/subscriptions})** Fix more instances of the capitalize Playlist type - ([2cee354](https://git.foss-syndicate.org/soispha/clients/yt/commit/2cee35477e4e4e2b3b6aeb094217e0419bdcaed4)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp)** Polyfill missing rustpython features used in urllib3 - ([8c65652](https://git.foss-syndicate.org/soispha/clients/yt/commit/8c6565295986b704f36a9174d05deacc6925b7e4)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Build system +- **(nix/package)** Update the git hashes after the update - ([f74251b](https://git.foss-syndicate.org/soispha/clients/yt/commit/f74251b5191963b979a23fa16555712aa83817ba)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(nix/package)** Add all required files to the src allow list - ([09d5c9c](https://git.foss-syndicate.org/soispha/clients/yt/commit/09d5c9c93c786a309328564d74c58e8be1dcfa5b)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **({flake,Cargo}.lock)** Update - ([033b0d3](https://git.foss-syndicate.org/soispha/clients/yt/commit/033b0d3ce9eef96827a3f33e4aa5f108e98e4878)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **({nix,flake})** Add missing buildInputs - ([bc1f78f](https://git.foss-syndicate.org/soispha/clients/yt/commit/bc1f78fde9aa45a3d53a36bbfab11178f6f8f684)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Miscellaneous Chores +- **(yt/storage/video_database/set)** Apply some of clippy's suggestions - ([d451984](https://git.foss-syndicate.org/soispha/clients/yt/commit/d451984d34c74190340cc82d203565c7e4747908)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/package_hacks)** Add missing license headers - ([c3a8c10](https://git.foss-syndicate.org/soispha/clients/yt/commit/c3a8c104515b47597f8b72eeabc7dcd266ec0316)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Revert +- "build(treewide): Update" - ([69d1f92](https://git.foss-syndicate.org/soispha/clients/yt/commit/69d1f92c9ff5e76c0c2b91641962f9e21afe2ded)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) + +- - - + +## [v1.7.0](https://git.foss-syndicate.org/soispha/clients/yt/compare/382eae56dc3ecaed91b9fd8db1c830d5dec49e44..v1.7.0) - 2025-06-24 +#### Bug Fixes +- **(yt/update/grouped)** Don't drop the verbosity level - ([28d4c61](https://git.foss-syndicate.org/soispha/clients/yt/commit/28d4c61bb0b3b6b20d57a0dd970af83265bb0ad2)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/post_processors/dearrow)** Don't try to access the drained vec - ([9b4f09c](https://git.foss-syndicate.org/soispha/clients/yt/commit/9b4f09cf736e68bdbd246dca17d7a3c6b8eba3ea)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Build system +- **(treewide)** Update - ([e6aa91c](https://git.foss-syndicate.org/soispha/clients/yt/commit/e6aa91c56ca51a8593b9a58ec5746741888db7f9)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/Cargo.toml)** Pin git dependencies - ([772f169](https://git.foss-syndicate.org/soispha/clients/yt/commit/772f16902d75e3d6ae211b9ef3977316708698c4)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Features +- **(yt/cli)** Also add completion for subscription names - ([382eae5](https://git.foss-syndicate.org/soispha/clients/yt/commit/382eae56dc3ecaed91b9fd8db1c830d5dec49e44)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/version)** Add the (rust)python version again - ([84175a0](https://git.foss-syndicate.org/soispha/clients/yt/commit/84175a03a71918497aa0c8ee3444736d771cccff)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) + +- - - + +## [v1.6.1](https://git.foss-syndicate.org/soispha/clients/yt/compare/3f6ef87fc31581215cb00d56462b35e07b7a1f28..v1.6.1) - 2025-06-17 +#### Bug Fixes +- **(package)** Set the PYTHONPATH ourselves - ([ea77b89](https://git.foss-syndicate.org/soispha/clients/yt/commit/ea77b898e5dfb2a7900a87a1bb73167a6e1a140c)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp)** Typos in strings - ([987cff2](https://git.foss-syndicate.org/soispha/clients/yt/commit/987cff2b5996cc86069dc1d9cbb0f465c32d391c)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/)** Include the frozen python stdlib - ([3f6ef87](https://git.foss-syndicate.org/soispha/clients/yt/commit/3f6ef87fc31581215cb00d56462b35e07b7a1f28)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/post_processing/dearrow)** Correctly type the `CasualVote` field - ([528c2d4](https://git.foss-syndicate.org/soispha/clients/yt/commit/528c2d4a4842647da3a91a034c810c44ebf9b949)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/post_processors)** Register in python - ([1a6d363](https://git.foss-syndicate.org/soispha/clients/yt/commit/1a6d3639e6fddb731735554d407d1eea77f053c6)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/post_processors/dearrow)** Migrate to curl for api requests - ([0a17001](https://git.foss-syndicate.org/soispha/clients/yt/commit/0a1700131341c5dac55a395ce5ccdac4f8ec0c9e)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Build system +- **(flake)** Teach the flake about the new package.nix location - ([c4bc9fd](https://git.foss-syndicate.org/soispha/clients/yt/commit/c4bc9fdfde2852cc0f5efbb9bed327f16a6fe275)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **({Cargo,flake}.lock)** Update - ([2aaa919](https://git.foss-syndicate.org/soispha/clients/yt/commit/2aaa919101be7a4fa42ac76a5f2f491689319e39)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Miscellaneous Chores +- **(treewide)** Assure that `nix fmt` and `reuse lint` are happy - ([d847968](https://git.foss-syndicate.org/soispha/clients/yt/commit/d847968fab7dc55b30f8a137dbce2bae07112c82)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Refactoring +- **(nix/package)** Avoid the duplicated `package` name - ([9fbbd3e](https://git.foss-syndicate.org/soispha/clients/yt/commit/9fbbd3e71f2d7286e9ef1cbdbdea4020bd511308)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp)** Split the big `lib.rs` file up - ([8d6eb78](https://git.foss-syndicate.org/soispha/clients/yt/commit/8d6eb786ee99e7b0c36736152e30a5f61cd34167)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/logging)** Avoid adding to the `__all__` list - ([e0120c0](https://git.foss-syndicate.org/soispha/clients/yt/commit/e0120c08672009f8d4445eebef8efb22ddae5fb3)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/progress_hook)** Use public api via `__priv` module - ([74ecf0e](https://git.foss-syndicate.org/soispha/clients/yt/commit/74ecf0ea1564343905a96dbd14826700762ec825)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) + +- - - + +## [v1.6.0](https://git.foss-syndicate.org/soispha/clients/yt/compare/07db485f9c5206fbcfe2a5f9db28a9587edc6d2b..v1.6.0) - 2025-06-16 +#### Bug Fixes +- **(libmpv2-sys)** Avoid generating comments, that confuse rustdoc - ([0c0e00d](https://git.foss-syndicate.org/soispha/clients/yt/commit/0c0e00da2c21c4b8325fa6145c808e9df0df0834)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(scripts/mkdb.sh)** Also use the `$DATABASE_URL` variable as source source - ([45e5500](https://git.foss-syndicate.org/soispha/clients/yt/commit/45e55007aa13b1ec24af4c543bc3b8699710301c)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/cli)** Remove duplicated short flag key (help also uses 'h') - ([c1122d6](https://git.foss-syndicate.org/soispha/clients/yt/commit/c1122d6ab31548aff9bf8aaa4a855a771355c8e9)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/download/get_file_size)** Correct deal with `filesize_approx` = Null - ([680f811](https://git.foss-syndicate.org/soispha/clients/yt/commit/680f811adc83554cfbaff56d8b50501786a949e2)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/downloader/progress_hook)** Silence clippy warnings - ([65ba5d7](https://git.foss-syndicate.org/soispha/clients/yt/commit/65ba5d738dcfeaecb398e246e0db5d7c4bf04b99)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/select/selection_file/duration)** Improve the duration parser - ([9e1c1ae](https://git.foss-syndicate.org/soispha/clients/yt/commit/9e1c1aec0548a6482e23ceac4e1265ef8baf8023)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/storage/migrate)** Correct the two to three migration script - ([7694496](https://git.foss-syndicate.org/soispha/clients/yt/commit/7694496efa621466e327b9c00fe1c5cc092ccc1f)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/storage/migrate)** Correctly state the upgrade to the topmost version - ([449c4c2](https://git.foss-syndicate.org/soispha/clients/yt/commit/449c4c26c91400e56e0e685958b825b3f02f4e40)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/storage/migrate)** Improve error messages - ([3a16edd](https://git.foss-syndicate.org/soispha/clients/yt/commit/3a16edde524f881c8955026350243a1b4d54d89b)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/storage/migrate)** Account for the fact that DbVersions::Empty means no Version - ([d1f004c](https://git.foss-syndicate.org/soispha/clients/yt/commit/d1f004ce48caf90ab4f3ec1d0bbb588c9cbf0fe9)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/storage/video_database/set)** Reset the `is_focused` flag - ([07db485](https://git.foss-syndicate.org/soispha/clients/yt/commit/07db485f9c5206fbcfe2a5f9db28a9587edc6d2b)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/subscribe)** Deal with moved url value - ([fb00ecf](https://git.foss-syndicate.org/soispha/clients/yt/commit/fb00ecf745c1bd12e026faabf235a75c2c775a3a)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/update)** Also handle the newly introduced error conditions - ([a7e1a2d](https://git.foss-syndicate.org/soispha/clients/yt/commit/a7e1a2d7475fc1304ef7b33aa2f170f8232bd1d8)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/update)** Correct the progress display in `--grouped` mode - ([35f400c](https://git.foss-syndicate.org/soispha/clients/yt/commit/35f400cebca70325e7e999f15dcaa562dbc78f25)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/update)** Avoid printing all the subscriptions that are not updated - ([810c0d3](https://git.foss-syndicate.org/soispha/clients/yt/commit/810c0d3e75287c15e8baf210f89c807a21d3acee)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/update/video_entry_to_video)** Cast the json objects - ([b6a57c5](https://git.foss-syndicate.org/soispha/clients/yt/commit/b6a57c5cad1ee7df56dad5ccb2317f936e682bbe)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/version)** Use yt_dlp's native python version imply - ([22f74fc](https://git.foss-syndicate.org/soispha/clients/yt/commit/22f74fc43b004045d13b0184ae075dea0ebc8eda)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/watch/playlist)** Workaround terminals, that treat 0 as 1 - ([b3be18a](https://git.foss-syndicate.org/soispha/clients/yt/commit/b3be18a0bfb55135135c9769ac531c098ca4d26c)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/{se,}dowa)** Don't exit completely, if the downloader fails - ([b70dd45](https://git.foss-syndicate.org/soispha/clients/yt/commit/b70dd458615bbad99cf05dbde3dc831a9922ba21)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp)** Avoid writing the json output to disk - ([c8601d6](https://git.foss-syndicate.org/soispha/clients/yt/commit/c8601d67c2dd67ed3ae4465fbf3906fa2cf15a98)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/json_{cast,get})** Improve error reporting - ([c4f8c14](https://git.foss-syndicate.org/soispha/clients/yt/commit/c4f8c14b5636055a2973afe0d5ef6494d97a1a76)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Build system +- **(.envrc)** Also disable ytdlp plugins by default - ([1b8113a](https://git.foss-syndicate.org/soispha/clients/yt/commit/1b8113a72161e5d5f1f7a8328265f8075fc3491a)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(.envrc)** Remove outdated env variables - ([e51139d](https://git.foss-syndicate.org/soispha/clients/yt/commit/e51139da51bbb8725614356bd173d1d66af7f74f)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(cog.toml)** Use the correct remote url - ([4e2aeec](https://git.foss-syndicate.org/soispha/clients/yt/commit/4e2aeec877ec9083de5116bcca8da039389b9f09)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(cog.toml)** Use correct username - ([b9957a2](https://git.foss-syndicate.org/soispha/clients/yt/commit/b9957a2dc50b02f1df8bcb2dc3ddcc3c081b94d3)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(flake)** Document for what the `CLANG_*` env vars are needed - ([d03e537](https://git.foss-syndicate.org/soispha/clients/yt/commit/d03e5374a238dc2b701c259bbd5ade91c6b4a9ff)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(flake)** Remove `flake-utils` - ([d21e1ac](https://git.foss-syndicate.org/soispha/clients/yt/commit/d21e1ac26c5e57f7e5f9cb2fea937b807118187b)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(flake)** Switch to `nixpkgs-unstable-small` - ([1a807d2](https://git.foss-syndicate.org/soispha/clients/yt/commit/1a807d25bd1a47fb81b538a1638514cedb928148)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(flake)** Adapt the dev env to yt_dlp's new dependencies - ([b8682b4](https://git.foss-syndicate.org/soispha/clients/yt/commit/b8682b478a3a2322a370cc8eabf46d20d00e8c37)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(flake)** Add `git-bug` to the devshell - ([ebcd3e1](https://git.foss-syndicate.org/soispha/clients/yt/commit/ebcd3e153e01bd1432b583b2a09569ba2017b8ed)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(package/package.nix)** Update to the new build requirements - ([f590cef](https://git.foss-syndicate.org/soispha/clients/yt/commit/f590cef92da3931fae1607c6964b8125ab2f6307)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(treewide)** Update - ([2380d7d](https://git.foss-syndicate.org/soispha/clients/yt/commit/2380d7d7fdfdda91c26e8027f41aa6788f3590e0)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(treewide)** Update - ([0791777](https://git.foss-syndicate.org/soispha/clients/yt/commit/0791777665fe99d02b5e4aaaa43ca3483712dac9)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(treewide)** Update - ([fa79bd7](https://git.foss-syndicate.org/soispha/clients/yt/commit/fa79bd7eef3824ad208984df9cc7784bdab5ba2b)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(treewide)** Update - ([47754f5](https://git.foss-syndicate.org/soispha/clients/yt/commit/47754f54b978e7ed66ccd29c866fabe28607997e)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **({flake,Cargo}.lock)** Update - ([9c7dfa7](https://git.foss-syndicate.org/soispha/clients/yt/commit/9c7dfa7a8ca71bd5067741917a6f96061290976b)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Documentation +- **(yt/update)** Add comment about the `unsmuggle_url` invocation - ([6c47d93](https://git.foss-syndicate.org/soispha/clients/yt/commit/6c47d93c983b8807032220e107ac2f686abb14e2)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/watch/playlist_handler/client_messages)** Add TODO about `current_exe` - ([13a0621](https://git.foss-syndicate.org/soispha/clients/yt/commit/13a062150e4efaf4b87d9213cf68b5a4eabb0235)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp)** Fix typo in `Cargo.toml`'s description - ([848270e](https://git.foss-syndicate.org/soispha/clients/yt/commit/848270ed0d9ed0409fe563a130e2913d9dfcc897)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Features +- **(yt/cli)** Add support for command line completions - ([e635ee7](https://git.foss-syndicate.org/soispha/clients/yt/commit/e635ee79a4ec0d30dca271cc269fee40150ea821)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/select)** Support a directory selection process - ([e2d5dc6](https://git.foss-syndicate.org/soispha/clients/yt/commit/e2d5dc6a9f000a875c3f2a100f660adc2a43275a)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/select/split)** Make sorting configurable - ([8b644e4](https://git.foss-syndicate.org/soispha/clients/yt/commit/8b644e4e0e058a003984c02d48e829de437145c6)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/status)** Show the percentage of videos that were actually watched - ([ec4e0c9](https://git.foss-syndicate.org/soispha/clients/yt/commit/ec4e0c91d33b2a8c11b71d4cdb1edeaa44ce8247)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/storage/videos)** Validate in DB, that is_focused is UNIQUE - ([cf16b93](https://git.foss-syndicate.org/soispha/clients/yt/commit/cf16b93b563daee88b3bda4b30666b1b0766a8b0)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/update)** Print a nice progress number - ([c04d530](https://git.foss-syndicate.org/soispha/clients/yt/commit/c04d530a1a9e09dd26adc4116959e5481b970bc6)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/update)** Support grouped updates - ([8a42c83](https://git.foss-syndicate.org/soispha/clients/yt/commit/8a42c835a0dd1fcaa3475938d9442199d57acf75)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/update)** Specify subscriptions to update as positional args - ([51bbd90](https://git.foss-syndicate.org/soispha/clients/yt/commit/51bbd90ab1f08c9056c4e5799e3abba568ae75c9)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/videos/list)** Replace the nucleo matcher with a simple `contains` - ([c0a3b61](https://git.foss-syndicate.org/soispha/clients/yt/commit/c0a3b61fb344a5ca86cae1c31d2e42fbe56b6726)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp)** Support a DeArrow post processor - ([ab61a4e](https://git.foss-syndicate.org/soispha/clients/yt/commit/ab61a4e47a955dd4a5dabeef3ade1b85f6576b84)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **({yt/update,yt_dlp})** Use yt_dlp errors again - ([078dfa0](https://git.foss-syndicate.org/soispha/clients/yt/commit/078dfa09a40a384b5cb8cf8cffd9b68cc9678556)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **({yt_dlp,yt})** Migrate from pyo3 to rustpython - ([69145b4](https://git.foss-syndicate.org/soispha/clients/yt/commit/69145b4deed4fe512239a9f88e6af69d3b8c0309)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Miscellaneous Chores +- **(treewide)** Add missing copyright headers - ([fd029a6](https://git.foss-syndicate.org/soispha/clients/yt/commit/fd029a65d43e1eb935b470b88893c16c30c19746)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(treewide)** Migrate to rust edition 2024 - ([8be7171](https://git.foss-syndicate.org/soispha/clients/yt/commit/8be717167ed77f5a1021fa0825b386674c5c1a39)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/wrappers/info_json)** Add additional missing field - ([8ef4cf9](https://git.foss-syndicate.org/soispha/clients/yt/commit/8ef4cf92635003fb79263d22126289d788e34633)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Performance Improvements +- **(yt/update/updater)** Acknowledge, that `yt_dlp` has a sync API - ([ab56f25](https://git.foss-syndicate.org/soispha/clients/yt/commit/ab56f2550d5086ccd1c6981b62081b70743a1f2c)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Refactoring +- **(yt)** Move to `crates/yt` - ([394d4f7](https://git.foss-syndicate.org/soispha/clients/yt/commit/394d4f7d105dadd7b516f198b0d6a9dda2d3f1a5)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt)** Consolidate the multiple ANSI escape code wrapper functions - ([efc35b5](https://git.foss-syndicate.org/soispha/clients/yt/commit/efc35b5bd76bf4e4aab6750ead45713a79e851f9)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/select)** Split the `select::select` function up - ([fb49841](https://git.foss-syndicate.org/soispha/clients/yt/commit/fb49841e1ec14b3ab2de981e439d4f10f5494cf5)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/select/selection_file)** Migrate from `trinitry` to `shlex` - ([56011be](https://git.foss-syndicate.org/soispha/clients/yt/commit/56011be94c09828b104008cb7bf3a19177bc1631)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/storage/migrate)** Factor out duplicated code into macro - ([137339d](https://git.foss-syndicate.org/soispha/clients/yt/commit/137339d1d2924da764c54517fcc6d5d11d46a69d)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/storage/migrate/sql)** Use predictable SQL paths - ([420f9c8](https://git.foss-syndicate.org/soispha/clients/yt/commit/420f9c87abe3a3480a2345cbad5ec427636b2cb5)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp)** Remove the unneeded `async` from the public functions - ([5b5caee](https://git.foss-syndicate.org/soispha/clients/yt/commit/5b5caee512dd82bc5106e69259ba916cd143deda)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/lib)** De-duplicate the info json sanitize code - ([e46ab9b](https://git.foss-syndicate.org/soispha/clients/yt/commit/e46ab9bc8bd4ecc35363e27aea9b5445bc858b2d)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt_dlp/lib)** Explicitly convert python exceptions into an error - ([ada9550](https://git.foss-syndicate.org/soispha/clients/yt/commit/ada9550b02ee13a8378bd2ee27d536b83eec4820)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Style +- **(treewide)** Reformat - ([10b07fa](https://git.foss-syndicate.org/soispha/clients/yt/commit/10b07fa5a4f4080ef5417720b2d15179b72d2fc2)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(treewide)** Adopt rust edition 2024 rustfmt style - ([a78b66e](https://git.foss-syndicate.org/soispha/clients/yt/commit/a78b66ed784cd6f2f97771d9e170c8f8558140b8)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/cli)** Sort the toplevel flags alphabetically - ([77ea1d8](https://git.foss-syndicate.org/soispha/clients/yt/commit/77ea1d8223b57567b448fb973b7240adaab61778)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +- **(yt/select)** Apply clippy's suggestions - ([b4ee42a](https://git.foss-syndicate.org/soispha/clients/yt/commit/b4ee42a62c683632c589f39e6ceac0b48d222e87)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) +#### Tests +- **(yt/cli)** Test the CLI - ([10f9d8b](https://git.foss-syndicate.org/soispha/clients/yt/commit/10f9d8bfd0c84146638cfdaf6b076493f943e650)) - [@bpeetz](https://git.foss-syndicate.org/bpeetz) + +- - - + ## [v1.5.0](https://git.vhack.eu/soispha/clients/yt/compare/2146109725115a9d01cc08ebbe3ef9c533ef1a89..v1.5.0) - 2025-02-22 #### Bug Fixes - **(crates/libmpv2)** Improve the error message for the `RawError` - ([0bd13d5](https://git.vhack.eu/soispha/clients/yt/commit/0bd13d5c26495649dabc23a4fb6b37fe682e3aec)) - [@soispha](https://git.vhack.eu/soispha) diff --git a/cog.toml b/cog.toml index 7cc3ac4..781175b 100644 --- a/cog.toml +++ b/cog.toml @@ -29,7 +29,7 @@ post_bump_hooks = [ [changelog] path = "NEWS.md" template = "remote" -remote = "git.vhack.eu" +remote = "git.foss-syndicate.org" repository = "clients/yt" owner = "soispha" -authors = [{ signature = "Benedikt Peetz", username = "soispha" }] +authors = [{ signature = "Benedikt Peetz", username = "bpeetz" }] diff --git a/crates/bytes/.gitignore b/contrib/external_commands_script.sh index 8876ea6..219eae7 100644..100755 --- a/crates/bytes/.gitignore +++ b/contrib/external_commands_script.sh @@ -1,6 +1,8 @@ +#! /usr/bin/env sh + # yt - A fully featured command line YouTube client # -# Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +# Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> # SPDX-License-Identifier: GPL-3.0-or-later # # This file is part of Yt. @@ -8,4 +10,10 @@ # You should have received a copy of the License along with this program. # If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -/target +riverctl focus-output next + +alacritty --title "floating please" --command "$@" + +riverctl focus-output next + +# vim: ft=sh diff --git a/crates/bytes/Cargo.lock b/crates/bytes/Cargo.lock deleted file mode 100644 index b30ba3d..0000000 --- a/crates/bytes/Cargo.lock +++ /dev/null @@ -1,65 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 3 - -[[package]] -name = "bytes" -version = "1.0.0" -dependencies = [ - "serde", -] - -[[package]] -name = "proc-macro2" -version = "1.0.86" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "serde" -version = "1.0.210" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.210" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "syn" -version = "2.0.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" diff --git a/crates/bytes/src/serde.rs b/crates/bytes/src/serde.rs deleted file mode 100644 index 4341e32..0000000 --- a/crates/bytes/src/serde.rs +++ /dev/null @@ -1,19 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use serde::{Serialize, Serializer}; - -use crate::Bytes; - -impl Serialize for Bytes { - fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { - serializer.serialize_str(self.to_string().as_str()) - } -} diff --git a/crates/bytes/Cargo.toml b/crates/colors/Cargo.toml index 4439aa8..4edefcf 100644 --- a/crates/bytes/Cargo.toml +++ b/crates/colors/Cargo.toml @@ -1,7 +1,8 @@ # yt - A fully featured command line YouTube client # -# Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +# Copyright (C) 2025 uutils developers +# SPDX-License-Identifier: MIT # # This file is part of Yt. # @@ -9,25 +10,17 @@ # If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. [package] -name = "bytes" -description = "Simple byte formatting utilities" -keywords = [] -categories = [] -version.workspace = true -edition.workspace = true +name = "colors" authors.workspace = true license.workspace = true +description = "A owo-colors inspired color crate." +version.workspace = true +edition.workspace = true repository.workspace = true rust-version.workspace = true publish = false [dependencies] -serde.workspace = true - -[dev-dependencies] [lints] workspace = true - -[package.metadata.docs.rs] -all-features = true diff --git a/crates/colors/src/custom.rs b/crates/colors/src/custom.rs new file mode 100644 index 0000000..fd6b7b3 --- /dev/null +++ b/crates/colors/src/custom.rs @@ -0,0 +1,75 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +// Taken from <https://github.com/owo-colors/owo-colors/blob/61f8bba2f5f80e9f4fa600fbfdf2c21656f1d523/src/colors/custom.rs> +// at 2025-07-16T18:05:55 CEST. + +const U8_TO_STR: [[u8; 3]; 256] = generate_lookup(); + +const fn generate_lookup() -> [[u8; 3]; 256] { + let mut table = [[0, 0, 0]; 256]; + + let mut i = 0; + while i < 256 { + table[i] = [ + b'0' + (i / 100) as u8, + b'0' + (i / 10 % 10) as u8, + b'0' + (i % 10) as u8, + ]; + i += 1; + } + + table +} + +#[derive(Clone, Copy)] +pub(crate) enum Plane { + Fg, + Bg, +} + +pub(crate) const fn rgb_to_ansi(r: u8, g: u8, b: u8, plane: Plane) -> [u8; 18] { + let mut buf = *b"\x1b[p8;2;rrr;ggg;bbb"; + + let r = U8_TO_STR[r as usize]; + let g = U8_TO_STR[g as usize]; + let b = U8_TO_STR[b as usize]; + + // p 2 + buf[2] = match plane { + Plane::Fg => b'3', + Plane::Bg => b'4', + }; + + // r 7 + buf[7] = r[0]; + buf[8] = r[1]; + buf[9] = r[2]; + + // g 11 + buf[11] = g[0]; + buf[12] = g[1]; + buf[13] = g[2]; + + // b 15 + buf[15] = b[0]; + buf[16] = b[1]; + buf[17] = b[2]; + + buf +} + +/// This exists since [`unwrap()`] isn't const-safe (it invokes formatting infrastructure) +pub(crate) const fn bytes_to_str(bytes: &'static [u8]) -> &'static str { + match core::str::from_utf8(bytes) { + Ok(o) => o, + Err(_e) => panic!("Const parsing &[u8] to a string failed!"), + } +} diff --git a/crates/colors/src/lib.rs b/crates/colors/src/lib.rs new file mode 100644 index 0000000..663e19a --- /dev/null +++ b/crates/colors/src/lib.rs @@ -0,0 +1,97 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::fmt::{Display, Write}; + +use crate::{ + list::{elements, methods}, + support::{CSE, CSI, elements_inner}, +}; + +pub(crate) mod custom; +mod list; +mod support; + +#[derive(Debug)] +pub struct Canvas<I: Display>(I); + +impl<I: Display> Colorize for Canvas<I> { + fn render_into(self, base: &mut String, use_colors: bool) { + write!(base, "{}", self.0).expect("Is written into a string"); + + if use_colors { + // Reset the color and style, if we used colours. + base.write_str(CSI).expect("In-memory write"); + base.write_str("0").expect("In-memory write"); + base.write_str(CSE).expect("In-memory write"); + } + } +} + +pub trait IntoCanvas: Display + Sized { + fn into_canvas(self) -> Canvas<Self> { + Canvas(self) + } + + methods! { IntoCanvas } +} + +impl<I: Display> IntoCanvas for I {} + +pub trait Colorize: Sized { + /// Turn this colorized struct into a string, by writing into the base. + fn render_into(self, base: &mut String, use_colors: bool); + + /// Turn this colorized struct into a string for consumption. + fn render(self, use_colors: bool) -> String { + let mut base = String::new(); + self.render_into(&mut base, use_colors); + base + } + + methods! { Colorize } +} + +elements! {} + +#[cfg(test)] +mod tests { + use crate::{Colorize, IntoCanvas}; + + #[test] + fn test_colorize_basic() { + let base = "Base".green().render(true); + #[rustfmt::skip] + let expected = concat!( + "\x1b[32m", + "Base", + "\x1b[0m", + ); + + assert_eq!(base.as_str(), expected); + } + + #[test] + fn test_colorize_combo() { + let base = "Base".green().on_red().bold().strike_through().render(true); + + #[rustfmt::skip] + let expected = concat!( + "\x1b[9m", // strike_through + "\x1b[1m", // bold + "\x1b[41m", // on_red + "\x1b[32m", // green + "Base", + "\x1b[0m", + ); + + assert_eq!(base.as_str(), expected); + } +} diff --git a/crates/colors/src/list.rs b/crates/colors/src/list.rs new file mode 100644 index 0000000..35fcb83 --- /dev/null +++ b/crates/colors/src/list.rs @@ -0,0 +1,233 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::support::prepend_input; + +prepend_input! { + crate::support::methods_inner as methods (($tt:tt) -> {$tt}), + crate::support::elements_inner as elements, + + <shared_input> + { + // Colors + Black black 30, + OnBlack on_black 40, + + Red red 31, + OnRed on_red 41, + + Green green 32, + OnGreen on_green 42, + + Yellow yellow 33, + OnYellow on_yellow 43, + + Blue blue 34, + OnBlue on_blue 44, + + Magenta magenta 35, + OnMagenta on_magenta 45, + + Cyan cyan 36, + OnCyan on_cyan 46, + + White white 37, + OnWhite on_white 47, + + Default default 39, + OnDefault on_default 49, + + // Bright bright colors + BrightBlack bright_black 90, + OnBrightBlack on_bright_black 100, + + BrightRed bright_red 91, + OnBrightRed on_bright_red 101, + + BrightGreen bright_green 92, + OnBrightGreen on_bright_green 102, + + BrightYellow bright_yellow 93, + OnBrightYellow on_bright_yellow 103, + + BrightBlue bright_blue 94, + OnBrightBlue on_bright_blue 104, + + BrightMagenta bright_magenta 95, + OnBrightMagenta on_bright_magenta 105, + + BrightCyan bright_cyan 96, + OnBrightCyan on_bright_cyan 106, + + BrightWhite bright_white 97, + OnBrightWhite on_bright_white 107, + + // CSS colors + // TODO(@bpeetz): Also support background colors with these values. <2025-07-16> + AliceBlue alice_blue (240, 248, 255), + AntiqueWhite antique_white (250, 235, 215), + Aqua aqua (0, 255, 255), + Aquamarine aquamarine (127, 255, 212), + Azure azure (240, 255, 255), + Beige beige (245, 245, 220), + Bisque bisque (255, 228, 196), + // Black black (0, 0, 0), + BlanchedAlmond blanched_almond (255, 235, 205), + // Blue blue (0, 0, 255), + BlueViolet blue_violet (138, 43, 226), + Brown brown (165, 42, 42), + BurlyWood burly_wood (222, 184, 135), + CadetBlue cadet_blue (95, 158, 160), + Chartreuse chartreuse (127, 255, 0), + Chocolate chocolate (210, 105, 30), + Coral coral (255, 127, 80), + CornflowerBlue cornflower_blue (100, 149, 237), + Cornsilk cornsilk (255, 248, 220), + Crimson crimson (220, 20, 60), + DarkBlue dark_blue (0, 0, 139), + DarkCyan dark_cyan (0, 139, 139), + DarkGoldenRod dark_golden_rod (184, 134, 11), + DarkGray dark_gray (169, 169, 169), + DarkGrey dark_grey (169, 169, 169), + DarkGreen dark_green (0, 100, 0), + DarkKhaki dark_khaki (189, 183, 107), + DarkMagenta dark_magenta (139, 0, 139), + DarkOliveGreen dark_olive_green (85, 107, 47), + DarkOrange dark_orange (255, 140, 0), + DarkOrchid dark_orchid (153, 50, 204), + DarkRed dark_red (139, 0, 0), + DarkSalmon dark_salmon (233, 150, 122), + DarkSeaGreen dark_sea_green (143, 188, 143), + DarkSlateBlue dark_slate_blue (72, 61, 139), + DarkSlateGray dark_slate_gray (47, 79, 79), + DarkSlateGrey dark_slate_grey (47, 79, 79), + DarkTurquoise dark_turquoise (0, 206, 209), + DarkViolet dark_violet (148, 0, 211), + DeepPink deep_pink (255, 20, 147), + DeepSkyBlue deep_sky_blue (0, 191, 255), + DimGray dim_gray (105, 105, 105), + DimGrey dim_grey (105, 105, 105), + DodgerBlue dodger_blue (30, 144, 255), + FireBrick fire_brick (178, 34, 34), + FloralWhite floral_white (255, 250, 240), + ForestGreen forest_green (34, 139, 34), + Fuchsia fuchsia (255, 0, 255), + Gainsboro gainsboro (220, 220, 220), + GhostWhite ghost_white (248, 248, 255), + Gold gold (255, 215, 0), + GoldenRod golden_rod (218, 165, 32), + Gray gray (128, 128, 128), + Grey grey (128, 128, 128), + // Green green (0, 128, 0), + GreenYellow green_yellow (173, 255, 47), + HoneyDew honey_dew (240, 255, 240), + HotPink hot_pink (255, 105, 180), + IndianRed indian_red (205, 92, 92), + Indigo indigo (75, 0, 130), + Ivory ivory (255, 255, 240), + Khaki khaki (240, 230, 140), + Lavender lavender (230, 230, 250), + LavenderBlush lavender_blush (255, 240, 245), + LawnGreen lawn_green (124, 252, 0), + LemonChiffon lemon_chiffon (255, 250, 205), + LightBlue light_blue (173, 216, 230), + LightCoral light_coral (240, 128, 128), + LightCyan light_cyan (224, 255, 255), + LightGoldenRodYellow light_golden_rod_yellow (250, 250, 210), + LightGray light_gray (211, 211, 211), + LightGrey light_grey (211, 211, 211), + LightGreen light_green (144, 238, 144), + LightPink light_pink (255, 182, 193), + LightSalmon light_salmon (255, 160, 122), + LightSeaGreen light_sea_green (32, 178, 170), + LightSkyBlue light_sky_blue (135, 206, 250), + LightSlateGray light_slate_gray (119, 136, 153), + LightSlateGrey light_slate_grey (119, 136, 153), + LightSteelBlue light_steel_blue (176, 196, 222), + LightYellow light_yellow (255, 255, 224), + Lime lime (0, 255, 0), + LimeGreen lime_green (50, 205, 50), + Linen linen (250, 240, 230), + // Magenta magenta (255, 0, 255), + Maroon maroon (128, 0, 0), + MediumAquaMarine medium_aqua_marine (102, 205, 170), + MediumBlue medium_blue (0, 0, 205), + MediumOrchid medium_orchid (186, 85, 211), + MediumPurple medium_purple (147, 112, 219), + MediumSeaGreen medium_sea_green (60, 179, 113), + MediumSlateBlue medium_slate_blue (123, 104, 238), + MediumSpringGreen medium_spring_green (0, 250, 154), + MediumTurquoise medium_turquoise (72, 209, 204), + MediumVioletRed medium_violet_red (199, 21, 133), + MidnightBlue midnight_blue (25, 25, 112), + MintCream mint_cream (245, 255, 250), + MistyRose misty_rose (255, 228, 225), + Moccasin moccasin (255, 228, 181), + NavajoWhite navajo_white (255, 222, 173), + Navy navy (0, 0, 128), + OldLace old_lace (253, 245, 230), + Olive olive (128, 128, 0), + OliveDrab olive_drab (107, 142, 35), + Orange orange (255, 165, 0), + OrangeRed orange_red (255, 69, 0), + Orchid orchid (218, 112, 214), + PaleGoldenRod pale_golden_rod (238, 232, 170), + PaleGreen pale_green (152, 251, 152), + PaleTurquoise pale_turquoise (175, 238, 238), + PaleVioletRed pale_violet_red (219, 112, 147), + PapayaWhip papaya_whip (255, 239, 213), + PeachPuff peach_puff (255, 218, 185), + Peru peru (205, 133, 63), + Pink pink (255, 192, 203), + Plum plum (221, 160, 221), + PowderBlue powder_blue (176, 224, 230), + Purple purple (128, 0, 128), + RebeccaPurple rebecca_purple (102, 51, 153), + // Red red (255, 0, 0), + RosyBrown rosy_brown (188, 143, 143), + RoyalBlue royal_blue (65, 105, 225), + SaddleBrown saddle_brown (139, 69, 19), + Salmon salmon (250, 128, 114), + SandyBrown sandy_brown (244, 164, 96), + SeaGreen sea_green (46, 139, 87), + SeaShell sea_shell (255, 245, 238), + Sienna sienna (160, 82, 45), + Silver silver (192, 192, 192), + SkyBlue sky_blue (135, 206, 235), + SlateBlue slate_blue (106, 90, 205), + SlateGray slate_gray (112, 128, 144), + SlateGrey slate_grey (112, 128, 144), + Snow snow (255, 250, 250), + SpringGreen spring_green (0, 255, 127), + SteelBlue steel_blue (70, 130, 180), + Tan tan (210, 180, 140), + Teal teal (0, 128, 128), + Thistle thistle (216, 191, 216), + Tomato tomato (255, 99, 71), + Turquoise turquoise (64, 224, 208), + Violet violet (238, 130, 238), + Wheat wheat (245, 222, 179), + // White white (255, 255, 255), + WhiteSmoke white_smoke (245, 245, 245), + // Yellow yellow (255, 255, 0), + YellowGreen yellow_green (154, 205, 50), + + // Styles + Bold bold 1, + Dim dim 2, + Italic italic 3, + Underline underline 4, + Blink blink 5, + BlinkFast blink_fast 6, + Reversed reversed 7, + Hidden hidden 8, + StrikeThrough strike_through 9, + } +} diff --git a/crates/colors/src/support.rs b/crates/colors/src/support.rs new file mode 100644 index 0000000..3c3f87d --- /dev/null +++ b/crates/colors/src/support.rs @@ -0,0 +1,126 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +pub(super) const CSI: &str = "\x1b["; +pub(super) const CSE: &str = "m"; + +macro_rules! elements_inner { + ( + $( + $name:ident $_:ident $number:tt + ),* + $(,)? + ) => { + $( + #[derive(Debug)] + pub struct $name<I: Colorize>(I); + + impl<I: Colorize> Colorize for $name<I> { + fn render_into(self, base: &mut String, use_colors: bool) { + elements_inner! {@parse_number $number} + + if use_colors { + base.write_str(CSI).expect("In-memory write"); + base.write_str(NUMBERS).expect("In-memory write"); + base.write_str(CSE).expect("In-memory write"); + } + self.0.render_into(base, use_colors); + // The canvas is resetting the colours again. + } + } + )* + }; + + (@parse_number $single:literal) => { + const NUMBERS: &str = stringify!($single); + }; + (@parse_number ($red:literal, $green:literal, $blue:literal)) => { + const NUMBERS_U8: [u8; 18] = $crate::custom::rgb_to_ansi($red, $green, $blue, $crate::custom::Plane::Fg); + + const NUMBERS: &str = $crate::custom::bytes_to_str(&NUMBERS_U8); + } +} +pub(super) use elements_inner; + +macro_rules! methods_inner { + ( + Colorize + + $( + $struct_name:ident $fn_name:ident $_:tt + ),* + $(,)? + ) => { + $( + fn $fn_name(self) -> $struct_name<Self> { + $struct_name(self) + } + )* + }; + ( + IntoCanvas + + $( + $struct_name:ident $fn_name:ident $_:tt + ),* + $(,)? + ) => { + $( + fn $fn_name(self) -> $struct_name<Canvas<Self>> { + $struct_name(Canvas(self)) + } + )* + }; +} +pub(super) use methods_inner; + +macro_rules! prepend_input { + ( + $( + $existing_macro_name:path as $new_macro_name:ident $(($macro_rule:tt -> $macro_apply:tt))? + ),* + $(,)? + + <shared_input> + $shared_input:tt + ) => { + $( + prepend_input! { + @generate_macro + $existing_macro_name as $new_macro_name $(($macro_rule -> $macro_apply))? + <shared_input> + $shared_input + } + )* + }; + + ( + @generate_macro + $existing_macro_name:path as $new_macro_name:ident $((($($macro_rule:tt)*) -> {$($macro_apply:tt)*}))? + + <shared_input> + { + $( + $shared_input:tt + )* + } + ) => { + macro_rules! $new_macro_name { + ($($($macro_rule)*)?) => { + $existing_macro_name! { + $($($macro_apply)*)? + $($shared_input)* + } + } + } + pub(super) use $new_macro_name; + } +} +pub(crate) use prepend_input; diff --git a/crates/fmt/Cargo.toml b/crates/fmt/Cargo.toml index f3cf4ad..dd314ab 100644 --- a/crates/fmt/Cargo.toml +++ b/crates/fmt/Cargo.toml @@ -24,7 +24,7 @@ publish = false path = "src/fmt.rs" [dependencies] -unicode-width = "0.2.1" +unicode-width = "0.2.2" [lints] workspace = true diff --git a/crates/libmpv2/CHANGELOG.md b/crates/libmpv2/CHANGELOG.md index dc6f861..a3d14d7 100644 --- a/crates/libmpv2/CHANGELOG.md +++ b/crates/libmpv2/CHANGELOG.md @@ -16,7 +16,7 @@ If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. ## Version 3.0.0 -- \[breaking\] Support libmpv version 2.0 (mpv version 0.35.0). Mpv versions \<= +- [breaking] Support libmpv version 2.0 (mpv version 0.35.0). Mpv versions \<= 0.34.0 will no longer be supported. - Add OpenGL rendering @@ -29,10 +29,10 @@ If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. ## Version 2.0.0 - Add method `Mpv::with_initializer` to set options before initialization -- \[breaking\] Borrow `&mut self` in `wait_event` to disallow using two events +- [breaking] Borrow `&mut self` in `wait_event` to disallow using two events where the first points to data freed in the second `wait_event` call -- \[breaking\] `PropertyData<'_>` is no longer `Clone` or `PartialEq`, - `Event<'_>` is no longer `Clone` to avoid cloning/comparing `MpvNode` +- [breaking] `PropertyData<'_>` is no longer `Clone` or `PartialEq`, `Event<'_>` + is no longer `Clone` to avoid cloning/comparing `MpvNode` ## Version 1.1.0 diff --git a/crates/libmpv2/Cargo.toml b/crates/libmpv2/Cargo.toml index fb2f5bf..67fbfec 100644 --- a/crates/libmpv2/Cargo.toml +++ b/crates/libmpv2/Cargo.toml @@ -28,7 +28,7 @@ log.workspace = true [dev-dependencies] crossbeam = "0.8" -sdl2 = "0.37.0" +sdl2 = "0.38.0" [features] default = ["protocols", "render"] diff --git a/crates/libmpv2/libmpv2-sys/Cargo.toml b/crates/libmpv2/libmpv2-sys/Cargo.toml index 96141d3..0be2c7a 100644 --- a/crates/libmpv2/libmpv2-sys/Cargo.toml +++ b/crates/libmpv2/libmpv2-sys/Cargo.toml @@ -23,4 +23,4 @@ rust-version.workspace = true publish = false [build-dependencies] -bindgen = { version = "0.72.0" } +bindgen = { version = "0.72.1" } diff --git a/crates/libmpv2/libmpv2-sys/build.rs b/crates/libmpv2/libmpv2-sys/build.rs index bf9a02e..45c2450 100644 --- a/crates/libmpv2/libmpv2-sys/build.rs +++ b/crates/libmpv2/libmpv2-sys/build.rs @@ -30,7 +30,9 @@ fn main() { ), "--verbose", ]) - .generate_comments(true) + // NOTE(@bpeetz): The comments are interpreted as doc-tests, + // which obviously fail, as the code is c. <2025-06-16> + .generate_comments(false) .generate() .expect("Unable to generate bindings"); diff --git a/crates/libmpv2/src/mpv.rs b/crates/libmpv2/src/mpv.rs index 29dac8d..d8164c0 100644 --- a/crates/libmpv2/src/mpv.rs +++ b/crates/libmpv2/src/mpv.rs @@ -552,21 +552,21 @@ impl Mpv { /// /// # Examples /// - /// ```dont_run - /// # use libmpv2::{Mpv}; - /// # use libmpv2::mpv_node::MpvNode; - /// # use libmpv2::mpv::errors::Result; - /// # use std::collections::HashMap; - /// # - /// # fn main() -> Result<()> { - /// # let mpv = Mpv::new()?; + /// ```text + ///# use libmpv2::{Mpv}; + ///# use libmpv2::mpv_node::MpvNode; + ///# use libmpv2::mpv::errors::Result; + ///# use std::collections::HashMap; + ///# + ///# fn main() -> Result<()> { + ///# let mpv = Mpv::new()?; /// mpv.command("loadfile", &["test-data/jellyfish.mp4", "append-play"]).unwrap(); - /// # let node = mpv.get_property::<MpvNode>("playlist").unwrap(); - /// # let mut list = node.array().unwrap().collect::<Vec<_>>(); - /// # let map = list.pop().unwrap().map().unwrap().collect::<HashMap<_, _>>(); - /// # assert_eq!(map, HashMap::from([(String::from("id"), MpvNode::Int64(1)), (String::from("current"), MpvNode::Flag(true)), (String::from("filename"), MpvNode::String(String::from("test-data/jellyfish.mp4")))])); - /// # Ok(()) - /// # } + ///# let node = mpv.get_property::<MpvNode>("playlist").unwrap(); + ///# let mut list = node.array().unwrap().collect::<Vec<_>>(); + ///# let map = list.pop().unwrap().map().unwrap().collect::<HashMap<_, _>>(); + ///# assert_eq!(map, HashMap::from([(String::from("id"), MpvNode::Int64(1)), (String::from("current"), MpvNode::Flag(true)), (String::from("filename"), MpvNode::String(String::from("test-data/jellyfish.mp4")))])); + ///# Ok(()) + ///# } /// ``` pub fn command(&self, name: &str, args: &[&str]) -> Result<()> { fn escape(input: &str) -> String { diff --git a/crates/libmpv2/src/mpv/events.rs b/crates/libmpv2/src/mpv/events.rs index f10ff6e..9f6324a 100644 --- a/crates/libmpv2/src/mpv/events.rs +++ b/crates/libmpv2/src/mpv/events.rs @@ -238,7 +238,7 @@ impl EventContext { /// Returns `Some(Err(...))` if there was invalid utf-8, or if either an /// `MPV_EVENT_GET_PROPERTY_REPLY`, `MPV_EVENT_SET_PROPERTY_REPLY`, `MPV_EVENT_COMMAND_REPLY`, /// or `MPV_EVENT_PROPERTY_CHANGE` event failed, or if `MPV_EVENT_END_FILE` reported an error. - pub fn wait_event(&mut self, timeout: f64) -> Option<Result<Event>> { + pub fn wait_event(&mut self, timeout: f64) -> Option<Result<Event<'_>>> { let event = unsafe { *libmpv2_sys::mpv_wait_event(self.ctx.as_ptr(), timeout) }; // debug!("Got an event from mpv: {:#?}", event); diff --git a/crates/libmpv2/src/mpv/protocol.rs b/crates/libmpv2/src/mpv/protocol.rs index ee33411..070fb66 100644 --- a/crates/libmpv2/src/mpv/protocol.rs +++ b/crates/libmpv2/src/mpv/protocol.rs @@ -24,7 +24,7 @@ impl Mpv { /// /// # Panics /// Panics if a context already exists - pub fn create_protocol_context<T, U>(&self) -> ProtocolContext<T, U> + pub fn create_protocol_context<T, U>(&self) -> ProtocolContext<'_, T, U> where T: RefUnwindSafe, U: RefUnwindSafe, diff --git a/crates/libmpv2/update.sh b/crates/libmpv2/update.sh index ecd5aa8..e1669a9 100755 --- a/crates/libmpv2/update.sh +++ b/crates/libmpv2/update.sh @@ -10,8 +10,4 @@ # You should have received a copy of the License along with this program. # If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -cd "$(dirname "$0")" || exit 1 -[ "$1" = "upgrade" ] && cargo upgrade --incompatible -cargo update - -./libmpv2-sys/update.sh "$@" +"$(dirname "$0")/libmpv2-sys/update.sh" "$@" diff --git a/crates/yt/Cargo.toml b/crates/yt/Cargo.toml index 6803e68..6184eb7 100644 --- a/crates/yt/Cargo.toml +++ b/crates/yt/Cargo.toml @@ -24,43 +24,44 @@ rust-version.workspace = true publish = false [dependencies] -anyhow = "1.0.98" -blake3 = "1.8.2" -chrono = { version = "0.4.41", features = ["now"] } +anyhow = "1.0.100" +blake3 = { version = "1.8.2", features = ["serde"] } +chrono = { version = "0.4.42", features = ["now"] } chrono-humanize = "0.2.3" -clap = { version = "4.5.40", features = ["derive"] } +clap = { version = "4.5.53", features = ["derive"] } +clap_complete = { version = "4.5.61", features = ["unstable-dynamic"] } +colors.workspace = true futures = "0.3.31" -nucleo-matcher = "0.3.1" -owo-colors = "4.2.1" -regex = "1.11.1" -sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] } -stderrlog = "0.6.0" -tempfile = "3.20.0" -toml = "0.8.23" -trinitry = { version = "0.2.2" } -xdg = "3.0.0" -bytes.workspace = true libmpv2.workspace = true log.workspace = true +notify = { version = "8.2.0", default-features = false } +regex = "1.12.2" serde.workspace = true serde_json.workspace = true +shlex = "1.3.0" +sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] } +stderrlog = "0.6.0" +tempfile = "3.23.0" +termsize.workspace = true +tokio-util = { version = "0.7.17", features = ["rt"] } tokio.workspace = true +toml = "0.9.8" url.workspace = true -yt_dlp.workspace = true -termsize.workspace = true uu_fmt.workspace = true -notify = { version = "8.0.0", default-features = false } -tokio-util = { version = "0.7.15", features = ["rt"] } +xdg = "3.0.0" +yt_dlp.workspace = true +reqwest = "0.12.24" [[bin]] name = "yt" doc = false path = "src/main.rs" -[dev-dependencies] - [lints] workspace = true +[dev-dependencies] +pretty_assertions = "1.4.1" + [package.metadata.docs.rs] all-features = true diff --git a/crates/yt/src/ansi_escape_codes.rs b/crates/yt/src/ansi_escape_codes.rs index ae1805d..28a8370 100644 --- a/crates/yt/src/ansi_escape_codes.rs +++ b/crates/yt/src/ansi_escape_codes.rs @@ -1,9 +1,19 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + // see: https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands const CSI: &str = "\x1b["; -pub fn erase_in_display_from_cursor() { +pub(crate) fn erase_from_cursor_to_bottom() { print!("{CSI}0J"); } -pub fn cursor_up(number: usize) { +pub(crate) fn cursor_up(number: usize) { // HACK(@bpeetz): The default is `1` and running this command with a // number of `0` results in it using the default (i.e., `1`) <2025-03-25> if number != 0 { @@ -11,16 +21,9 @@ pub fn cursor_up(number: usize) { } } -pub fn clear_whole_line() { +pub(crate) fn clear_whole_line() { eprint!("{CSI}2K"); } -pub fn move_to_col(x: usize) { +pub(crate) fn move_to_col(x: usize) { eprint!("{CSI}{x}G"); } - -pub fn hide_cursor() { - eprint!("{CSI}?25l"); -} -pub fn show_cursor() { - eprint!("{CSI}?25h"); -} diff --git a/crates/yt/src/app.rs b/crates/yt/src/app.rs index 15a9388..3ea12a4 100644 --- a/crates/yt/src/app.rs +++ b/crates/yt/src/app.rs @@ -16,13 +16,13 @@ use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; use crate::{config::Config, storage::migrate::migrate_db}; #[derive(Debug)] -pub struct App { - pub database: SqlitePool, - pub config: Config, +pub(crate) struct App { + pub(crate) database: SqlitePool, + pub(crate) config: Config, } impl App { - pub async fn new(config: Config, should_migrate_db: bool) -> Result<Self> { + pub(crate) async fn new(config: Config, should_migrate_db: bool) -> Result<Self> { let options = SqliteConnectOptions::new() .filename(&config.paths.database_path) .optimize_on_close(true, None) diff --git a/crates/yt/src/cache/mod.rs b/crates/yt/src/cache/mod.rs deleted file mode 100644 index 83d5ee0..0000000 --- a/crates/yt/src/cache/mod.rs +++ /dev/null @@ -1,105 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use anyhow::{Context, Result}; -use log::{debug, info}; -use tokio::fs; - -use crate::{ - app::App, - storage::video_database::{ - Video, VideoStatus, VideoStatusMarker, downloader::set_video_cache_path, get, - }, -}; - -async fn invalidate_video(app: &App, video: &Video, hard: bool) -> Result<()> { - info!("Invalidating cache of video: '{}'", video.title); - - if hard { - if let VideoStatus::Cached { - cache_path: path, .. - } = &video.status - { - info!("Removing cached video at: '{}'", path.display()); - if let Err(err) = fs::remove_file(path).await.map_err(|err| err.kind()) { - match err { - std::io::ErrorKind::NotFound => { - // The path is already gone - debug!( - "Not actually removing path: '{}'. It is already gone.", - path.display() - ); - } - err => Err(std::io::Error::from(err)).with_context(|| { - format!( - "Failed to delete video ('{}') cache path: '{}'.", - video.title, - path.display() - ) - })?, - } - } - } - } - - set_video_cache_path(app, &video.extractor_hash, None).await?; - - Ok(()) -} - -pub async fn invalidate(app: &App, hard: bool) -> Result<()> { - let all_cached_things = get::videos(app, &[VideoStatusMarker::Cached]).await?; - - info!("Got videos to invalidate: '{}'", all_cached_things.len()); - - for video in all_cached_things { - invalidate_video(app, &video, hard).await?; - } - - Ok(()) -} - -/// # Panics -/// Only if internal assertions fail. -pub async fn maintain(app: &App, all: bool) -> Result<()> { - let domain = if all { - VideoStatusMarker::ALL.as_slice() - } else { - &[VideoStatusMarker::Watch, VideoStatusMarker::Cached] - }; - - let cached_videos = get::videos(app, domain).await?; - - let mut found_focused = 0; - for vid in cached_videos { - if let VideoStatus::Cached { - cache_path: path, - is_focused, - } = &vid.status - { - info!("Checking if path ('{}') exists", path.display()); - if !path.exists() { - invalidate_video(app, &vid, false).await?; - } - - if *is_focused { - found_focused += 1; - } - } - } - - assert!( - found_focused <= 1, - "Only one video can be focused at a time" - ); - - Ok(()) -} diff --git a/crates/yt/src/cli.rs b/crates/yt/src/cli.rs index 634e422..9a24403 100644 --- a/crates/yt/src/cli.rs +++ b/crates/yt/src/cli.rs @@ -9,390 +9,59 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -use std::{path::PathBuf, str::FromStr}; +use std::path::PathBuf; -use anyhow::Context; -use bytes::Bytes; -use chrono::NaiveDate; -use clap::{ArgAction, Args, Parser, Subcommand}; -use url::Url; +use clap::{ArgAction, Parser}; -use crate::{ - select::selection_file::duration::MaybeDuration, - storage::video_database::extractor_hash::LazyExtractorHash, -}; +use crate::commands::Command; #[derive(Parser, Debug)] #[clap(author, about, long_about = None)] #[allow(clippy::module_name_repetitions)] /// An command line interface to select, download and watch videos -pub struct CliArgs { +pub(crate) struct CliArgs { #[command(subcommand)] /// The subcommand to execute [default: select] - pub command: Option<Command>, + pub(crate) command: Option<Command>, /// Show the version and exit #[arg(long, short = 'V', action= ArgAction::SetTrue)] - pub version: bool, + pub(crate) version: bool, /// Do not perform database migration before starting. /// Setting this could cause runtime database access errors. #[arg(long, short, action=ArgAction::SetTrue, default_value_t = false)] - pub no_migrate_db: bool, + pub(crate) no_migrate_db: bool, /// Display colors [defaults to true, if the config file has no value] #[arg(long, short = 'C')] - pub color: Option<bool>, + pub(crate) color: Option<bool>, /// Set the path to the videos.db. This overrides the default and the config file. #[arg(long, short)] - pub db_path: Option<PathBuf>, + pub(crate) db_path: Option<PathBuf>, /// Set the path to the config.toml. /// This overrides the default. #[arg(long, short)] - pub config_path: Option<PathBuf>, + pub(crate) config_path: Option<PathBuf>, /// Increase message verbosity #[arg(long="verbose", short = 'v', action = ArgAction::Count)] - pub verbosity: u8, + pub(crate) verbosity: u8, /// Silence all output #[arg(long, short = 'q')] - pub quiet: bool, + pub(crate) quiet: bool, } -#[derive(Subcommand, Debug)] -pub enum Command { - /// Download and cache URLs - Download { - /// Forcefully re-download all cached videos (i.e. delete the cache path, then download). - #[arg(short, long)] - force: bool, +#[cfg(test)] +mod test { + use clap::CommandFactory; - /// The maximum size the download dir should have. Beware that the value must be given in - /// bytes. - #[arg(short, long, value_parser = byte_parser)] - max_cache_size: Option<u64>, - }, - - /// Select, download and watch in one command. - Sedowa {}, - /// Download and watch in one command. - Dowa {}, - - /// Work with single videos - Videos { - #[command(subcommand)] - cmd: VideosCommand, - }, - - /// Watch the already cached (and selected) videos - Watch {}, - - /// Visualize the current playlist - Playlist { - /// Linger and display changes - #[arg(short, long)] - watch: bool, - }, - - /// Show, which videos have been selected to be watched (and their cache status) - Status {}, - - /// Show, the configuration options in effect - Config {}, - - /// Display the comments of the currently playing video - Comments {}, - /// Display the description of the currently playing video - Description {}, - - /// Manipulate the video cache in the database - #[command(visible_alias = "db")] - Database { - #[command(subcommand)] - command: CacheCommand, - }, - - /// Change the state of videos in the database (the default) - Select { - #[command(subcommand)] - cmd: Option<SelectCommand>, - }, - - /// Update the video database - Update { - /// The maximal number of videos to fetch for each subscription. - #[arg(short, long)] - max_backlog: Option<usize>, - - /// How many subs were already checked. - /// - /// Only used in the progress display in combination with `--grouped`. - #[arg(short, long, hide = true)] - current_progress: Option<usize>, - - /// How many subs are to be checked. - /// - /// Only used in the progress display in combination with `--grouped`. - #[arg(short, long, hide = true)] - total_number: Option<usize>, - - /// The subscriptions to update - subscriptions: Vec<String>, - - /// Perform the updates in blocks. - /// - /// This works around the memory leaks in the default update invocation. - #[arg( - short, - long, - conflicts_with = "total_number", - conflicts_with = "current_progress" - )] - grouped: bool, - }, - - /// Manipulate subscription - #[command(visible_alias = "subs")] - Subscriptions { - #[command(subcommand)] - cmd: SubscriptionCommand, - }, -} - -fn byte_parser(input: &str) -> Result<u64, anyhow::Error> { - Ok(input - .parse::<Bytes>() - .with_context(|| format!("Failed to parse '{input}' as bytes!"))? - .as_u64()) -} - -impl Default for Command { - fn default() -> Self { - Self::Select { - cmd: Some(SelectCommand::default()), - } + use super::CliArgs; + #[test] + fn verify_cli() { + CliArgs::command().debug_assert(); } } - -#[derive(Subcommand, Clone, Debug)] -pub enum VideosCommand { - /// List the videos in the database - #[command(visible_alias = "ls")] - List { - /// An optional search query to limit the results - #[arg(action = ArgAction::Append)] - search_query: Option<String>, - - /// The number of videos to show - #[arg(short, long)] - limit: Option<usize>, - }, - - /// Get detailed information about a video - Info { - /// The short hash of the video - hash: LazyExtractorHash, - }, -} - -#[derive(Subcommand, Clone, Debug)] -pub enum SubscriptionCommand { - /// Subscribe to an URL - Add { - #[arg(short, long)] - /// The human readable name of the subscription - name: Option<String>, - - /// The URL to listen to - url: Url, - }, - - /// Unsubscribe from an URL - Remove { - /// The human readable name of the subscription - name: String, - }, - - /// Import a bunch of URLs as subscriptions. - Import { - /// The file containing the URLs. Will use Stdin otherwise. - file: Option<PathBuf>, - - /// Remove any previous subscriptions - #[arg(short, long)] - force: bool, - }, - /// Write all subscriptions in an format understood by `import` - Export {}, - - /// List all subscriptions - List {}, -} - -#[derive(Clone, Debug, Args)] -#[command(infer_subcommands = true)] -/// Mark the video given by the hash to be watched -pub struct SharedSelectionCommandArgs { - /// The ordering priority (higher means more at the top) - #[arg(short, long)] - pub priority: Option<i64>, - - /// The subtitles to download (e.g. 'en,de,sv') - #[arg(short = 'l', long)] - pub subtitle_langs: Option<String>, - - /// The speed to set mpv to - #[arg(short, long)] - pub speed: Option<f64>, - - /// The short extractor hash - pub hash: LazyExtractorHash, - - pub title: Option<String>, - - pub date: Option<OptionalNaiveDate>, - - pub publisher: Option<OptionalPublisher>, - - pub duration: Option<MaybeDuration>, - - pub url: Option<Url>, -} -#[derive(Clone, Debug, Copy)] -pub struct OptionalNaiveDate { - pub date: Option<NaiveDate>, -} -impl FromStr for OptionalNaiveDate { - type Err = anyhow::Error; - fn from_str(v: &str) -> Result<Self, Self::Err> { - if v == "[No release date]" { - Ok(Self { date: None }) - } else { - Ok(Self { - date: Some(NaiveDate::from_str(v)?), - }) - } - } -} -#[derive(Clone, Debug)] -pub struct OptionalPublisher { - pub publisher: Option<String>, -} -impl FromStr for OptionalPublisher { - type Err = anyhow::Error; - fn from_str(v: &str) -> Result<Self, Self::Err> { - if v == "[No author]" { - Ok(Self { publisher: None }) - } else { - Ok(Self { - publisher: Some(v.to_owned()), - }) - } - } -} - -#[derive(Subcommand, Clone, Debug)] -// NOTE: Keep this in sync with the [`constants::HELP_STR`] constant. <2024-08-20> -// NOTE: Also keep this in sync with the `tree-sitter-yts/grammar.js`. <2024-11-04> -pub enum SelectCommand { - /// Open a `git rebase` like file to select the videos to watch (the default) - File { - /// Include done (watched, dropped) videos - #[arg(long, short)] - done: bool, - - /// Generate a directory, where each file contains only one subscription. - #[arg(long, short, conflicts_with = "use_last_selection")] - split: bool, - - /// Use the last selection file (useful if you've spend time on it and want to get it again) - #[arg(long, short, conflicts_with = "done")] - use_last_selection: bool, - }, - - /// Add a video to the database - /// - /// This optionally supports to add a playlist. - /// When a playlist is added, the `start` and `stop` arguments can be used to select which - /// playlist entries to include. - #[command(visible_alias = "a")] - Add { - urls: Vec<Url>, - - /// Start adding playlist entries at this playlist index (zero based and inclusive) - #[arg(short = 's', long)] - start: Option<usize>, - - /// Stop adding playlist entries at this playlist index (zero based and inclusive) - #[arg(short = 'e', long)] - stop: Option<usize>, - }, - - /// Mark the video given by the hash to be watched - #[command(visible_alias = "w")] - Watch { - #[command(flatten)] - shared: SharedSelectionCommandArgs, - }, - - /// Mark the video given by the hash to be dropped - #[command(visible_alias = "d")] - Drop { - #[command(flatten)] - shared: SharedSelectionCommandArgs, - }, - - /// Mark the video given by the hash as already watched - #[command(visible_alias = "wd")] - Watched { - #[command(flatten)] - shared: SharedSelectionCommandArgs, - }, - - /// Open the video URL in Firefox's `timesinks.youtube` profile - #[command(visible_alias = "u")] - Url { - #[command(flatten)] - shared: SharedSelectionCommandArgs, - }, - - /// Reset the videos status to 'Pick' - #[command(visible_alias = "p")] - Pick { - #[command(flatten)] - shared: SharedSelectionCommandArgs, - }, -} -impl Default for SelectCommand { - fn default() -> Self { - Self::File { - done: false, - use_last_selection: false, - split: false, - } - } -} - -#[derive(Subcommand, Clone, Copy, Debug)] -pub enum CacheCommand { - /// Invalidate all cache entries - Invalidate { - /// Also delete the cache path - #[arg(short, long)] - hard: bool, - }, - - /// Perform basic maintenance operations on the database. - /// This helps recovering from invalid db states after a crash (or force exit via <CTRL-C>). - /// - /// 1. Check every path for validity (removing all invalid cache entries) - #[command(verbatim_doc_comment)] - Maintain { - /// Check every video (otherwise only the videos to be watched are checked) - #[arg(short, long)] - all: bool, - }, -} diff --git a/crates/yt/src/commands/cache/implm.rs b/crates/yt/src/commands/cache/implm.rs new file mode 100644 index 0000000..fd0fbce --- /dev/null +++ b/crates/yt/src/commands/cache/implm.rs @@ -0,0 +1,40 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{ + app::App, + commands::CacheCommand, + storage::db::{ + insert::Operations, + video::{Video, VideoStatusMarker}, + }, +}; + +use anyhow::Result; + +impl CacheCommand { + pub(in crate::commands) async fn implm(self, app: &App) -> Result<()> { + match self { + CacheCommand::Clear {} => { + let mut videos = Video::in_states(app, &[VideoStatusMarker::Cached]).await?; + + let mut ops = Operations::new("Cache clear"); + + for vid in &mut videos { + vid.remove_download_path(&mut ops); + } + + ops.commit(app).await?; + } + } + + Ok(()) + } +} diff --git a/crates/yt/src/commands/cache/mod.rs b/crates/yt/src/commands/cache/mod.rs new file mode 100644 index 0000000..4ed4b40 --- /dev/null +++ b/crates/yt/src/commands/cache/mod.rs @@ -0,0 +1,19 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use clap::Subcommand; + +mod implm; + +#[derive(Debug, Subcommand)] +pub(super) enum CacheCommand { + /// Remove all downloaded video files. + Clear {}, +} diff --git a/crates/yt/src/commands/config/implm.rs b/crates/yt/src/commands/config/implm.rs new file mode 100644 index 0000000..00c28a9 --- /dev/null +++ b/crates/yt/src/commands/config/implm.rs @@ -0,0 +1,23 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{app::App, commands::config::ConfigCommand}; + +use anyhow::Result; + +impl ConfigCommand { + pub(in crate::commands) fn implm(self, app: &App) -> Result<()> { + let config_str = toml::to_string(&app.config)?; + + print!("{config_str}"); + + Ok(()) + } +} diff --git a/crates/yt/src/constants.rs b/crates/yt/src/commands/config/mod.rs index 0f5b918..503b4f7 100644 --- a/crates/yt/src/constants.rs +++ b/crates/yt/src/commands/config/mod.rs @@ -1,6 +1,5 @@ // yt - A fully featured command line YouTube client // -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> // Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> // SPDX-License-Identifier: GPL-3.0-or-later // @@ -9,4 +8,9 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -pub const HELP_STR: &str = include_str!("./select/selection_file/help.str"); +use clap::Parser; + +mod implm; + +#[derive(Parser, Debug)] +pub(super) struct ConfigCommand {} diff --git a/crates/yt/src/commands/database/implm.rs b/crates/yt/src/commands/database/implm.rs new file mode 100644 index 0000000..07d346b --- /dev/null +++ b/crates/yt/src/commands/database/implm.rs @@ -0,0 +1,45 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{ + app::App, + commands::DatabaseCommand, + storage::db::{ + insert::{Committable, subscription, video}, + txn_log::TxnLog, + }, +}; + +use anyhow::Result; + +impl DatabaseCommand { + pub(in crate::commands) async fn implm(&self, app: &App) -> Result<()> { + match self { + DatabaseCommand::Log { kind } => match kind { + super::OperationType::Video => { + let log = TxnLog::<video::Operation>::get(app).await?; + display_log(&log); + } + super::OperationType::Subscription => { + let log = TxnLog::<subscription::Operation>::get(app).await?; + display_log(&log); + } + }, + } + + Ok(()) + } +} + +fn display_log<O: Committable>(log: &TxnLog<O>) { + for (time, value) in log.inner() { + println!("At {time}: {value:?}"); + } +} diff --git a/crates/yt/src/commands/database/mod.rs b/crates/yt/src/commands/database/mod.rs new file mode 100644 index 0000000..06e3169 --- /dev/null +++ b/crates/yt/src/commands/database/mod.rs @@ -0,0 +1,41 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::fmt::{self, Display}; + +use clap::{Subcommand, ValueEnum}; + +mod implm; + +#[derive(Subcommand, Debug)] +pub(super) enum DatabaseCommand { + /// Show the history of operations, in they groups they were committed in. + Log { + /// What kind of operation to show. + #[arg(short, long, default_value_t)] + kind: OperationType, + }, +} + +#[derive(Debug, Clone, Copy, ValueEnum, Default)] +pub(super) enum OperationType { + #[default] + Video, + Subscription, +} + +impl Display for OperationType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OperationType::Video => f.write_str("video"), + OperationType::Subscription => f.write_str("subscription"), + } + } +} diff --git a/crates/yt/src/download/download_options.rs b/crates/yt/src/commands/download/implm/download/download_options.rs index 03c20ba..15fed7e 100644 --- a/crates/yt/src/download/download_options.rs +++ b/crates/yt/src/commands/download/implm/download/download_options.rs @@ -11,13 +11,16 @@ use anyhow::Context; use serde_json::{Value, json}; -use yt_dlp::{YoutubeDL, YoutubeDLOptions}; +use yt_dlp::{YoutubeDL, options::YoutubeDLOptions}; -use crate::{app::App, storage::video_database::YtDlpOptions}; +use crate::app::App; use super::progress_hook::wrapped_progress_hook; -pub fn download_opts(app: &App, additional_opts: &YtDlpOptions) -> anyhow::Result<YoutubeDL> { +pub(crate) fn download_opts( + app: &App, + subtitle_langs: Option<&String>, +) -> anyhow::Result<YoutubeDL> { YoutubeDLOptions::new() .with_progress_hook(wrapped_progress_hook) .set("extract_flat", "in_playlist") @@ -106,8 +109,8 @@ pub fn download_opts(app: &App, additional_opts: &YtDlpOptions) -> anyhow::Resul .set( "subtitleslangs", Value::Array( - additional_opts - .subtitle_langs + subtitle_langs + .map_or("", String::as_str) .split(',') .map(|val| Value::String(val.to_owned())) .collect::<Vec<_>>(), diff --git a/crates/yt/src/commands/download/implm/download/mod.rs b/crates/yt/src/commands/download/implm/download/mod.rs new file mode 100644 index 0000000..ab9de80 --- /dev/null +++ b/crates/yt/src/commands/download/implm/download/mod.rs @@ -0,0 +1,290 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{collections::HashMap, path::PathBuf, sync::Arc, time::Duration}; + +use crate::{ + app::App, + commands::download::implm::download::download_options::download_opts, + shared::bytes::Bytes, + storage::{ + db::{extractor_hash::ExtractorHash, insert::Operations, video::Video}, + notify::{wait_for_cache_reduction, wait_for_db_write}, + }, + yt_dlp::get_current_cache_allocation, +}; + +use anyhow::{Context, Result, bail}; +use log::{debug, error, info, warn}; +use tokio::{select, task::JoinHandle, time}; +use yt_dlp::YoutubeDL; + +#[allow(clippy::module_name_repetitions)] +pub(crate) mod download_options; +pub(crate) mod progress_hook; + +#[derive(Debug)] +#[allow(clippy::module_name_repetitions)] +pub(crate) struct CurrentDownload { + task_handle: JoinHandle<Result<(PathBuf, Video)>>, + yt_dlp: Arc<YoutubeDL>, + extractor_hash: ExtractorHash, +} + +impl CurrentDownload { + fn new_from_video(app: &App, video: Video) -> Result<Self> { + let extractor_hash = video.extractor_hash; + + debug!("Download started: {}", &video.title); + let yt_dlp = Arc::new(download_opts(app, video.subtitle_langs.as_ref())?); + + let local_yt_dlp = Arc::clone(&yt_dlp); + + let task_handle = tokio::task::spawn_blocking(move || { + let mut result = local_yt_dlp + .download(&[video.url.clone()]) + .with_context(|| format!("Failed to download video: '{}'", video.title))?; + + assert_eq!(result.len(), 1); + Ok((result.remove(0), video)) + }); + + Ok(Self { + task_handle, + yt_dlp, + extractor_hash, + }) + } + + fn abort(self) -> Result<()> { + debug!("Cancelling download."); + self.yt_dlp.close()?; + + Ok(()) + } + + fn is_finished(&self) -> bool { + self.task_handle.is_finished() + } + + async fn finalize(self, app: &App) -> Result<()> { + let (result, mut video) = self.task_handle.await??; + + let mut ops = Operations::new("Downloader: Set download path"); + video.set_download_path(&result, &mut ops); + ops.commit(app) + .await + .with_context(|| format!("Failed to commit download of video: '{}'", video.title))?; + + info!( + "Video '{}' was downlaoded to path: {}", + video.title, + result.display() + ); + + Ok(()) + } +} + +enum CacheSizeCheck { + /// The video can be downloaded + Fits, + + /// The video and the current cache size together would exceed the size + TooLarge, + + /// The video would not even fit into the empty cache + ExceedsMaxCacheSize, +} + +#[derive(Debug)] +pub(crate) struct Downloader { + current_download: Option<CurrentDownload>, + video_size_cache: HashMap<ExtractorHash, u64>, + printed_warning: bool, + cached_cache_allocation: Option<Bytes>, +} + +impl Default for Downloader { + fn default() -> Self { + Self::new() + } +} + +impl Downloader { + #[must_use] + pub(crate) fn new() -> Self { + Self { + current_download: None, + video_size_cache: HashMap::new(), + printed_warning: false, + cached_cache_allocation: None, + } + } + + /// Check if enough cache is available. + /// + /// Will wait for the next cache deletion if not. + async fn is_enough_cache_available( + &mut self, + app: &App, + max_cache_size: u64, + next_video: &Video, + ) -> Result<CacheSizeCheck> { + if let Some(cdownload) = &self.current_download { + if cdownload.extractor_hash == next_video.extractor_hash { + // If the video is already being downloaded it will always fit. Otherwise the + // download would not have been started. + return Ok(CacheSizeCheck::Fits); + } + } + let cache_allocation = get_current_cache_allocation(app).await?; + let video_size = self.get_approx_video_size(next_video)?; + + if video_size >= max_cache_size { + error!( + "The video '{}' ({}) exceeds the maximum cache size ({})! \ + Please set a bigger maximum (`--max-cache-size`) or skip it.", + next_video.title, + Bytes::new(video_size), + Bytes::new(max_cache_size) + ); + + return Ok(CacheSizeCheck::ExceedsMaxCacheSize); + } + + if cache_allocation.as_u64() + video_size >= max_cache_size { + if !self.printed_warning { + warn!( + "Can't download video: '{}' ({}) as it's too large for the cache ({} of {} allocated). \ + Waiting for cache size reduction..", + next_video.title, + Bytes::new(video_size), + &cache_allocation, + Bytes::new(max_cache_size) + ); + self.printed_warning = true; + + // Update this value immediately. + // This avoids printing the "Current cache size has changed .." warning below. + self.cached_cache_allocation = Some(cache_allocation); + } + + if let Some(cca) = self.cached_cache_allocation { + if cca != cache_allocation { + // Only print the warning if the display string has actually changed. + // Otherwise, we might confuse the user + if cca.to_string() != cache_allocation.to_string() { + warn!("Current cache size has changed, it's now: '{cache_allocation}'"); + } + debug!( + "Cache size has changed: {} -> {}", + cca.as_u64(), + cache_allocation.as_u64() + ); + self.cached_cache_allocation = Some(cache_allocation); + } + } else { + unreachable!( + "The `printed_warning` should be false in this case, \ + and thus should have already set the `cached_cache_allocation`." + ); + } + + // Wait and hope, that a large video is deleted from the cache. + wait_for_cache_reduction(app).await?; + Ok(CacheSizeCheck::TooLarge) + } else { + self.printed_warning = false; + Ok(CacheSizeCheck::Fits) + } + } + + /// The entry point to the Downloader. + /// This Downloader will periodically check if the database has changed, and then also + /// change which videos it downloads. + /// This will run, until the database doesn't contain any watchable videos + pub(crate) async fn consume(&mut self, app: Arc<App>, max_cache_size: u64) -> Result<()> { + while let Some(next_video) = Video::next_to_download(&app).await? { + match self + .is_enough_cache_available(&app, max_cache_size, &next_video) + .await? + { + CacheSizeCheck::Fits => (), + CacheSizeCheck::TooLarge => continue, + CacheSizeCheck::ExceedsMaxCacheSize => bail!("Giving up."), + } + + if self.current_download.is_some() { + let current_download = self.current_download.take().expect("It is `Some`."); + + if current_download.is_finished() { + // The download is done, finalize it and leave it removed. + current_download.finalize(&app).await?; + continue; + } + + if next_video.extractor_hash == current_download.extractor_hash { + // We still want to download the same video. + // reset the taken value + self.current_download = Some(current_download); + } else { + info!( + "Noticed, that the next video is not the video being downloaded, replacing it ('{}' vs. '{}')!", + next_video.extractor_hash.as_short_hash(&app).await?, + current_download.extractor_hash.as_short_hash(&app).await? + ); + + // Replace the currently downloading video + current_download + .abort() + .context("Failed to abort last download")?; + + let new_current_download = CurrentDownload::new_from_video(&app, next_video)?; + + self.current_download = Some(new_current_download); + } + } else { + info!( + "No video is being downloaded right now, setting it to '{}'", + next_video.title + ); + let new_current_download = CurrentDownload::new_from_video(&app, next_video)?; + self.current_download = Some(new_current_download); + } + + // We have to continuously check, if the current download is done. + // As such we simply wait or recheck on the next write to the db. + select! { + () = time::sleep(Duration::from_secs(1)) => (), + Ok(()) = wait_for_db_write(&app) => (), + } + } + + info!("Finished downloading!"); + Ok(()) + } + + fn get_approx_video_size(&mut self, video: &Video) -> Result<u64> { + if let Some(value) = self.video_size_cache.get(&video.extractor_hash) { + Ok(*value) + } else { + let size = video.get_approx_size()?; + + assert_eq!( + self.video_size_cache.insert(video.extractor_hash, size), + None + ); + + Ok(size) + } + } +} diff --git a/crates/yt/src/commands/download/implm/download/progress_hook.rs b/crates/yt/src/commands/download/implm/download/progress_hook.rs new file mode 100644 index 0000000..a414d4a --- /dev/null +++ b/crates/yt/src/commands/download/implm/download/progress_hook.rs @@ -0,0 +1,210 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + collections::HashSet, + io::{Write, stderr}, + process, + sync::{Mutex, atomic::Ordering}, +}; + +use colors::{Colorize, IntoCanvas}; +use log::{Level, log_enabled}; +use yt_dlp::{json_cast, json_get, wrap_progress_hook}; + +use crate::{ + ansi_escape_codes::{clear_whole_line, move_to_col}, + config::SHOULD_DISPLAY_COLOR, + select::duration::MaybeDuration, + shared::bytes::Bytes, +}; + +macro_rules! json_get_default { + ($value:expr, $name:literal, $convert:ident, $default:expr) => { + $value.get($name).map_or($default, |v| { + if v == &serde_json::Value::Null { + $default + } else { + json_cast!(@log_key $name, v, $convert) + } + }) + }; +} + +static TITLES: Mutex<Option<HashSet<String>>> = Mutex::new(None); + +fn format_bytes(bytes: u64) -> String { + let bytes = Bytes::new(bytes); + bytes.to_string() +} + +fn format_speed(speed: f64) -> String { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let bytes = Bytes::new(speed.floor() as u64); + format!("{bytes}/s") +} + +/// # Panics +/// If expectations fail. +#[allow(clippy::needless_pass_by_value)] +pub(crate) fn progress_hook( + input: serde_json::Map<String, serde_json::Value>, +) -> Result<(), std::io::Error> { + // Only add the handler, if the log-level is higher than Debug (this avoids covering debug + // messages). + if log_enabled!(Level::Debug) { + return Ok(()); + } + + let info_dict = json_get!(input, "info_dict", as_object); + + let get_title = || -> String { + match json_get!(info_dict, "ext", as_str) { + "vtt" => { + format!( + "Subtitles ({})", + json_get_default!(info_dict, "name", as_str, "<No Subtitle Language>") + ) + } + "webm" | "mp4" | "mp3" | "m4a" => { + json_get_default!(info_dict, "title", as_str, "<No title>").to_owned() + } + other => panic!("The extension '{other}' is not yet implemented"), + } + }; + + match json_get!(input, "status", as_str) { + "downloading" => { + let elapsed = json_get_default!(input, "elapsed", as_f64, 0.0); + let eta = json_get_default!(input, "eta", as_f64, 0.0); + let speed = json_get_default!(input, "speed", as_f64, 0.0); + + let downloaded_bytes = json_get!(input, "downloaded_bytes", as_u64); + let (total_bytes, bytes_is_estimate): (u64, &'static str) = { + let total_bytes = json_get_default!(input, "total_bytes", as_u64, 0); + + if total_bytes == 0 { + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let maybe_estimate = + json_get_default!(input, "total_bytes_estimate", as_f64, 0.0) as u64; + + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + if maybe_estimate == 0 { + // The download speed should be in bytes + // per second and the eta in seconds. + // Thus multiplying them gets us the raw bytes + // (which were estimated by `yt_dlp`, from their `info.json`) + let bytes_still_needed = (speed * eta).ceil() as u64; + + (downloaded_bytes + bytes_still_needed, "~") + } else { + (maybe_estimate, "~") + } + } else { + (total_bytes, "") + } + }; + + let percent: f64 = { + if total_bytes == 0 { + 100.0 + } else { + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + clippy::cast_precision_loss + )] + { + (downloaded_bytes as f64 / total_bytes as f64) * 100.0 + } + } + }; + + clear_whole_line(); + move_to_col(1); + + let should_use_color = SHOULD_DISPLAY_COLOR.load(Ordering::Relaxed); + + let title = get_title(); + + eprint!( + "{} [{}/{} at {}] -> [{} of {}{} {}] ", + (&title).bold().blue().render(should_use_color), + MaybeDuration::from_secs_f64(elapsed) + .bold() + .yellow() + .render(should_use_color), + MaybeDuration::from_secs_f64(eta) + .bold() + .yellow() + .render(should_use_color), + format_speed(speed).bold().green().render(should_use_color), + format_bytes(downloaded_bytes) + .bold() + .red() + .render(should_use_color), + bytes_is_estimate.bold().red().render(should_use_color), + format_bytes(total_bytes) + .bold() + .red() + .render(should_use_color), + format!("{percent:.02}%") + .bold() + .cyan() + .render(should_use_color), + ); + stderr().flush()?; + + { + let mut titles = TITLES.lock().expect("The lock should work"); + + match titles.as_mut() { + Some(titles) => { + titles.insert(title); + } + None => *titles = Some(HashSet::from_iter([title])), + } + } + } + "finished" => { + let should_use_color = SHOULD_DISPLAY_COLOR.load(Ordering::Relaxed); + let title = get_title(); + + let has_already_been_printed = { + let titles = TITLES.lock().expect("The lock should work"); + + match titles.as_ref() { + Some(titles) => titles.contains(&title), + None => false, + } + }; + + if has_already_been_printed { + eprintln!("-> Finished downloading."); + } else { + eprintln!( + "Download of {} already finished.", + title.bold().blue().render(should_use_color) + ); + } + } + "error" => { + // TODO: This should probably return an Err. But I'm not so sure where the error would + // bubble up to (i.e., who would catch it) <2025-01-21> + eprintln!("-> Error while downloading: {}", get_title()); + process::exit(1); + } + other => unreachable!("'{other}' should not be a valid state!"), + } + + Ok(()) +} + +wrap_progress_hook!(progress_hook, wrapped_progress_hook); diff --git a/crates/yt/src/commands/download/implm/mod.rs b/crates/yt/src/commands/download/implm/mod.rs new file mode 100644 index 0000000..c74a909 --- /dev/null +++ b/crates/yt/src/commands/download/implm/mod.rs @@ -0,0 +1,55 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::sync::Arc; + +use crate::{ + app::App, + commands::download::DownloadCommand, + shared::bytes::Bytes, + storage::db::{ + insert::{Operations, maintenance::clear_stale_downloaded_paths}, + video::{Video, VideoStatusMarker}, + }, +}; + +use anyhow::Result; +use log::info; + +mod download; + +impl DownloadCommand { + pub(in crate::commands) async fn implm(self, app: Arc<App>) -> Result<()> { + let DownloadCommand { + force, + max_cache_size, + } = self; + + let max_cache_size = max_cache_size.unwrap_or(app.config.download.max_cache_size.as_u64()); + info!("Max cache size: '{}'", Bytes::new(max_cache_size)); + + clear_stale_downloaded_paths(&app).await?; + if force { + let mut all = Video::in_states(&app, &[VideoStatusMarker::Cached]).await?; + + let mut ops = Operations::new("Download: Clear old download paths due to `--force`"); + for a in &mut all { + a.remove_download_path(&mut ops); + } + ops.commit(&app).await?; + } + + download::Downloader::new() + .consume(app, max_cache_size) + .await?; + + Ok(()) + } +} diff --git a/crates/yt/src/commands/download/mod.rs b/crates/yt/src/commands/download/mod.rs new file mode 100644 index 0000000..15026ba --- /dev/null +++ b/crates/yt/src/commands/download/mod.rs @@ -0,0 +1,34 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use anyhow::Context; +use clap::Parser; + +use crate::shared::bytes::Bytes; + +mod implm; + +#[derive(Parser, Debug)] +pub(super) struct DownloadCommand { + /// Forcefully re-download all cached videos (i.e. delete all already downloaded paths, then download). + #[arg(short, long)] + force: bool, + + /// The maximum size the download dir should have. + #[arg(short, long, value_parser = byte_parser)] + max_cache_size: Option<u64>, +} + +fn byte_parser(input: &str) -> Result<u64, anyhow::Error> { + Ok(input + .parse::<Bytes>() + .with_context(|| format!("Failed to parse '{input}' as bytes!"))? + .as_u64()) +} diff --git a/crates/yt/src/commands/implm.rs b/crates/yt/src/commands/implm.rs new file mode 100644 index 0000000..7c60c6a --- /dev/null +++ b/crates/yt/src/commands/implm.rs @@ -0,0 +1,38 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::sync::Arc; + +use crate::commands::Command; + +use anyhow::Result; + +impl Command { + pub(crate) async fn implm(self, app: crate::app::App) -> Result<()> { + match self { + Command::Cache { cmd } => cmd.implm(&app).await?, + Command::Config { cmd } => cmd.implm(&app)?, + Command::Database { cmd } => cmd.implm(&app).await?, + Command::Download { cmd } => cmd.implm(Arc::new(app)).await?, + Command::Playlist { cmd } => cmd.implm(&app).await?, + Command::Select { cmd } => { + cmd.unwrap_or_default().implm(&app).await?; + } + Command::Show { cmd } => cmd.implm(&app).await?, + Command::Status { cmd } => cmd.implm(&app).await?, + Command::Subscriptions { cmd } => cmd.implm(&app).await?, + Command::Update { cmd } => cmd.implm(&app).await?, + Command::Videos { cmd } => cmd.implm(&app).await?, + Command::Watch { cmd } => cmd.implm(Arc::new(app)).await?, + } + + Ok(()) + } +} diff --git a/crates/yt/src/commands/mod.rs b/crates/yt/src/commands/mod.rs new file mode 100644 index 0000000..431acef --- /dev/null +++ b/crates/yt/src/commands/mod.rs @@ -0,0 +1,164 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ffi::OsStr, thread}; + +use clap::Subcommand; +use clap_complete::CompletionCandidate; +use tokio::runtime::Runtime; + +use crate::{ + app::App, + commands::{ + cache::CacheCommand, config::ConfigCommand, database::DatabaseCommand, + download::DownloadCommand, playlist::PlaylistCommand, select::SelectCommand, + show::ShowCommand, status::StatusCommand, subscriptions::SubscriptionCommand, + update::UpdateCommand, videos::VideosCommand, watch::WatchCommand, + }, + config::Config, + storage::db::subscription::Subscriptions, +}; + +pub(super) mod implm; + +mod cache; +mod config; +mod database; +mod download; +mod playlist; +mod select; +mod show; +mod status; +mod subscriptions; +mod update; +mod videos; +mod watch; + +#[derive(Subcommand, Debug)] +#[allow(private_interfaces)] // Only the main `implm` method should be accessible. +pub(super) enum Command { + /// Manipulate the download cache + Cache { + #[command(subcommand)] + cmd: CacheCommand, + }, + + /// Show, the configuration options in effect. + Config { + #[command(flatten)] + cmd: ConfigCommand, + }, + + /// Interact with the video database. + #[command(visible_alias = "db")] + Database { + #[command(subcommand)] + cmd: DatabaseCommand, + }, + + /// Download and cache URLs + Download { + #[command(flatten)] + cmd: DownloadCommand, + }, + + /// Visualize the current playlist + Playlist { + #[command(flatten)] + cmd: PlaylistCommand, + }, + + /// Change the state of videos in the database (the default) + Select { + #[command(subcommand)] + cmd: Option<SelectCommand>, + }, + + /// Show things about the currently playing video. + Show { + #[command(subcommand)] + cmd: ShowCommand, + }, + + /// Show, which videos have been selected to be watched (and their cache status) + Status { + #[command(flatten)] + cmd: StatusCommand, + }, + + /// Manipulate subscription + #[command(visible_alias = "subs")] + Subscriptions { + #[command(subcommand)] + cmd: SubscriptionCommand, + }, + + /// Update the video database + Update { + #[command(flatten)] + cmd: UpdateCommand, + }, + + /// Work with single videos + Videos { + #[command(subcommand)] + cmd: VideosCommand, + }, + + /// Watch the already cached (and selected) videos + Watch { + #[command(flatten)] + cmd: WatchCommand, + }, +} + +impl Default for Command { + fn default() -> Self { + Self::Select { + cmd: Some(SelectCommand::default()), + } + } +} + +fn complete_subscription(current: &OsStr) -> Vec<CompletionCandidate> { + let mut output = vec![]; + + let Some(current_prog) = current.to_str().map(ToOwned::to_owned) else { + return output; + }; + + let Ok(config) = Config::from_config_file(None, None, None) else { + return output; + }; + + let handle = thread::spawn(move || { + let Ok(rt) = Runtime::new() else { + return output; + }; + + let Ok(app) = rt.block_on(App::new(config, false)) else { + return output; + }; + + let Ok(all) = rt.block_on(Subscriptions::get(&app)) else { + return output; + }; + + for sub in all.0.into_keys() { + if sub.starts_with(¤t_prog) { + output.push(CompletionCandidate::new(sub)); + } + } + + output + }); + + handle.join().unwrap_or_default() +} diff --git a/crates/yt/src/commands/playlist/implm.rs b/crates/yt/src/commands/playlist/implm.rs new file mode 100644 index 0000000..603184b --- /dev/null +++ b/crates/yt/src/commands/playlist/implm.rs @@ -0,0 +1,110 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{fmt::Write, path::Path}; + +use crate::{ + ansi_escape_codes, + app::App, + commands::playlist::PlaylistCommand, + storage::{ + db::{ + playlist::Playlist, + video::{Video, VideoStatus}, + }, + notify::wait_for_db_write, + }, + videos::RenderWithApp, +}; + +use anyhow::Result; +use futures::{TryStreamExt, stream::FuturesOrdered}; + +impl PlaylistCommand { + pub(in crate::commands) async fn implm(self, app: &App) -> Result<()> { + let PlaylistCommand { watch } = self; + + let mut previous_output_length = 0; + loop { + let playlist = Playlist::create(app).await?.videos; + + let output = playlist + .into_iter() + .map(|video| async move { + let mut output = String::new(); + + let (_, is_focused) = cache_values(&video); + + if is_focused { + output.push_str("🔻 "); + } else { + output.push_str(" "); + } + + output.push_str(&video.title_fmt().to_string(app)); + + output.push_str(" ("); + output.push_str(&video.parent_subscription_name_fmt().to_string(app)); + output.push(')'); + + output.push_str(" ["); + output.push_str(&video.duration_fmt().to_string(app)); + + if is_focused { + output.push_str(" ("); + if let Some(percent) = video.watch_progress_percent_fmt() { + write!(output, "{}", percent.to_string(app))?; + } else { + write!(output, "{}", video.watch_progress_fmt().to_string(app))?; + } + + output.push(')'); + } + output.push(']'); + + output.push('\n'); + + Ok::<String, anyhow::Error>(output) + }) + .collect::<FuturesOrdered<_>>() + .try_collect::<String>() + .await?; + + // Delete the previous output + ansi_escape_codes::cursor_up(previous_output_length); + ansi_escape_codes::erase_from_cursor_to_bottom(); + + previous_output_length = output.chars().filter(|ch| *ch == '\n').count(); + + print!("{output}"); + + if !watch { + break; + } + + wait_for_db_write(app).await?; + } + + Ok(()) + } +} + +/// Extract the values of the [`VideoStatus::Cached`] value from a Video. +fn cache_values(video: &Video) -> (&Path, bool) { + if let VideoStatus::Cached { + cache_path, + is_focused, + } = &video.status + { + (cache_path, *is_focused) + } else { + unreachable!("All of these videos should be cached"); + } +} diff --git a/crates/yt/src/commands/playlist/mod.rs b/crates/yt/src/commands/playlist/mod.rs new file mode 100644 index 0000000..8d3407d --- /dev/null +++ b/crates/yt/src/commands/playlist/mod.rs @@ -0,0 +1,20 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use clap::Parser; + +mod implm; + +#[derive(Parser, Debug)] +pub(super) struct PlaylistCommand { + /// Linger and display changes + #[arg(short, long)] + watch: bool, +} diff --git a/crates/yt/src/select/selection_file/help.str b/crates/yt/src/commands/select/implm/fs_generators/help.str index e3cc347..e3cc347 100644 --- a/crates/yt/src/select/selection_file/help.str +++ b/crates/yt/src/commands/select/implm/fs_generators/help.str diff --git a/crates/yt/src/select/selection_file/help.str.license b/crates/yt/src/commands/select/implm/fs_generators/help.str.license index a0e196c..a0e196c 100644 --- a/crates/yt/src/select/selection_file/help.str.license +++ b/crates/yt/src/commands/select/implm/fs_generators/help.str.license diff --git a/crates/yt/src/commands/select/implm/fs_generators/mod.rs b/crates/yt/src/commands/select/implm/fs_generators/mod.rs new file mode 100644 index 0000000..10da032 --- /dev/null +++ b/crates/yt/src/commands/select/implm/fs_generators/mod.rs @@ -0,0 +1,355 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + collections::HashMap, + env, + fs::{self, File, OpenOptions}, + io::{BufRead, BufReader, BufWriter, Read, Write}, + iter, + os::fd::{AsFd, AsRawFd}, + path::Path, +}; + +use crate::{ + app::App, + cli::CliArgs, + commands::{ + Command, + select::{ + SelectCommand, SelectSplitSortKey, SelectSplitSortMode, + implm::standalone::{self, handle_select_cmd}, + }, + }, + storage::db::{ + extractor_hash::ExtractorHash, + insert::Operations, + video::{Video, VideoStatusMarker}, + }, +}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use futures::{TryStreamExt, stream::FuturesOrdered}; +use log::info; +use shlex::Shlex; +use tokio::process; + +const HELP_STR: &str = include_str!("./help.str"); + +pub(crate) async fn select_split( + app: &App, + done: bool, + sort_key: SelectSplitSortKey, + sort_mode: SelectSplitSortMode, +) -> Result<()> { + let temp_dir = tempfile::Builder::new() + .prefix("yt_video_select-") + .rand_bytes(6) + .tempdir() + .context("Failed to get tempdir")?; + + let matching_videos = get_videos(app, done).await?; + + let mut no_author = vec![]; + let mut author_map = HashMap::new(); + for video in matching_videos { + if let Some(sub) = &video.parent_subscription_name { + if author_map.contains_key(sub) { + let vec: &mut Vec<_> = author_map + .get_mut(sub) + .expect("This key is set, we checked in the if above"); + + vec.push(video); + } else { + author_map.insert(sub.to_owned(), vec![video]); + } + } else { + no_author.push(video); + } + } + + let author_map = { + let mut temp_vec: Vec<_> = author_map.into_iter().collect(); + + match sort_key { + SelectSplitSortKey::Publisher => { + // PERFORMANCE: The clone here should not be neeed. <2025-06-15> + temp_vec.sort_by_key(|(name, _): &(String, Vec<Video>)| name.to_owned()); + } + SelectSplitSortKey::Videos => { + temp_vec.sort_by_key(|(_, videos): &(String, Vec<Video>)| videos.len()); + } + } + + match sort_mode { + SelectSplitSortMode::Asc => { + // Std's default mode is ascending. + } + SelectSplitSortMode::Desc => { + temp_vec.reverse(); + } + } + + temp_vec + }; + + for (index, (name, videos)) in author_map + .into_iter() + .chain(iter::once(( + "<No parent subscription>".to_owned(), + no_author, + ))) + .enumerate() + { + let mut file_path = temp_dir.path().join(format!("{index:02}_{name}")); + file_path.set_extension("yts"); + + let tmp_file = File::create(&file_path) + .with_context(|| format!("Falied to create file at: {}", file_path.display()))?; + + write_videos_to_file(app, &tmp_file, &videos) + .await + .with_context(|| format!("Falied to populate file at: {}", file_path.display()))?; + } + + open_editor_at(temp_dir.path()).await?; + + let mut paths = vec![]; + for maybe_entry in temp_dir + .path() + .read_dir() + .context("Failed to open temp dir for reading")? + { + let entry = maybe_entry.context("Failed to read entry in temp dir")?; + + if !entry.file_type()?.is_file() { + bail!("Found non-file entry: {}", entry.path().display()); + } + + paths.push(entry.path()); + } + + paths.sort(); + + let mut persistent_file = OpenOptions::new() + .read(false) + .write(true) + .create(true) + .truncate(true) + .open(&app.config.paths.last_selection_path) + .context("Failed to open persistent selection file")?; + + for path in paths { + let mut read_file = File::open(path)?; + + let mut buffer = vec![]; + read_file.read_to_end(&mut buffer)?; + persistent_file.write_all(&buffer)?; + } + + persistent_file.flush()?; + let persistent_file = OpenOptions::new() + .read(true) + .open(format!( + "/proc/self/fd/{}", + persistent_file.as_fd().as_raw_fd() + )) + .context("Failed to re-open persistent file")?; + + let processed = process_file(app, &persistent_file).await?; + + info!("Processed {processed} records."); + temp_dir.close().context("Failed to close the temp dir")?; + Ok(()) +} + +pub(crate) async fn select_file(app: &App, done: bool, use_last_selection: bool) -> Result<()> { + let temp_file = tempfile::Builder::new() + .prefix("yt_video_select-") + .suffix(".yts") + .rand_bytes(6) + .tempfile() + .context("Failed to get tempfile")?; + + if use_last_selection { + fs::copy(&app.config.paths.last_selection_path, &temp_file)?; + } else { + let matching_videos = get_videos(app, done).await?; + + write_videos_to_file(app, temp_file.as_file(), &matching_videos).await?; + } + + open_editor_at(temp_file.path()).await?; + + let read_file = OpenOptions::new().read(true).open(temp_file.path())?; + fs::copy(temp_file.path(), &app.config.paths.last_selection_path) + .context("Failed to persist selection file")?; + + let processed = process_file(app, &read_file).await?; + info!("Processed {processed} records."); + + Ok(()) +} + +async fn get_videos(app: &App, include_done: bool) -> Result<Vec<Video>> { + if include_done { + Video::in_states(app, VideoStatusMarker::ALL).await + } else { + Video::in_states( + app, + &[ + VideoStatusMarker::Pick, + // + VideoStatusMarker::Watch, + VideoStatusMarker::Cached, + ], + ) + .await + } +} + +async fn write_videos_to_file(app: &App, file: &File, videos: &[Video]) -> Result<()> { + // Warm-up the cache for the display rendering of the videos. + // Otherwise the futures would all try to warm it up at the same time. + if let Some(vid) = videos.first() { + drop(vid.to_line_display(app, None).await?); + } + + let mut edit_file = BufWriter::new(file); + + videos + .iter() + .map(|vid| vid.to_select_file_display(app)) + .collect::<FuturesOrdered<_>>() + .try_collect::<Vec<String>>() + .await? + .into_iter() + .try_for_each(|line| -> Result<()> { + edit_file + .write_all(line.as_bytes()) + .context("Failed to write to `edit_file`")?; + + Ok(()) + })?; + + edit_file.write_all(HELP_STR.as_bytes())?; + edit_file.flush().context("Failed to flush edit file")?; + + Ok(()) +} + +async fn process_file(app: &App, file: &File) -> Result<i64> { + let mut line_number = 0; + + let mut ops = Operations::new("Select: process file"); + + // Fetch all the hashes once, instead of every time we need to process a line. + let all_hashes = ExtractorHash::get_all(app).await?; + + let reader = BufReader::new(file); + for line in reader.lines() { + let line = line.context("Failed to read a line")?; + + if let Some(line) = process_line(&line)? { + line_number -= 1; + + // debug!( + // "Parsed command: `{}`", + // line.iter() + // .map(|val| format!("\"{}\"", val)) + // .collect::<Vec<String>>() + // .join(" ") + // ); + + let arg_line = ["yt", "select"] + .into_iter() + .chain(line.iter().map(String::as_str)); + + let args = CliArgs::parse_from(arg_line); + + let Command::Select { cmd } = args + .command + .expect("This will be some, as we constructed it above.") + else { + unreachable!("This is checked in the `filter_line` function") + }; + + match cmd.expect( + "This value should always be some \ + here, as it would otherwise thrown an error above.", + ) { + SelectCommand::File { .. } | SelectCommand::Split { .. } => { + bail!("You cannot use `select file` or `select split` recursively.") + } + SelectCommand::Add { urls, start, stop } => { + Box::pin(standalone::add::add(app, urls, start, stop)).await?; + } + other => { + let shared = other + .clone() + .into_shared() + .expect("The ones without shared should have been filtered out."); + + let hash = shared.hash.realize(app, Some(&all_hashes)).await?; + let mut video = hash + .get_with_app(app) + .await + .expect("The hash was already realized, it should therefore exist"); + + handle_select_cmd(app, other, &mut video, Some(line_number), &mut ops).await?; + } + } + } + } + + ops.commit(app).await?; + Ok(-line_number) +} + +async fn open_editor_at(path: &Path) -> Result<()> { + let editor = env::var("EDITOR").unwrap_or("nvim".to_owned()); + + let mut nvim = process::Command::new(&editor); + nvim.arg(path); + let status = nvim + .status() + .await + .with_context(|| format!("Falied to run editor: {editor}"))?; + + if status.success() { + Ok(()) + } else { + bail!("Editor ({editor}) exited with error status: {}", status) + } +} + +fn process_line(line: &str) -> Result<Option<Vec<String>>> { + // Filter out comments and empty lines + if line.starts_with('#') || line.trim().is_empty() { + Ok(None) + } else { + let split: Vec<_> = { + let mut shl = Shlex::new(line); + let res = shl.by_ref().collect(); + + if shl.had_error { + bail!("Failed to parse line '{line}'") + } + + assert_eq!(shl.line_no, 1, "A unexpected newline appeared"); + res + }; + + assert!(!split.is_empty()); + + Ok(Some(split)) + } +} diff --git a/crates/yt/src/commands/select/implm/mod.rs b/crates/yt/src/commands/select/implm/mod.rs new file mode 100644 index 0000000..f39c77f --- /dev/null +++ b/crates/yt/src/commands/select/implm/mod.rs @@ -0,0 +1,52 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{app::App, commands::select::SelectCommand, storage::db::insert::Operations}; + +use anyhow::Result; + +mod fs_generators; +mod standalone; + +impl SelectCommand { + pub(in crate::commands) async fn implm(self, app: &App) -> Result<()> { + match self { + SelectCommand::File { + done, + use_last_selection, + } => Box::pin(fs_generators::select_file(app, done, use_last_selection)).await?, + SelectCommand::Split { + done, + sort_key, + sort_mode, + } => Box::pin(fs_generators::select_split(app, done, sort_key, sort_mode)).await?, + SelectCommand::Add { urls, start, stop } => { + Box::pin(standalone::add::add(app, urls, start, stop)).await?; + } + other => { + let shared = other + .clone() + .into_shared() + .expect("The ones without shared should have been filtered out."); + let hash = shared.hash.realize(app, None).await?; + let mut video = hash + .get_with_app(app) + .await + .expect("The hash was already realized, it should therefore exist"); + + let mut ops = Operations::new("Main: handle select cmd"); + standalone::handle_select_cmd(app, other, &mut video, None, &mut ops).await?; + ops.commit(app).await?; + } + } + + Ok(()) + } +} diff --git a/crates/yt/src/select/cmds/add.rs b/crates/yt/src/commands/select/implm/standalone/add.rs index 387b3a1..2a7db53 100644 --- a/crates/yt/src/select/cmds/add.rs +++ b/crates/yt/src/commands/select/implm/standalone/add.rs @@ -10,20 +10,17 @@ use crate::{ app::App, - download::download_options::download_opts, - storage::video_database::{ - self, extractor_hash::ExtractorHash, get::get_all_hashes, set::add_video, - }, - update::video_entry_to_video, + storage::db::{extractor_hash::ExtractorHash, insert::Operations, video::Video}, + yt_dlp::yt_dlp_opts_updating, }; use anyhow::{Context, Result, bail}; -use log::{error, warn}; +use log::{debug, error, warn}; use url::Url; -use yt_dlp::{InfoJson, YoutubeDL, json_cast, json_get}; +use yt_dlp::{YoutubeDL, info_json::InfoJson, json_cast, json_get, json_try_get}; #[allow(clippy::too_many_lines)] -pub(super) async fn add( +pub(crate) async fn add( app: &App, urls: Vec<Url>, start: Option<usize>, @@ -31,11 +28,17 @@ pub(super) async fn add( ) -> Result<()> { for url in urls { async fn process_and_add(app: &App, entry: InfoJson, yt_dlp: &YoutubeDL) -> Result<()> { - let url = json_get!(entry, "url", as_str).parse()?; - - let entry = yt_dlp - .extract_info(&url, false, true) - .with_context(|| format!("Failed to fetch entry for url: '{url}'"))?; + let entry = if json_try_get!(entry, "formats", as_array).is_some() { + // We assume, that this entry is already processed. + debug!("Refusing to re-process entry again"); + entry + } else { + let url = json_get!(entry, "url", as_str).parse()?; + + yt_dlp + .extract_info(&url, false, true) + .with_context(|| format!("Failed to fetch entry for url: '{url}'"))? + }; add_entry(app, entry).await?; @@ -45,50 +48,46 @@ pub(super) async fn add( async fn add_entry(app: &App, entry: InfoJson) -> Result<()> { // We have to re-fetch all hashes every time, because a user could try to add the same // URL twice (for whatever reason.) - let hashes = get_all_hashes(app) + let hashes = ExtractorHash::get_all(app) .await .context("Failed to fetch all video hashes")?; - let extractor_hash = blake3::hash(json_get!(entry, "id", as_str).as_bytes()); + + let extractor_hash = ExtractorHash::from_info_json(&entry); if hashes.contains(&extractor_hash) { error!( "Video '{}'{} is already in the database. Skipped adding it", - ExtractorHash::from_hash(extractor_hash) - .into_short_hash(app) + extractor_hash + .as_short_hash(app) .await .with_context(|| format!( "Failed to format hash of video '{}' as short hash", - entry - .get("url") - .map_or("<Unknown video Url>".to_owned(), ToString::to_string) + json_try_get!(entry, "url", as_str).unwrap_or("<Unknown video Url>") ))?, - entry - .get("title") - .map_or(String::new(), |title| format!(" ('{title}')")) + json_try_get!(entry, "title", as_str) + .map_or(String::new(), |title| format!(" (\"{title}\")")) ); return Ok(()); } - let video = video_entry_to_video(&entry, None)?; - add_video(app, video.clone()).await?; + let mut ops = Operations::new("SelectAdd: Video entry to video"); + let video = Video::from_info_json(&entry, None)?.add(&mut ops)?; + ops.commit(app).await?; - println!("{}", &video.to_line_display(app).await?); + println!("{}", &video.to_line_display(app, None).await?); Ok(()) } - let yt_dlp = download_opts( - app, - &video_database::YtDlpOptions { - subtitle_langs: String::new(), - }, + let yt_dlp = yt_dlp_opts_updating( + start.unwrap_or(0) + stop.map_or(usize::MAX, |val| val.saturating_add(1)), )?; let entry = yt_dlp .extract_info(&url, false, true) .with_context(|| format!("Failed to fetch entry for url: '{url}'"))?; - match entry.get("_type").map(|val| json_cast!(val, as_str)) { - Some("Video") => { + match json_try_get!(entry, "_type", as_str) { + Some("video") => { add_entry(app, entry).await?; if start.is_some() || stop.is_some() { warn!( @@ -96,12 +95,15 @@ pub(super) async fn add( ); } } - Some("Playlist") => { - if let Some(entries) = entry.get("entries") { - let entries = json_cast!(entries, as_array); + Some("playlist") => { + if let Some(entries) = json_try_get!(entry, "entries", as_array) { let start = start.unwrap_or(0); let stop = stop.unwrap_or(entries.len() - 1); + if entries.is_empty() { + bail!("Failed to add playlist, as it is empty (contains no entries).") + } + let respected_entries = take_vector(entries, start, stop).with_context(|| { format!( @@ -141,8 +143,7 @@ pub(super) async fn add( } } other => bail!( - "Your URL should point to a video or a playlist, but points to a '{:#?}'", - other + "Your URL should point to a video or a playlist, but points to a '{other:#?}'" ), } } @@ -164,7 +165,7 @@ fn take_vector<T>(vector: &[T], start: usize, stop: usize) -> Result<&[T]> { #[cfg(test)] mod test { - use crate::select::cmds::add::take_vector; + use super::take_vector; #[test] fn test_vector_take() { diff --git a/crates/yt/src/commands/select/implm/standalone/mod.rs b/crates/yt/src/commands/select/implm/standalone/mod.rs new file mode 100644 index 0000000..9512e32 --- /dev/null +++ b/crates/yt/src/commands/select/implm/standalone/mod.rs @@ -0,0 +1,132 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::io::{Write, stderr}; + +use crate::{ + ansi_escape_codes, + app::App, + commands::select::{SelectCommand, SharedSelectionCommandArgs}, + storage::db::{ + insert::{Operations, video::Operation}, + video::{Priority, Video, VideoStatus}, + }, +}; + +use anyhow::{Context, Result, bail}; + +pub(super) mod add; + +pub(crate) async fn handle_select_cmd( + app: &App, + cmd: SelectCommand, + video: &mut Video, + line_number: Option<i64>, + ops: &mut Operations<Operation>, +) -> Result<()> { + let status = match cmd { + SelectCommand::Pick { shared } => Some((VideoStatus::Pick, shared)), + SelectCommand::Drop { shared } => Some((VideoStatus::Drop, shared)), + SelectCommand::Watched { shared } => Some((VideoStatus::Watched, shared)), + SelectCommand::Watch { shared } => { + if let VideoStatus::Cached { + cache_path, + is_focused, + } = &video.status + { + Some(( + VideoStatus::Cached { + cache_path: cache_path.to_owned(), + is_focused: *is_focused, + }, + shared, + )) + } else { + Some((VideoStatus::Watch, shared)) + } + } + SelectCommand::Url { shared } => { + let Some(url) = shared.url else { + bail!("You need to provide a url to `select url ..`") + }; + + let mut firefox = std::process::Command::new(app.config.commands.url_opener.first()); + firefox.args(app.config.commands.url_opener.tail()); + firefox.arg(url.as_str()); + let _handle = firefox.spawn().context("Failed to run firefox")?; + None + } + SelectCommand::File { .. } | SelectCommand::Split { .. } | SelectCommand::Add { .. } => { + unreachable!("These should have been filtered out") + } + }; + + if let Some((status, shared)) = status { + handle_status_change( + app, + video, + shared, + line_number, + status, + line_number.is_none(), + ops, + ) + .await?; + } + + Ok(()) +} + +async fn handle_status_change( + app: &App, + video: &mut Video, + shared: SharedSelectionCommandArgs, + line_number: Option<i64>, + new_status: VideoStatus, + is_single: bool, + ops: &mut Operations<Operation>, +) -> Result<()> { + let priority = compute_priority(line_number, shared.priority); + + video.set_status(new_status, ops); + if let Some(priority) = priority { + video.set_priority(priority, ops); + } + + if let Some(subtitle_langs) = shared.subtitle_langs { + video.set_subtitle_langs(subtitle_langs, ops); + } + if let Some(playback_speed) = shared.playback_speed { + video.set_playback_speed(playback_speed, ops); + } + + if !is_single { + ansi_escape_codes::clear_whole_line(); + ansi_escape_codes::move_to_col(1); + } + + eprint!("{}", &video.to_line_display(app, None).await?); + + if is_single { + eprintln!(); + } else { + stderr().flush()?; + } + + Ok(()) +} + +fn compute_priority(line_number: Option<i64>, priority: Option<i64>) -> Option<Priority> { + if let Some(pri) = priority { + Some(Priority::from(pri)) + } else { + line_number.map(Priority::from) + } +} diff --git a/crates/yt/src/commands/select/mod.rs b/crates/yt/src/commands/select/mod.rs new file mode 100644 index 0000000..db69238 --- /dev/null +++ b/crates/yt/src/commands/select/mod.rs @@ -0,0 +1,230 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + fmt::{self, Display, Formatter}, + str::FromStr, +}; + +use chrono::NaiveDate; +use clap::{Args, Subcommand, ValueEnum}; +use url::Url; + +use crate::{select::duration::MaybeDuration, storage::db::extractor_hash::LazyExtractorHash}; + +mod implm; + +#[derive(Subcommand, Clone, Debug)] +// NOTE: Keep this in sync with the [`constants::HELP_STR`] constant. <2024-08-20> +// NOTE: Also keep this in sync with the `tree-sitter-yts/grammar.js`. <2024-11-04> +#[allow(private_interfaces)] // Only the main `implm` method should be accessible. +pub(super) enum SelectCommand { + /// Open a `git rebase` like file to select the videos to watch (the default) + File { + /// Include done (watched, dropped) videos + #[arg(long, short)] + done: bool, + + /// Use the last selection file (useful if you've spend time on it and want to get it again) + #[arg(long, short, conflicts_with = "done")] + use_last_selection: bool, + }, + + /// Generate a directory, where each file contains only one subscription. + Split { + /// Include done (watched, dropped) videos + #[arg(long, short)] + done: bool, + + /// Which key to use for sorting. + #[arg(default_value_t)] + sort_key: SelectSplitSortKey, + + /// Which mode to use for sorting. + #[arg(default_value_t)] + sort_mode: SelectSplitSortMode, + }, + + /// Add a video to the database + /// + /// This optionally supports to add a playlist. + /// When a playlist is added, the `start` and `stop` arguments can be used to select which + /// playlist entries to include. + #[command(visible_alias = "a")] + Add { + urls: Vec<Url>, + + /// Start adding playlist entries at this playlist index (zero based and inclusive) + #[arg(short = 's', long)] + start: Option<usize>, + + /// Stop adding playlist entries at this playlist index (zero based and inclusive) + #[arg(short = 'e', long)] + stop: Option<usize>, + }, + + /// Mark the video given by the hash to be watched + #[command(visible_alias = "w")] + Watch { + #[command(flatten)] + shared: SharedSelectionCommandArgs, + }, + + /// Mark the video given by the hash to be dropped + #[command(visible_alias = "d")] + Drop { + #[command(flatten)] + shared: SharedSelectionCommandArgs, + }, + + /// Mark the video given by the hash as already watched + #[command(visible_alias = "wd")] + Watched { + #[command(flatten)] + shared: SharedSelectionCommandArgs, + }, + + /// Open the video URL in your specified command + #[command(visible_alias = "u")] + Url { + #[command(flatten)] + shared: SharedSelectionCommandArgs, + }, + + /// Reset the videos status to 'Pick' + #[command(visible_alias = "p")] + Pick { + #[command(flatten)] + shared: SharedSelectionCommandArgs, + }, +} +impl Default for SelectCommand { + fn default() -> Self { + Self::File { + done: false, + use_last_selection: false, + } + } +} + +#[derive(Clone, Debug, Args)] +#[command(infer_subcommands = true)] +/// Mark the video given by the hash to be watched +struct SharedSelectionCommandArgs { + /// The ordering priority (higher means more at the top) + #[arg(short, long)] + priority: Option<i64>, + + /// The subtitles to download (e.g. 'en,de,sv') + #[arg(short = 'l', long)] + subtitle_langs: Option<String>, + + /// The speed to set mpv to + #[arg(short = 's', long)] + playback_speed: Option<f64>, + + /// The short extractor hash + hash: LazyExtractorHash, + + title: Option<String>, + + date: Option<OptionalNaiveDate>, + + publisher: Option<OptionalPublisher>, + + duration: Option<MaybeDuration>, + + url: Option<Url>, +} + +impl SelectCommand { + fn into_shared(self) -> Option<SharedSelectionCommandArgs> { + match self { + SelectCommand::File { .. } + | SelectCommand::Split { .. } + | SelectCommand::Add { .. } => None, + SelectCommand::Watch { shared } + | SelectCommand::Drop { shared } + | SelectCommand::Watched { shared } + | SelectCommand::Url { shared } + | SelectCommand::Pick { shared } => Some(shared), + } + } +} + +#[derive(Clone, Debug, Copy)] +struct OptionalNaiveDate { + date: Option<NaiveDate>, +} +impl FromStr for OptionalNaiveDate { + type Err = anyhow::Error; + fn from_str(v: &str) -> Result<Self, Self::Err> { + if v == "[No release date]" { + Ok(Self { date: None }) + } else { + Ok(Self { + date: Some(NaiveDate::from_str(v)?), + }) + } + } +} +#[derive(Clone, Debug)] +struct OptionalPublisher { + publisher: Option<String>, +} +impl FromStr for OptionalPublisher { + type Err = anyhow::Error; + fn from_str(v: &str) -> Result<Self, Self::Err> { + if v == "[No author]" { + Ok(Self { publisher: None }) + } else { + Ok(Self { + publisher: Some(v.to_owned()), + }) + } + } +} + +#[derive(Default, ValueEnum, Clone, Copy, Debug)] +enum SelectSplitSortKey { + /// Sort by the name of the publisher. + #[default] + Publisher, + + /// Sort by the number of unselected videos per publisher. + Videos, +} +impl Display for SelectSplitSortKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + SelectSplitSortKey::Publisher => f.write_str("publisher"), + SelectSplitSortKey::Videos => f.write_str("videos"), + } + } +} + +#[derive(Default, ValueEnum, Clone, Copy, Debug)] +enum SelectSplitSortMode { + /// Sort in ascending order (small -> big) + #[default] + Asc, + + /// Sort in descending order (big -> small) + Desc, +} + +impl Display for SelectSplitSortMode { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + SelectSplitSortMode::Asc => f.write_str("asc"), + SelectSplitSortMode::Desc => f.write_str("desc"), + } + } +} diff --git a/crates/yt/src/commands/show/implm/mod.rs b/crates/yt/src/commands/show/implm/mod.rs new file mode 100644 index 0000000..a2e40fd --- /dev/null +++ b/crates/yt/src/commands/show/implm/mod.rs @@ -0,0 +1,110 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + fs::{self, OpenOptions}, + io, + process::Command, +}; + +use crate::{ + app::App, + commands::ShowCommand, + output::{display_fmt_and_less, display_less}, + storage::db::video::Video, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use tempfile::Builder; +use tokio_util::bytes::Buf; + +impl ShowCommand { + pub(in crate::commands) async fn implm(&self, app: &App) -> Result<()> { + match self { + ShowCommand::Description {} => { + let description = Video::get_current_description(app).await?; + + display_fmt_and_less(&description)?; + } + ShowCommand::Comments {} => { + let comments = Video::get_current_comments(app).await?; + + display_less(comments.render(app.config.global.display_colors))?; + } + ShowCommand::Thumbnail {} => { + let video = Video::currently_focused(app).await?.ok_or(anyhow!( + "You need to have a current video to display its info" + ))?; + + if let Some(url) = video.thumbnail_url { + let response = reqwest::get(url.clone()) + .await + .with_context(|| format!("Failed to download thumbnail from url: {url}"))?; + let response = response + .error_for_status() + .context("Failed to download thumbnail")?; + + let (tmp_path, mut tmp) = { + let file = Builder::new().prefix("yt-thumbnail-download").tempfile()?; + let (_, path) = file.keep()?; + let new_file = OpenOptions::new() + .write(true) + .read(false) + .create(false) + .truncate(true) + .open(&path)?; + + (path, new_file) + }; + + let mut content = response.bytes().await?.reader(); + io::copy(&mut content, &mut tmp)?; + + let status = Command::new(app.config.commands.image_show.first()) + .args(app.config.commands.image_show.tail()) + .arg(tmp_path.as_os_str()) + .status() + .context("Failed to spawn image show command")?; + + if !status.success() { + bail!( + "{:?} failed with status: {}", + &app.config.commands.image_show.join(" "), + status + ); + } + + fs::remove_file(&tmp_path).with_context(|| { + format!( + "Failed to cleanup downloaded thumbnail image at: {}", + tmp_path.display() + ) + })?; + } else { + eprintln!("Current video does not have a thumbnail."); + } + } + ShowCommand::Info {} => { + let video = Video::currently_focused(app).await?.ok_or(anyhow!( + "You need to have a current video to display its info" + ))?; + + display_less( + video + .to_info_display(app, None) + .await + .context("Failed to format video")?, + )?; + } + } + + Ok(()) + } +} diff --git a/crates/yt/src/commands/show/mod.rs b/crates/yt/src/commands/show/mod.rs new file mode 100644 index 0000000..60f2e51 --- /dev/null +++ b/crates/yt/src/commands/show/mod.rs @@ -0,0 +1,30 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use clap::Subcommand; + +mod implm; + +#[derive(Subcommand, Debug)] +pub(super) enum ShowCommand { + /// Display the description of the currently playing video + Description {}, + + /// Display the comments of the currently playing video. + Comments {}, + + /// Display the thumbnail of the currently playing video. + Thumbnail {}, + + /// Display general info of the currently playing video. + /// + /// This is the same as running `yt videos info <hash of current video>` + Info {}, +} diff --git a/crates/yt/src/commands/status/implm.rs b/crates/yt/src/commands/status/implm.rs new file mode 100644 index 0000000..5832fde --- /dev/null +++ b/crates/yt/src/commands/status/implm.rs @@ -0,0 +1,166 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::time::Duration; + +use crate::{ + app::App, + commands::status::StatusCommand, + select::duration::MaybeDuration, + shared::bytes::Bytes, + storage::db::{ + subscription::Subscriptions, + video::{Video, VideoStatusMarker}, + }, + yt_dlp::get_current_cache_allocation, +}; + +use anyhow::{Context, Result}; + +macro_rules! get { + ($videos:expr, $status:ident) => { + $videos + .iter() + .filter(|vid| vid.status.as_marker() == VideoStatusMarker::$status) + .count() + }; + + (@collect $videos:expr, $status:ident) => { + $videos + .iter() + .filter(|vid| vid.status.as_marker() == VideoStatusMarker::$status) + .collect() + }; +} + +impl StatusCommand { + pub(in crate::commands) async fn implm(self, app: &App) -> Result<()> { + let StatusCommand { format } = self; + + let all_videos = Video::in_states(app, VideoStatusMarker::ALL).await?; + + // lengths + let picked_videos_len = get!(all_videos, Pick); + + let watch_videos_len = get!(all_videos, Watch); + let cached_videos_len = get!(all_videos, Cached); + let watched_videos_len = get!(all_videos, Watched); + let watched_videos: Vec<_> = get!(@collect all_videos, Watched); + + let drop_videos_len = get!(all_videos, Drop); + let dropped_videos_len = get!(all_videos, Dropped); + + let subscriptions = Subscriptions::get(app).await?; + let active_subscriptions_len = subscriptions + .0 + .iter() + .filter(|(_, sub)| sub.is_active) + .count(); + let subscriptions_len = subscriptions.0.len(); + + let watchtime_status = { + let total_watch_time_raw = watched_videos + .iter() + .fold(Duration::default(), |acc, vid| acc + vid.watch_progress); + + // Most things are watched at a speed of s (which is defined in the config file). + // Thus + // y = x * s -> y / s = x + let total_watch_time = Duration::from_secs_f64( + (total_watch_time_raw.as_secs_f64()) / app.config.select.playback_speed, + ); + + let speed = app.config.select.playback_speed; + + // Do not print the adjusted time, if the user has keep the speed level at 1. + #[allow(clippy::float_cmp)] + if speed == 1.0 { + format!( + "Total Watchtime: {}\n", + MaybeDuration::from_std(total_watch_time_raw) + ) + } else { + format!( + "Total Watchtime: {} (at {speed} speed: {})\n", + MaybeDuration::from_std(total_watch_time_raw), + MaybeDuration::from_std(total_watch_time), + ) + } + }; + + let watch_rate: f64 = { + fn to_f64(input: usize) -> f64 { + f64::from(u32::try_from(input).expect("This should never exceed u32::MAX")) + } + + let count = + to_f64(watched_videos_len) / (to_f64(drop_videos_len) + to_f64(dropped_videos_len)); + count * 100.0 + }; + + let cache_usage: Bytes = get_current_cache_allocation(app) + .await + .context("Failed to get current cache allocation")?; + + if let Some(fmt) = format { + let output = fmt + .replace( + "{picked_videos_len}", + picked_videos_len.to_string().as_str(), + ) + .replace("{watch_videos_len}", watch_videos_len.to_string().as_str()) + .replace( + "{cached_videos_len}", + cached_videos_len.to_string().as_str(), + ) + .replace( + "{watched_videos_len}", + watched_videos_len.to_string().as_str(), + ) + .replace("{watch_rate}", watch_rate.to_string().as_str()) + .replace("{drop_videos_len}", drop_videos_len.to_string().as_str()) + .replace( + "{dropped_videos_len}", + dropped_videos_len.to_string().as_str(), + ) + .replace("{watchtime_status}", watchtime_status.to_string().as_str()) + .replace( + "{subscriptions_len}", + subscriptions_len.to_string().as_str(), + ) + .replace( + "{active_subscriptions_len}", + active_subscriptions_len.to_string().as_str(), + ) + .replace("{cache_usage}", cache_usage.to_string().as_str()); + + print!("{output}"); + } else { + println!( + "\ +Picked Videos: {picked_videos_len} + +Watch Videos: {watch_videos_len} +Cached Videos: {cached_videos_len} +Watched Videos: {watched_videos_len} (watch rate: {watch_rate:.2} %) + +Drop Videos: {drop_videos_len} +Dropped Videos: {dropped_videos_len} + +{watchtime_status} + + Subscriptions: {subscriptions_len} ({active_subscriptions_len} active) + Cache usage: {cache_usage}" + ); + } + + Ok(()) + } +} diff --git a/crates/yt/src/commands/status/mod.rs b/crates/yt/src/commands/status/mod.rs new file mode 100644 index 0000000..4a8dee7 --- /dev/null +++ b/crates/yt/src/commands/status/mod.rs @@ -0,0 +1,20 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use clap::Parser; + +mod implm; + +#[derive(Parser, Debug)] +pub(super) struct StatusCommand { + /// Which format to use + #[arg(short, long)] + format: Option<String>, +} diff --git a/crates/yt/src/commands/subscriptions/implm.rs b/crates/yt/src/commands/subscriptions/implm.rs new file mode 100644 index 0000000..1e2e545 --- /dev/null +++ b/crates/yt/src/commands/subscriptions/implm.rs @@ -0,0 +1,287 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::str::FromStr; + +use crate::{ + app::App, + commands::subscriptions::{SubscriptionCommand, SubscriptionStatus}, + storage::db::{ + insert::{Operations, subscription::Operation}, + subscription::{Subscription, Subscriptions, check_url}, + }, +}; + +use anyhow::{Context, Result, bail}; +use log::{error, warn}; +use tokio::{ + fs::File, + io::{AsyncBufRead, AsyncBufReadExt, BufReader, stdin}, +}; +use url::Url; +use yt_dlp::{json_cast, json_get, json_try_get, options::YoutubeDLOptions}; + +impl SubscriptionCommand { + pub(in crate::commands) async fn implm(self, app: &App) -> Result<()> { + match self { + SubscriptionCommand::Add { + name, + url, + no_check, + } => { + let mut ops = Operations::new("main: subscribe"); + subscribe(app, name, url, no_check, &mut ops) + .await + .context("Failed to add a subscription")?; + ops.commit(app).await?; + } + SubscriptionCommand::Remove { name } => { + let mut present_subscriptions = Subscriptions::get(app).await?; + + let mut ops = Operations::new("Subscribe: unsubscribe"); + if let Some(subscription) = present_subscriptions.0.remove(&name) { + subscription.remove(&mut ops); + } else { + bail!("Couldn't find subscription: '{}'", &name); + } + ops.commit(app) + .await + .with_context(|| format!("Failed to unsubscribe from {name:?}"))?; + } + SubscriptionCommand::SetStatus { name, new_status } => { + let mut present_subscriptions = Subscriptions::get(app).await?; + + let mut ops = Operations::new("Subscribe: Set Status"); + if let Some(subscription) = present_subscriptions.0.remove(&name) { + subscription.set_is_active( + match new_status { + SubscriptionStatus::Active => true, + SubscriptionStatus::Inactive => false, + }, + &mut ops, + ); + } else { + bail!("Couldn't find subscription: '{}'", &name); + } + ops.commit(app) + .await + .with_context(|| format!("Failed to change status of {name:?}"))?; + } + SubscriptionCommand::List { active } => { + let all_subs = Subscriptions::get(app).await?; + + let all_subs = if active { + all_subs.remove_inactive() + } else { + all_subs + }; + + for (key, val) in all_subs.0 { + println!( + "{}: '{}' ({})", + key, + val.url, + if val.is_active { "active" } else { "inactive" } + ); + } + } + SubscriptionCommand::Export {} => { + let all_subs = Subscriptions::get(app).await?; + for val in all_subs.0.values() { + println!("{}", val.url); + } + } + SubscriptionCommand::Import { + file, + force, + no_check, + } => { + if let Some(file) = file { + let f = File::open(file).await?; + + import(app, BufReader::new(f), force, no_check).await?; + } else { + import(app, BufReader::new(stdin()), force, no_check).await?; + } + } + } + + Ok(()) + } +} + +async fn import<W: AsyncBufRead + AsyncBufReadExt + Unpin>( + app: &App, + reader: W, + force: bool, + no_check: bool, +) -> Result<()> { + let mut ops = Operations::new("SubscribeImport: init"); + + let all = Subscriptions::get(app).await?; + if force { + all.remove(&mut ops); + } + ops.commit(app).await?; + let mut ops = Operations::new("SubscribeImport: after all subs remove"); + + let mut lines = reader.lines(); + while let Some(line) = lines.next_line().await? { + let url = + Url::from_str(&line).with_context(|| format!("Failed to parse '{line}' as url"))?; + + match subscribe(app, None, url, no_check, &mut ops) + .await + .with_context(|| format!("Failed to subscribe to: '{line}'")) + { + Ok(()) => (), + Err(err) => eprintln!( + "Error while subscribing to '{}': '{}'", + line, + err.source().expect("Should have a source") + ), + } + } + ops.commit(app).await?; + + Ok(()) +} + +async fn subscribe( + app: &App, + name: Option<String>, + url: Url, + no_check: bool, + ops: &mut Operations<Operation>, +) -> Result<()> { + if !(url.as_str().ends_with("videos") + || url.as_str().ends_with("streams") + || url.as_str().ends_with("shorts") + || url.as_str().ends_with("videos/") + || url.as_str().ends_with("streams/") + || url.as_str().ends_with("shorts/")) + && url.as_str().contains("youtube.com") + { + warn!( + "Your youtube url does not seem like it actually tracks a channels playlist \ + (videos, streams, shorts). Adding subscriptions for each of them..." + ); + + let url = Url::parse(&(url.as_str().to_owned() + "/")) + .expect("This was an url, it should stay one"); + + let (videos, streams, shorts) = if let Some(name) = name { + ( + Some(name.clone() + " {Videos}"), + Some(name.clone() + " {Streams}"), + Some(name.clone() + " {Shorts}"), + ) + } else { + (None, None, None) + }; + + let _ = actual_subscribe( + app, + videos, + url.join("videos/").expect("See above."), + no_check, + ops, + ) + .await + .map_err(|err| { + error!("Failed to subscribe to the '{}' variant: {err}", "{Videos}"); + }); + + let _ = actual_subscribe( + app, + streams, + url.join("streams/").expect("See above."), + no_check, + ops, + ) + .await + .map_err(|err| { + error!( + "Failed to subscribe to the '{}' variant: {err}", + "{Streams}" + ); + }); + + let _ = actual_subscribe( + app, + shorts, + url.join("shorts/").expect("See above."), + no_check, + ops, + ) + .await + .map_err(|err| { + error!("Failed to subscribe to the '{}' variant: {err}", "{Shorts}"); + }); + } else { + actual_subscribe(app, name, url, no_check, ops).await?; + } + + Ok(()) +} + +async fn actual_subscribe( + app: &App, + name: Option<String>, + url: Url, + no_check: bool, + ops: &mut Operations<Operation>, +) -> Result<()> { + if !no_check && !check_url(url.clone()).await? { + bail!("The url ('{}') does not represent a playlist!", &url) + } + + let name = if let Some(name) = name { + name + } else { + let yt_dlp = YoutubeDLOptions::new() + .set("playliststart", 1) + .set("playlistend", 10) + .set("noplaylist", false) + .set("extract_flat", "in_playlist") + .build()?; + + let info = yt_dlp.extract_info(&url, false, false)?; + + if json_try_get!(info, "_type", as_str) == Some("playlist") { + json_get!(info, "title", as_str).to_owned() + } else { + bail!("The url ('{}') does not represent a playlist!", &url) + } + }; + + let present_subscriptions = Subscriptions::get(app).await?; + + if let Some(subs) = present_subscriptions.0.get(&name) { + bail!( + "The subscription '{}' could not be added, \ + as another one with the same name ('{}') already exists. \ + It points to the Url: '{}'", + name, + name, + subs.url + ); + } + + let sub = Subscription { + name, + url, + is_active: true, + }; + + sub.add(ops); + + Ok(()) +} diff --git a/crates/yt/src/commands/subscriptions/mod.rs b/crates/yt/src/commands/subscriptions/mod.rs new file mode 100644 index 0000000..6a16a9a --- /dev/null +++ b/crates/yt/src/commands/subscriptions/mod.rs @@ -0,0 +1,84 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::path::PathBuf; + +use clap::{Subcommand, ValueEnum}; +use clap_complete::ArgValueCompleter; +use url::Url; + +use crate::commands::complete_subscription; + +mod implm; + +#[derive(Subcommand, Clone, Debug)] +pub(super) enum SubscriptionCommand { + /// Subscribe to an URL + Add { + #[arg(short, long)] + /// The human readable name of the subscription + name: Option<String>, + + /// The URL to listen to + url: Url, + + /// Don't check, whether the URL actually points to something yt understands. + #[arg(long, default_value_t = false)] + no_check: bool, + }, + + /// Unsubscribe from an URL + Remove { + /// The human readable name of the subscription + #[arg(add = ArgValueCompleter::new(complete_subscription))] + name: String, + }, + + /// Change the status of an subscription. + /// + /// An active subscription will be updated in `yt update`, while an inactive one will not. + SetStatus { + /// The human readable name of the subscription + #[arg(add = ArgValueCompleter::new(complete_subscription))] + name: String, + + /// What should this subscription be considered now? + new_status: SubscriptionStatus, + }, + + /// Import a bunch of URLs as subscriptions. + Import { + /// The file containing the URLs. Will use Stdin otherwise. + file: Option<PathBuf>, + + /// Remove any previous subscriptions + #[arg(short, long)] + force: bool, + + /// Don't check, whether the URLs actually point to something yt understands. + #[arg(long, default_value_t = false)] + no_check: bool, + }, + /// Write all subscriptions in an format understood by `import` + Export {}, + + /// List all subscriptions + List { + /// Only show active subscriptions + #[arg(short, long, default_value_t = false)] + active: bool, + }, +} + +#[derive(ValueEnum, Debug, Clone, Copy)] +pub(in crate::commands) enum SubscriptionStatus { + Active, + Inactive, +} diff --git a/crates/yt/src/commands/update/implm/mod.rs b/crates/yt/src/commands/update/implm/mod.rs new file mode 100644 index 0000000..10626ac --- /dev/null +++ b/crates/yt/src/commands/update/implm/mod.rs @@ -0,0 +1,62 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{ + app::App, + commands::update::{UpdateCommand, implm::updater::Updater}, + storage::db::{ + extractor_hash::ExtractorHash, + subscription::{Subscription, Subscriptions}, + }, +}; + +use anyhow::{Result, bail}; + +mod updater; + +impl UpdateCommand { + pub(in crate::commands) async fn implm(self, app: &App) -> Result<()> { + let UpdateCommand { + max_backlog, + subscriptions: subscription_names_to_update, + } = self; + + let mut all_subs = Subscriptions::get(app).await?.remove_inactive(); + + let max_backlog = max_backlog.unwrap_or(app.config.update.max_backlog); + + let subs: Vec<Subscription> = if subscription_names_to_update.is_empty() { + all_subs.0.into_values().collect() + } else { + subscription_names_to_update + .into_iter() + .map(|sub| { + if let Some(val) = all_subs.0.remove(&sub) { + Ok(val) + } else { + bail!( + "Your specified subscription to update '{}' is not a subscription!", + sub + ) + } + }) + .collect::<Result<_>>()? + }; + + // We can get away with not having to re-fetch the hashes every time, as the returned video + // should not contain duplicates. + let hashes = ExtractorHash::get_all(app).await?; + + let updater = Updater::new(max_backlog, app.config.update.pool_size, hashes); + updater.update(app, subs).await?; + + Ok(()) + } +} diff --git a/crates/yt/src/commands/update/implm/updater.rs b/crates/yt/src/commands/update/implm/updater.rs new file mode 100644 index 0000000..2b96bf2 --- /dev/null +++ b/crates/yt/src/commands/update/implm/updater.rs @@ -0,0 +1,205 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use anyhow::{Context, Result}; +use futures::{StreamExt, future::join_all, stream}; +use log::{Level, debug, error, log_enabled}; +use tokio::io::{AsyncWriteExt, stderr}; +use tokio_util::task::LocalPoolHandle; +use yt_dlp::{ + info_json::InfoJson, json_cast, json_try_get, options::YoutubeDLOptions, process_ie_result, + python_error::PythonError, +}; + +use crate::{ + ansi_escape_codes, + app::App, + storage::db::{ + extractor_hash::ExtractorHash, insert::Operations, subscription::Subscription, video::Video, + }, + yt_dlp::yt_dlp_opts_updating, +}; + +pub(super) struct Updater { + max_backlog: usize, + hashes: Vec<ExtractorHash>, + pool: LocalPoolHandle, +} + +static REACHED_NUMBER: AtomicUsize = const { AtomicUsize::new(1) }; + +impl Updater { + pub(super) fn new(max_backlog: usize, max_threads: usize, hashes: Vec<ExtractorHash>) -> Self { + let pool = LocalPoolHandle::new(max_threads); + + Self { + max_backlog, + hashes, + pool, + } + } + + pub(super) async fn update(self, app: &App, subscriptions: Vec<Subscription>) -> Result<()> { + let total_number = subscriptions.len(); + + let mut stream = stream::iter(subscriptions) + .map(|sub| self.get_new_entries(sub, total_number)) + .buffer_unordered(app.config.update.futures); + + while let Some(output) = stream.next().await { + let mut entries = output?; + + if let Some(next) = entries.next() { + let (sub, entry) = next; + process_subscription(app, sub, entry).await?; + + join_all(entries.map(|(sub, entry)| process_subscription(app, sub, entry))) + .await + .into_iter() + .collect::<Result<(), _>>()?; + } + } + + Ok(()) + } + + #[allow(clippy::too_many_lines)] + async fn get_new_entries( + &self, + sub: Subscription, + total_number: usize, + ) -> Result<impl Iterator<Item = (Subscription, InfoJson)>> { + let max_backlog = self.max_backlog; + let hashes = self.hashes.clone(); + + let yt_dlp = yt_dlp_opts_updating(max_backlog)?; + + self.pool + .spawn_pinned(move || { + async move { + if !log_enabled!(Level::Debug) { + ansi_escape_codes::clear_whole_line(); + ansi_escape_codes::move_to_col(1); + eprint!( + "({}/{total_number}) Checking playlist {}...", + REACHED_NUMBER.fetch_add(1, Ordering::Relaxed), + sub.name + ); + ansi_escape_codes::move_to_col(1); + stderr().flush().await?; + } + + let info = yt_dlp + .extract_info(&sub.url, false, false) + .with_context(|| format!("Failed to get playlist '{}'.", sub.name))?; + + let empty = vec![]; + let entries = json_try_get!(info, "entries", as_array).unwrap_or(&empty); + + let valid_entries: Vec<(Subscription, InfoJson)> = entries + .iter() + .take(max_backlog) + .filter_map(|entry| -> Option<(Subscription, InfoJson)> { + let extractor_hash = + ExtractorHash::from_info_json(json_cast!(entry, as_object)); + + if hashes.contains(&extractor_hash) { + debug!( + "Skipping entry, as it is \ + already present: '{extractor_hash}'", + ); + None + } else { + Some((sub.clone(), json_cast!(entry, as_object).to_owned())) + } + }) + .collect(); + + Ok(valid_entries + .into_iter() + .map(|(sub, entry)| { + let inner_yt_dlp = YoutubeDLOptions::new() + .set("noplaylist", true) + .build() + .expect("Worked before, should work now"); + + match inner_yt_dlp.process_ie_result(entry, false) { + Ok(output) => Ok((sub, output)), + Err(err) => Err(err), + } + }) + // Don't fail the whole update, if one of the entries fails to fetch. + .filter_map(move |base| match base { + Ok(ok) => Some(ok), + Err(err) => { + match err { + process_ie_result::Error::Python(PythonError(err)) => { + if err.contains( + "Join this channel to get access \ + to members-only content ", + ) { + // Hide this error + } else { + // Show the error, but don't fail. + let error = err + .strip_prefix( + "DownloadError: \u{1b}[0;31mERROR:\u{1b}[0m ", + ) + .unwrap_or(&err); + error!("While fetching {:#?}: {error}", sub.name); + } + + None + } + process_ie_result::Error::InfoJsonPrepare(error) => { + error!( + "While fetching {:#?}: Failed to prepare \ + info json: {error}", + sub.name + ); + None + } + } + } + })) + } + }) + .await? + } +} + +async fn process_subscription(app: &App, sub: Subscription, entry: InfoJson) -> Result<()> { + let mut ops = Operations::new("Update: process subscription"); + let video = Video::from_info_json(&entry, Some(&sub)) + .context("Failed to parse search entry as Video")?; + + let title = video.title.clone(); + let url = video.url.clone(); + let video = video.add(&mut ops).with_context(|| { + format!("Failed to add video to database: '{title}' (with url: '{url}')") + })?; + + ops.commit(app).await.with_context(|| { + format!( + "Failed to add video to database: '{}' (with url: '{}')", + video.title, video.url + ) + })?; + println!( + "{}", + &video + .to_line_display(app, None) + .await + .with_context(|| format!("Failed to format video: '{}'", video.title))? + ); + Ok(()) +} diff --git a/crates/yt/src/commands/update/mod.rs b/crates/yt/src/commands/update/mod.rs new file mode 100644 index 0000000..cb29148 --- /dev/null +++ b/crates/yt/src/commands/update/mod.rs @@ -0,0 +1,27 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use clap::Parser; +use clap_complete::ArgValueCompleter; + +use crate::commands::complete_subscription; + +mod implm; + +#[derive(Parser, Debug)] +pub(super) struct UpdateCommand { + /// The maximal number of videos to fetch for each subscription. + #[arg(short, long)] + max_backlog: Option<usize>, + + /// The subscriptions to update + #[arg(add = ArgValueCompleter::new(complete_subscription))] + subscriptions: Vec<String>, +} diff --git a/crates/yt/src/commands/videos/implm.rs b/crates/yt/src/commands/videos/implm.rs new file mode 100644 index 0000000..2a018c7 --- /dev/null +++ b/crates/yt/src/commands/videos/implm.rs @@ -0,0 +1,73 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{ + app::App, + commands::videos::VideosCommand, + storage::db::video::{Video, VideoStatusMarker}, +}; + +use anyhow::{Context, Result}; +use futures::{TryStreamExt, stream::FuturesUnordered}; + +impl VideosCommand { + pub(in crate::commands) async fn implm(self, app: &App) -> Result<()> { + match self { + VideosCommand::List { + search_query, + limit, + format, + } => { + let all_videos = Video::in_states(app, VideoStatusMarker::ALL).await?; + + // turn one video to a color display, to pre-warm the hash shrinking cache + if let Some(val) = all_videos.first() { + val.to_line_display(app, format.clone()).await?; + } + + let limit = limit.unwrap_or(all_videos.len()); + + let all_video_strings: Vec<String> = all_videos + .into_iter() + .take(limit) + .map(|vid| to_line_display_owned(vid, app, format.clone())) + .collect::<FuturesUnordered<_>>() + .try_collect::<Vec<String>>() + .await?; + + if let Some(query) = search_query { + all_video_strings + .into_iter() + .filter(|video| video.to_lowercase().contains(&query.to_lowercase())) + .for_each(|video| println!("{video}")); + } else { + println!("{}", all_video_strings.join("\n")); + } + } + VideosCommand::Info { hash, format } => { + let video = hash.realize(app, None).await?.get_with_app(app).await?; + + print!( + "{}", + &video + .to_info_display(app, format) + .await + .context("Failed to format video")? + ); + } + } + + Ok(()) + } +} + +async fn to_line_display_owned(video: Video, app: &App, format: Option<String>) -> Result<String> { + video.to_line_display(app, format).await +} diff --git a/crates/yt/src/commands/videos/mod.rs b/crates/yt/src/commands/videos/mod.rs new file mode 100644 index 0000000..ca20715 --- /dev/null +++ b/crates/yt/src/commands/videos/mod.rs @@ -0,0 +1,46 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use clap::{ArgAction, Subcommand}; + +use crate::storage::db::extractor_hash::LazyExtractorHash; + +mod implm; + +#[derive(Subcommand, Clone, Debug)] +pub(super) enum VideosCommand { + /// List the videos in the database + #[command(visible_alias = "ls")] + List { + /// An optional search query to limit the results + #[arg(action = ArgAction::Append)] + search_query: Option<String>, + + /// The format string to use. + // TODO(@bpeetz): Encode the default format, as the default string here. <2025-07-04> + #[arg(short, long)] + format: Option<String>, + + /// The number of videos to show + #[arg(short, long)] + limit: Option<usize>, + }, + + /// Get detailed information about a video + Info { + /// The short hash of the video + hash: LazyExtractorHash, + + /// The format string to use. + // TODO(@bpeetz): Encode the default format, as the default string here. <2025-07-04> + #[arg(short, long)] + format: Option<String>, + }, +} diff --git a/crates/yt/src/commands/watch/implm/mod.rs b/crates/yt/src/commands/watch/implm/mod.rs new file mode 100644 index 0000000..8182216 --- /dev/null +++ b/crates/yt/src/commands/watch/implm/mod.rs @@ -0,0 +1,244 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + fs, + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use crate::{ + app::App, + commands::watch::{WatchCommand, implm::playlist_handler::Status}, + storage::{ + db::{ + insert::{Operations, maintenance::clear_stale_downloaded_paths}, + playlist::Playlist, + }, + notify::wait_for_db_write, + }, +}; + +use anyhow::{Context, Result}; +use libmpv2::{Mpv, events::EventContext}; +use log::{debug, info, trace, warn}; +use tokio::{task, time}; + +mod playlist_handler; + +impl WatchCommand { + #[allow(clippy::too_many_lines)] + pub(in crate::commands) async fn implm(self, app: Arc<App>) -> Result<()> { + let WatchCommand { + provide_ipc_socket, + headless, + } = self; + + clear_stale_downloaded_paths(&app).await?; + + let ipc_socket = if provide_ipc_socket { + Some(app.config.paths.mpv_ipc_socket_path.clone()) + } else { + None + }; + + let (mpv, mut ev_ctx) = + init_mpv(&app, ipc_socket, headless).context("Failed to initialize mpv instance")?; + let mpv = Arc::new(mpv); + + if provide_ipc_socket { + println!("{}", app.config.paths.mpv_ipc_socket_path.display()); + } + + let should_break = Arc::new(AtomicBool::new(false)); + let local_app = Arc::clone(&app); + let local_mpv = Arc::clone(&mpv); + let local_should_break = Arc::clone(&should_break); + let progress_handle = task::spawn(async move { + loop { + if local_should_break.load(Ordering::Relaxed) { + trace!("WatchProgressThread: Stopping, as we received exit signal."); + break; + } + + let mut playlist = Playlist::create(&local_app).await?; + + if let Some(index) = playlist.current_index() { + trace!("WatchProgressThread: Saving watch progress for current video"); + + let mut ops = + Operations::new("WatchProgressThread: save watch progress thread"); + playlist.save_watch_progress(&local_mpv, index, &mut ops); + ops.commit(&local_app).await?; + } else { + trace!( + "WatchProgressThread: Tried to save current watch progress, but no video active." + ); + } + + time::sleep(local_app.config.watch.progress_save_intervall).await; + } + + Ok::<(), anyhow::Error>(()) + }); + + let playlist = Playlist::create(&app).await?; + playlist.resync_with_mpv(&app, &mpv)?; + + let mut have_warned = (false, 0); + 'watchloop: loop { + 'waitloop: while let Ok(value) = playlist_handler::status(&app).await { + match value { + Status::NoMoreAvailable => { + break 'watchloop; + } + Status::NoCached { marked_watch } => { + // try again next time. + if have_warned.0 { + if have_warned.1 != marked_watch { + warn!("Now {marked_watch} videos are marked as to be watched."); + have_warned.1 = marked_watch; + } + } else { + warn!( + "There is nothing to watch yet, but still {marked_watch} videos marked as to be watched. \ + Will idle, until they become available" + ); + have_warned = (true, marked_watch); + } + wait_for_db_write(&app).await?; + + // Add the new videos, if they are there. + let playlist = Playlist::create(&app).await?; + playlist.resync_with_mpv(&app, &mpv)?; + } + Status::Available { newly_available } => { + debug!( + "Checked for currently available videos and found {newly_available}!" + ); + have_warned.0 = false; + + // Something just became available! + break 'waitloop; + } + } + } + + // TODO(@bpeetz): Is the following assumption correct? <2025-07-10> + // We wait until forever for the next event, because we really don't need to do anything + // else. + if let Some(ev) = ev_ctx.wait_event(f64::MAX) { + match ev { + Ok(event) => { + trace!("Mpv event triggered: {event:#?}"); + if playlist_handler::handle_mpv_event(&app, &mpv, &event) + .await + .with_context(|| format!("Failed to handle mpv event: '{event:#?}'"))? + { + break; + } + } + Err(e) => debug!("Mpv Event errored: {e}"), + } + } + } + should_break.store(true, Ordering::Relaxed); + progress_handle.await??; + + if provide_ipc_socket { + fs::remove_file(&app.config.paths.mpv_ipc_socket_path).with_context(|| { + format!( + "Failed to clean-up the mpv ipc socket at {}", + app.config.paths.mpv_ipc_socket_path.display() + ) + })?; + } + + Ok(()) + } +} + +fn init_mpv(app: &App, ipc_socket: Option<PathBuf>, headless: bool) -> Result<(Mpv, EventContext)> { + // set some default values, to make things easier (these can be overridden by the config file, + // which we load later) + let mpv = Mpv::with_initializer(|mpv| { + if let Some(socket) = ipc_socket { + mpv.set_property( + "input-ipc-server", + socket + .to_str() + .expect("This path comes from us, it should never contain not-utf8"), + )?; + } + + if headless { + // Do not provide video output. + mpv.set_property("vid", "no")?; + } else { + // Enable default key bindings, so the user can actually interact with + // the player (and e.g. close the window). + mpv.set_property("input-default-bindings", "yes")?; + mpv.set_property("input-vo-keyboard", "yes")?; + + // Show the on screen controller. + mpv.set_property("osc", "yes")?; + + // Don't automatically advance to the next video (or exit the player) + mpv.set_option("keep-open", "always")?; + + // Always display an window, even for non-video playback. + // As mpv does not have cli access, no window means no control and no user feedback. + mpv.set_option("force-window", "yes")?; + } + + Ok(()) + }) + .context("Failed to initialize mpv")?; + + let config_path = &app.config.paths.mpv_config_path; + if config_path.try_exists()? { + info!("Found mpv.conf at '{}'!", config_path.display()); + mpv.command( + "load-config-file", + &[config_path + .to_str() + .context("Failed to parse the config path is utf8-stringt")?], + )?; + } else { + warn!( + "Did not find a mpv.conf file at '{}'", + config_path.display() + ); + } + + let input_path = &app.config.paths.mpv_input_path; + if input_path.try_exists()? { + info!("Found mpv.input.conf at '{}'!", input_path.display()); + mpv.command( + "load-input-conf", + &[input_path + .to_str() + .context("Failed to parse the input path as utf8 string")?], + )?; + } else { + warn!( + "Did not find a mpv.input.conf file at '{}'", + input_path.display() + ); + } + + let ev_ctx = EventContext::new(mpv.ctx); + ev_ctx.disable_deprecated_events()?; + + Ok((mpv, ev_ctx)) +} diff --git a/crates/yt/src/watch/playlist_handler/client_messages/mod.rs b/crates/yt/src/commands/watch/implm/playlist_handler/client_messages.rs index c05ca87..fd7e035 100644 --- a/crates/yt/src/watch/playlist_handler/client_messages/mod.rs +++ b/crates/yt/src/commands/watch/implm/playlist_handler/client_messages.rs @@ -10,7 +10,7 @@ use std::{env, time::Duration}; -use crate::{app::App, comments}; +use crate::{app::App, storage::db::video::Video}; use anyhow::{Context, Result, bail}; use libmpv2::Mpv; @@ -23,19 +23,8 @@ async fn run_self_in_external_command(app: &App, args: &[&str]) -> Result<()> { let binary = env::current_exe().context("Failed to determine the current executable to re-execute")?; - let status = Command::new("riverctl") - .args(["focus-output", "next"]) - .status() - .await?; - if !status.success() { - bail!("focusing the next output failed!"); - } - let arguments = [ &[ - "--title", - "floating please", - "--command", binary .to_str() .context("Failed to turn the executable path to a utf8-string")?, @@ -50,29 +39,24 @@ async fn run_self_in_external_command(app: &App, args: &[&str]) -> Result<()> { ] .concat(); - let status = Command::new("alacritty").args(arguments).status().await?; - if !status.success() { - bail!("Falied to start `yt comments`"); - } - - let status = Command::new("riverctl") - .args(["focus-output", "next"]) + let status = Command::new(app.config.commands.external_spawn.first()) + .args(app.config.commands.external_spawn.tail()) + .args(arguments) .status() .await?; - if !status.success() { - bail!("focusing the next output failed!"); + bail!("Falied to start (external) `yt {}`", args.join(" ")); } Ok(()) } pub(super) async fn handle_yt_description_external(app: &App) -> Result<()> { - run_self_in_external_command(app, &["description"]).await?; + run_self_in_external_command(app, &["show", "description"]).await?; Ok(()) } pub(super) async fn handle_yt_description_local(app: &App, mpv: &Mpv) -> Result<()> { - let description: String = comments::description::get(app) + let description: String = Video::get_current_description(app) .await? .chars() .take(app.config.watch.local_displays_length) @@ -83,11 +67,11 @@ pub(super) async fn handle_yt_description_local(app: &App, mpv: &Mpv) -> Result< } pub(super) async fn handle_yt_comments_external(app: &App) -> Result<()> { - run_self_in_external_command(app, &["comments"]).await?; + run_self_in_external_command(app, &["show", "comments"]).await?; Ok(()) } pub(super) async fn handle_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()> { - let comments: String = comments::get(app) + let comments: String = Video::get_current_comments(app) .await? .render(false) .chars() @@ -97,3 +81,13 @@ pub(super) async fn handle_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()> mpv_message(mpv, &comments, Duration::from_secs(6))?; Ok(()) } + +pub(super) async fn handle_yt_info_external(app: &App) -> Result<()> { + run_self_in_external_command(app, &["show", "info"]).await?; + Ok(()) +} + +pub(super) async fn handle_yt_thumbnail_external(app: &App) -> Result<()> { + run_self_in_external_command(app, &["show", "thumbnail"]).await?; + Ok(()) +} diff --git a/crates/yt/src/commands/watch/implm/playlist_handler/mod.rs b/crates/yt/src/commands/watch/implm/playlist_handler/mod.rs new file mode 100644 index 0000000..bdb77d2 --- /dev/null +++ b/crates/yt/src/commands/watch/implm/playlist_handler/mod.rs @@ -0,0 +1,225 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::time::Duration; + +use crate::{ + app::App, + storage::db::{ + insert::{Operations, playlist::VideoTransition}, + playlist::{Playlist, PlaylistIndex}, + video::{Video, VideoStatusMarker}, + }, +}; + +use anyhow::{Context, Result}; +use libmpv2::{EndFileReason, Mpv, events::Event}; +use log::{debug, info}; + +mod client_messages; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum Status { + /// There are no videos cached and no more marked to be watched. + /// Waiting is pointless. + NoMoreAvailable, + + /// There are no videos cached, but some (> 0) are marked to be watched. + /// So we should wait for them to become available. + NoCached { marked_watch: usize }, + + /// There are videos cached and ready to be inserted into the playback queue. + Available { newly_available: usize }, +} + +fn mpv_message(mpv: &Mpv, message: &str, time: Duration) -> Result<()> { + mpv.command( + "show-text", + &[message, time.as_millis().to_string().as_str()], + )?; + Ok(()) +} + +/// Return the status of the playback queue +pub(crate) async fn status(app: &App) -> Result<Status> { + let playlist = Playlist::create(app).await?; + + let playlist_len = playlist.len(); + let marked_watch_num = Video::in_states(app, &[VideoStatusMarker::Watch]) + .await? + .len(); + + if playlist_len == 0 && marked_watch_num == 0 { + Ok(Status::NoMoreAvailable) + } else if playlist_len == 0 && marked_watch_num != 0 { + Ok(Status::NoCached { + marked_watch: marked_watch_num, + }) + } else if playlist_len != 0 { + Ok(Status::Available { + newly_available: playlist_len, + }) + } else { + unreachable!( + "The playlist length is {playlist_len}, but the number of marked watch videos is {marked_watch_num}! This is a bug." + ); + } +} + +/// # Returns +/// This will return [`true`], if the event handling should be stopped +/// +/// # Panics +/// Only if internal assertions fail. +#[allow(clippy::too_many_lines)] +pub(crate) async fn handle_mpv_event(app: &App, mpv: &Mpv, event: &Event<'_>) -> Result<bool> { + let mut ops = Operations::new("PlaylistHandler: handle event"); + + // Construct the playlist lazily. + // This avoids unneeded db lookups. + // (We use the moved `call_once` as guard for this) + let call_once = String::new(); + let playlist = move || { + drop(call_once); + Playlist::create(app) + }; + + let should_stop_event_handling = match event { + Event::EndFile(r) => match r.reason { + EndFileReason::Eof => { + info!("Mpv reached the end of the current video. Marking it watched."); + playlist().await?.resync_with_mpv(app, mpv)?; + + false + } + EndFileReason::Stop => { + // This reason is incredibly ambiguous. It _both_ means actually pausing a + // video and going to the next one in the playlist. + // Oh, and it's also called, when a video is removed from the playlist (at + // least via "playlist-remove current") + info!("Paused video (or went to next playlist entry); Doing nothing"); + + false + } + EndFileReason::Quit => { + info!("Mpv quit. Exiting playback"); + + playlist().await?.save_current_watch_progress(mpv, &mut ops); + + true + } + EndFileReason::Error => { + unreachable!("This should have been raised as a separate error") + } + EndFileReason::Redirect => { + // TODO: We probably need to handle this somehow <2025-02-17> + false + } + }, + Event::StartFile(_) => { + let mut playlist = playlist().await?; + + let mpv_pos = usize::try_from(mpv.get_property::<i64>("playlist-pos")?) + .expect("The value is strictly positive"); + + let yt_pos = playlist.current_index().map(usize::from); + + if (Some(mpv_pos) != yt_pos) || yt_pos.is_none() { + debug!( + "StartFileHandler: mpv pos {mpv_pos} and our pos {yt_pos:?} do not align. Reloading.." + ); + + if let Some((_, vid)) = playlist.get_focused_mut() { + vid.set_focused(false, &mut ops); + ops.commit(app) + .await + .context("Failed to commit video unfocusing")?; + + ops = Operations::new("PlaylistHandler: after set-focused"); + } + + let video = playlist + .get_mut(PlaylistIndex::from(mpv_pos)) + .expect("The mpv pos should not be out of bounds"); + + video.set_focused(true, &mut ops); + + playlist.resync_with_mpv(app, mpv)?; + } + + false + } + Event::Seek => { + playlist().await?.save_current_watch_progress(mpv, &mut ops); + + false + } + Event::ClientMessage(a) => { + debug!("Got Client Message event: '{}'", a.join(" ")); + + match a.as_slice() { + &["yt-comments-external"] => { + client_messages::handle_yt_comments_external(app).await?; + } + &["yt-comments-local"] => { + client_messages::handle_yt_comments_local(app, mpv).await?; + } + + &["yt-description-external"] => { + client_messages::handle_yt_description_external(app).await?; + } + &["yt-description-local"] => { + client_messages::handle_yt_description_local(app, mpv).await?; + } + + &["yt-info-external"] => { + client_messages::handle_yt_info_external(app).await?; + } + &["yt-thumbnail-external"] => { + client_messages::handle_yt_thumbnail_external(app).await?; + } + + &["yt-mark-picked"] => { + playlist().await?.mark_current_done( + app, + mpv, + VideoTransition::Picked, + &mut ops, + )?; + + mpv_message(mpv, "Marked the video as picked", Duration::from_secs(3))?; + } + &["yt-mark-watched"] => { + playlist().await?.mark_current_done( + app, + mpv, + VideoTransition::Watched, + &mut ops, + )?; + + mpv_message(mpv, "Marked the video watched", Duration::from_secs(3))?; + } + &["yt-check-new-videos"] => { + playlist().await?.resync_with_mpv(app, mpv)?; + } + other => { + debug!("Unknown message: {}", other.join(" ")); + } + } + + false + } + _ => false, + }; + + ops.commit(app).await?; + + Ok(should_stop_event_handling) +} diff --git a/crates/yt/src/commands/watch/mod.rs b/crates/yt/src/commands/watch/mod.rs new file mode 100644 index 0000000..ea4c513 --- /dev/null +++ b/crates/yt/src/commands/watch/mod.rs @@ -0,0 +1,24 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use clap::Parser; + +mod implm; + +#[derive(Parser, Debug)] +pub(super) struct WatchCommand { + /// Print the path to an ipc socket for mpv control to stdout at startup. + #[arg(long)] + provide_ipc_socket: bool, + + /// Don't start an mpv window at all. + #[arg(long)] + headless: bool, +} diff --git a/crates/yt/src/comments/comment.rs b/crates/yt/src/comments/comment.rs deleted file mode 100644 index 5bc939c..0000000 --- a/crates/yt/src/comments/comment.rs +++ /dev/null @@ -1,152 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use serde::{Deserialize, Deserializer, Serialize}; -use url::Url; - -#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, PartialOrd, Ord)] -#[serde(from = "String")] -#[serde(deny_unknown_fields)] -pub enum Parent { - Root, - Id(String), -} - -impl Parent { - #[must_use] - pub fn id(&self) -> Option<&str> { - if let Self::Id(id) = self { - Some(id) - } else { - None - } - } -} - -impl From<String> for Parent { - fn from(value: String) -> Self { - if value == "root" { - Self::Root - } else { - Self::Id(value) - } - } -} - -#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, PartialOrd, Ord)] -#[serde(from = "String")] -#[serde(deny_unknown_fields)] -pub struct Id { - pub id: String, -} -impl From<String> for Id { - fn from(value: String) -> Self { - Self { - // Take the last element if the string is split with dots, otherwise take the full id - id: value.split('.').last().unwrap_or(&value).to_owned(), - } - } -} - -#[derive(Debug, Deserialize, Serialize, Clone, Eq, PartialEq, PartialOrd, Ord)] -#[allow(clippy::struct_excessive_bools)] -pub struct Comment { - pub id: Id, - pub text: String, - #[serde(default = "zero")] - pub like_count: u32, - pub is_pinned: bool, - pub author_id: String, - #[serde(default = "unknown")] - pub author: String, - pub author_is_verified: bool, - pub author_thumbnail: Url, - pub parent: Parent, - #[serde(deserialize_with = "edited_from_time_text", alias = "_time_text")] - pub edited: bool, - // Can't also be deserialized, as it's already used in 'edited' - // _time_text: String, - pub timestamp: i64, - pub author_url: Option<Url>, - pub author_is_uploader: bool, - pub is_favorited: bool, -} - -fn unknown() -> String { - "<Unknown>".to_string() -} -fn zero() -> u32 { - 0 -} -fn edited_from_time_text<'de, D>(d: D) -> Result<bool, D::Error> -where - D: Deserializer<'de>, -{ - let s = String::deserialize(d)?; - if s.contains(" (edited)") { - Ok(true) - } else { - Ok(false) - } -} - -#[derive(Debug, Clone)] -#[allow(clippy::module_name_repetitions)] -pub struct CommentExt { - pub value: Comment, - pub replies: Vec<CommentExt>, -} - -#[derive(Debug, Default)] -pub struct Comments { - pub(super) vec: Vec<CommentExt>, -} - -impl Comments { - pub fn new() -> Self { - Self::default() - } - pub fn push(&mut self, value: CommentExt) { - self.vec.push(value); - } - pub fn get_mut(&mut self, key: &str) -> Option<&mut CommentExt> { - self.vec.iter_mut().filter(|c| c.value.id.id == key).last() - } - pub fn insert(&mut self, key: &str, value: CommentExt) { - let parent = self - .vec - .iter_mut() - .filter(|c| c.value.id.id == key) - .last() - .expect("One of these should exist"); - parent.push_reply(value); - } -} -impl CommentExt { - pub fn push_reply(&mut self, value: CommentExt) { - self.replies.push(value); - } - pub fn get_mut_reply(&mut self, key: &str) -> Option<&mut CommentExt> { - self.replies - .iter_mut() - .filter(|c| c.value.id.id == key) - .last() - } -} - -impl From<Comment> for CommentExt { - fn from(value: Comment) -> Self { - Self { - replies: vec![], - value, - } - } -} diff --git a/crates/yt/src/comments/description.rs b/crates/yt/src/comments/description.rs deleted file mode 100644 index e8cb29d..0000000 --- a/crates/yt/src/comments/description.rs +++ /dev/null @@ -1,46 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use crate::{ - App, - comments::output::display_fmt_and_less, - storage::video_database::{Video, get}, - unreachable::Unreachable, -}; - -use anyhow::{Result, bail}; -use yt_dlp::{InfoJson, json_cast}; - -pub async fn description(app: &App) -> Result<()> { - let description = get(app).await?; - display_fmt_and_less(description).await?; - - Ok(()) -} - -pub async fn get(app: &App) -> Result<String> { - let currently_playing_video: Video = - if let Some(video) = get::currently_focused_video(app).await? { - video - } else { - bail!("Could not find a currently playing video!"); - }; - - let info_json: InfoJson = get::video_info_json(¤tly_playing_video)?.unreachable( - "A currently *playing* must be cached. And thus the info.json should be available", - ); - - Ok(info_json - .get("description") - .map(|val| json_cast!(val, as_str)) - .unwrap_or("<No description>") - .to_owned()) -} diff --git a/crates/yt/src/comments/mod.rs b/crates/yt/src/comments/mod.rs deleted file mode 100644 index 876146d..0000000 --- a/crates/yt/src/comments/mod.rs +++ /dev/null @@ -1,167 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::mem; - -use anyhow::{Result, bail}; -use comment::{Comment, CommentExt, Comments, Parent}; -use output::display_fmt_and_less; -use regex::Regex; -use yt_dlp::{InfoJson, json_cast}; - -use crate::{ - app::App, - storage::video_database::{Video, get}, - unreachable::Unreachable, -}; - -mod comment; -mod display; -pub mod output; - -pub mod description; -pub use description::*; - -#[allow(clippy::too_many_lines)] -pub async fn get(app: &App) -> Result<Comments> { - let currently_playing_video: Video = - if let Some(video) = get::currently_focused_video(app).await? { - video - } else { - bail!("Could not find a currently playing video!"); - }; - - let info_json: InfoJson = get::video_info_json(¤tly_playing_video)?.unreachable( - "A currently *playing* video must be cached. And thus the info.json should be available", - ); - - let base_comments = if let Some(comments) = info_json.get("comments") { - json_cast!(comments, as_array) - } else { - bail!( - "The video ('{}') does not have comments!", - info_json - .get("title") - .map(|val| json_cast!(val, as_str)) - .unwrap_or("<No Title>") - ) - }; - - let mut comments = Comments::new(); - for c in base_comments { - let c: Comment = serde_json::from_value(c.to_owned())?; - if let Parent::Id(id) = &c.parent { - comments.insert(&(id.clone()), CommentExt::from(c)); - } else { - comments.push(CommentExt::from(c)); - } - } - - comments.vec.iter_mut().for_each(|comment| { - let replies = mem::take(&mut comment.replies); - let mut output_replies: Vec<CommentExt> = vec![]; - - let re = Regex::new(r"\u{200b}?(@[^\t\s]+)\u{200b}?").unreachable("This is hardcoded"); - for reply in replies { - if let Some(replyee_match) = re.captures(&reply.value.text){ - let full_match = replyee_match.get(0).unreachable("This will always exist"); - let text = reply. - value. - text[0..full_match.start()] - .to_owned() - + - &reply - .value - .text[full_match.end()..]; - let text: &str = text.trim().trim_matches('\u{200b}'); - - let replyee = replyee_match.get(1).unreachable("This should also exist").as_str(); - - - if let Some(parent) = output_replies - .iter_mut() - // .rev() - .flat_map(|com| &mut com.replies) - .flat_map(|com| &mut com.replies) - .flat_map(|com| &mut com.replies) - .filter(|com| com.value.author == replyee) - .last() - { - parent.replies.push(CommentExt::from(Comment { - text: text.to_owned(), - ..reply.value - })); - } else if let Some(parent) = output_replies - .iter_mut() - // .rev() - .flat_map(|com| &mut com.replies) - .flat_map(|com| &mut com.replies) - .filter(|com| com.value.author == replyee) - .last() - { - parent.replies.push(CommentExt::from(Comment { - text: text.to_owned(), - ..reply.value - })); - } else if let Some(parent) = output_replies - .iter_mut() - // .rev() - .flat_map(|com| &mut com.replies) - .filter(|com| com.value.author == replyee) - .last() - { - parent.replies.push(CommentExt::from(Comment { - text: text.to_owned(), - ..reply.value - })); - } else if let Some(parent) = output_replies.iter_mut() - // .rev() - .filter(|com| com.value.author == replyee) - .last() - { - parent.replies.push(CommentExt::from(Comment { - text: text.to_owned(), - ..reply.value - })); - } else { - eprintln!( - "Failed to find a parent for ('{}') both directly and via replies! The reply text was:\n'{}'\n", - replyee, - reply.value.text - ); - output_replies.push(reply); - } - } else { - output_replies.push(reply); - } - } - comment.replies = output_replies; - }); - - Ok(comments) -} - -pub async fn comments(app: &App) -> Result<()> { - let comments = get(app).await?; - - display_fmt_and_less(comments.render(true)).await?; - - Ok(()) -} - -#[cfg(test)] -mod test { - #[test] - fn test_string_replacement() { - let s = "A \n\nB\n\nC".to_owned(); - assert_eq!("A \n \n B\n \n C", s.replace('\n', "\n ")); - } -} diff --git a/crates/yt/src/config/default.rs b/crates/yt/src/config/default.rs deleted file mode 100644 index 4ed643b..0000000 --- a/crates/yt/src/config/default.rs +++ /dev/null @@ -1,110 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::path::PathBuf; - -use anyhow::{Context, Result}; - -fn get_runtime_path(name: &'static str) -> Result<PathBuf> { - let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); - xdg_dirs - .place_runtime_file(name) - .with_context(|| format!("Failed to place runtime file: '{name}'")) -} -fn get_data_path(name: &'static str) -> Result<PathBuf> { - let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); - xdg_dirs - .place_data_file(name) - .with_context(|| format!("Failed to place data file: '{name}'")) -} -fn get_config_path(name: &'static str) -> Result<PathBuf> { - let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); - xdg_dirs - .place_config_file(name) - .with_context(|| format!("Failed to place config file: '{name}'")) -} - -pub(super) fn create_path(path: PathBuf) -> Result<PathBuf> { - if !path.exists() { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("Failed to create the '{}' directory", path.display()))?; - } - } - - Ok(path) -} - -pub(crate) const PREFIX: &str = "yt"; - -pub(crate) mod global { - pub(crate) fn display_colors() -> bool { - // TODO: This should probably check if the output is a tty and otherwise return `false` <2025-02-14> - true - } -} - -pub(crate) mod select { - pub(crate) fn playback_speed() -> f64 { - 2.7 - } - pub(crate) fn subtitle_langs() -> &'static str { - "" - } -} - -pub(crate) mod watch { - pub(crate) fn local_displays_length() -> usize { - 1000 - } -} - -pub(crate) mod update { - pub(crate) fn max_backlog() -> usize { - 20 - } -} - -pub(crate) mod paths { - use std::{env::temp_dir, path::PathBuf}; - - use anyhow::Result; - - use super::{PREFIX, create_path, get_config_path, get_data_path, get_runtime_path}; - - // We download to the temp dir to avoid taxing the disk - pub(crate) fn download_dir() -> Result<PathBuf> { - let temp_dir = temp_dir(); - - create_path(temp_dir.join(PREFIX)) - } - pub(crate) fn mpv_config_path() -> Result<PathBuf> { - get_config_path("mpv.conf") - } - pub(crate) fn mpv_input_path() -> Result<PathBuf> { - get_config_path("mpv.input.conf") - } - pub(crate) fn database_path() -> Result<PathBuf> { - get_data_path("videos.sqlite") - } - pub(crate) fn config_path() -> Result<PathBuf> { - get_config_path("config.toml") - } - pub(crate) fn last_selection_path() -> Result<PathBuf> { - get_runtime_path("selected.yts") - } -} - -pub(crate) mod download { - pub(crate) fn max_cache_size() -> &'static str { - "3 GiB" - } -} diff --git a/crates/yt/src/config/definitions.rs b/crates/yt/src/config/definitions.rs deleted file mode 100644 index ce8c0d4..0000000 --- a/crates/yt/src/config/definitions.rs +++ /dev/null @@ -1,67 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::path::PathBuf; - -use serde::Deserialize; - -#[derive(Debug, Deserialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub(crate) struct ConfigFile { - pub global: Option<GlobalConfig>, - pub select: Option<SelectConfig>, - pub watch: Option<WatchConfig>, - pub paths: Option<PathsConfig>, - pub download: Option<DownloadConfig>, - pub update: Option<UpdateConfig>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone, Copy)] -#[serde(deny_unknown_fields)] -pub(crate) struct GlobalConfig { - pub display_colors: Option<bool>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone, Copy)] -#[serde(deny_unknown_fields)] -pub(crate) struct UpdateConfig { - pub max_backlog: Option<usize>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone)] -#[serde(deny_unknown_fields)] -pub(crate) struct DownloadConfig { - /// This will then be converted to an u64 - pub max_cache_size: Option<String>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone)] -#[serde(deny_unknown_fields)] -pub(crate) struct SelectConfig { - pub playback_speed: Option<f64>, - pub subtitle_langs: Option<String>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone, Copy)] -#[serde(deny_unknown_fields)] -pub(crate) struct WatchConfig { - pub local_displays_length: Option<usize>, -} - -#[derive(Debug, Deserialize, PartialEq, Clone)] -#[serde(deny_unknown_fields)] -pub(crate) struct PathsConfig { - pub download_dir: Option<PathBuf>, - pub mpv_config_path: Option<PathBuf>, - pub mpv_input_path: Option<PathBuf>, - pub database_path: Option<PathBuf>, - pub last_selection_path: Option<PathBuf>, -} diff --git a/crates/yt/src/config/file_system.rs b/crates/yt/src/config/file_system.rs deleted file mode 100644 index 2463e9d..0000000 --- a/crates/yt/src/config/file_system.rs +++ /dev/null @@ -1,120 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use crate::config::{DownloadConfig, PathsConfig, SelectConfig, WatchConfig}; - -use super::{ - Config, GlobalConfig, UpdateConfig, - default::{create_path, download, global, paths, select, update, watch}, -}; - -use std::{fs::read_to_string, path::PathBuf}; - -use anyhow::{Context, Result}; -use bytes::Bytes; - -macro_rules! get { - ($default:path, $config:expr, $key_one:ident, $($keys:ident),*) => { - { - let maybe_value = get!{@option $config, $key_one, $($keys),*}; - if let Some(value) = maybe_value { - value - } else { - $default().to_owned() - } - } - }; - - (@option $config:expr, $key_one:ident, $($keys:ident),*) => { - if let Some(key) = $config.$key_one.clone() { - get!{@option key, $($keys),*} - } else { - None - } - }; - (@option $config:expr, $key_one:ident) => { - $config.$key_one - }; - - (@path_if_none $config:expr, $option_default:expr, $default:path, $key_one:ident, $($keys:ident),*) => { - { - let maybe_download_dir: Option<PathBuf> = - get! {@option $config, $key_one, $($keys),*}; - - let down_dir = if let Some(dir) = maybe_download_dir { - PathBuf::from(dir) - } else { - if let Some(path) = $option_default { - path - } else { - $default() - .with_context(|| format!("Failed to get default path for: '{}.{}'", stringify!($key_one), stringify!($($keys),*)))? - } - }; - create_path(down_dir)? - } - }; - (@path $config:expr, $default:path, $key_one:ident, $($keys:ident),*) => { - get! {@path_if_none $config, None, $default, $key_one, $($keys),*} - }; -} - -impl Config { - pub fn from_config_file( - db_path: Option<PathBuf>, - config_path: Option<PathBuf>, - display_colors: Option<bool>, - ) -> Result<Self> { - let config_file_path = - config_path.map_or_else(|| -> Result<_> { paths::config_path() }, Ok)?; - - let config: super::definitions::ConfigFile = - toml::from_str(&read_to_string(config_file_path).unwrap_or(String::new())) - .context("Failed to parse the config file as toml")?; - - Ok(Self { - global: GlobalConfig { - display_colors: { - let config_value: Option<bool> = get! {@option config, global, display_colors}; - - display_colors.unwrap_or(config_value.unwrap_or_else(global::display_colors)) - }, - }, - select: SelectConfig { - playback_speed: get! {select::playback_speed, config, select, playback_speed}, - subtitle_langs: get! {select::subtitle_langs, config, select, subtitle_langs}, - }, - watch: WatchConfig { - local_displays_length: get! {watch::local_displays_length, config, watch, local_displays_length}, - }, - update: UpdateConfig { - max_backlog: get! {update::max_backlog, config, update, max_backlog}, - }, - paths: PathsConfig { - download_dir: get! {@path config, paths::download_dir, paths, download_dir}, - mpv_config_path: get! {@path config, paths::mpv_config_path, paths, mpv_config_path}, - mpv_input_path: get! {@path config, paths::mpv_input_path, paths, mpv_input_path}, - database_path: get! {@path_if_none config, db_path, paths::database_path, paths, database_path}, - last_selection_path: get! {@path config, paths::last_selection_path, paths, last_selection_path}, - }, - download: DownloadConfig { - max_cache_size: { - let bytes_str: String = - get! {download::max_cache_size, config, download, max_cache_size}; - let number: Bytes = bytes_str - .parse() - .context("Failed to parse max_cache_size")?; - number - }, - }, - }) - } -} diff --git a/crates/yt/src/config/mod.rs b/crates/yt/src/config/mod.rs index a10f7c2..05bb4cf 100644 --- a/crates/yt/src/config/mod.rs +++ b/crates/yt/src/config/mod.rs @@ -1,6 +1,5 @@ // yt - A fully featured command line YouTube client // -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> // Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> // SPDX-License-Identifier: GPL-3.0-or-later // @@ -9,68 +8,131 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -#![allow(clippy::module_name_repetitions)] +use std::sync::atomic::{AtomicBool, Ordering}; -use std::path::PathBuf; +use crate::config::support::mk_config; -use bytes::Bytes; -use serde::Serialize; +mod non_empty_vec; +mod paths; +mod support; -mod default; -mod definitions; -pub mod file_system; +pub(crate) static SHOULD_DISPLAY_COLOR: AtomicBool = AtomicBool::new(false); -#[derive(Serialize, Debug)] -pub struct Config { - pub global: GlobalConfig, - pub select: SelectConfig, - pub watch: WatchConfig, - pub paths: PathsConfig, - pub download: DownloadConfig, - pub update: UpdateConfig, -} -// These structures could get non-copy fields in the future. +// We need to do both things to comply with what the config expects. +#[allow(clippy::trivially_copy_pass_by_ref, clippy::unnecessary_wraps)] +fn set_static_should_display_color(value: &bool) -> anyhow::Result<()> { + SHOULD_DISPLAY_COLOR.store(*value, Ordering::Relaxed); -#[derive(Serialize, Debug)] -#[allow(missing_copy_implementations)] -pub struct GlobalConfig { - pub display_colors: bool, -} -#[derive(Serialize, Debug)] -#[allow(missing_copy_implementations)] -pub struct UpdateConfig { - pub max_backlog: usize, -} -#[derive(Serialize, Debug)] -#[allow(missing_copy_implementations)] -pub struct DownloadConfig { - pub max_cache_size: Bytes, -} -#[derive(Serialize, Debug)] -pub struct SelectConfig { - pub playback_speed: f64, - pub subtitle_langs: String, -} -#[derive(Serialize, Debug)] -#[allow(missing_copy_implementations)] -pub struct WatchConfig { - pub local_displays_length: usize, -} -#[derive(Serialize, Debug)] -pub struct PathsConfig { - pub download_dir: PathBuf, - pub mpv_config_path: PathBuf, - pub mpv_input_path: PathBuf, - pub database_path: PathBuf, - pub last_selection_path: PathBuf, + Ok(()) } -// pub fn status_path() -> anyhow::Result<PathBuf> { -// const STATUS_PATH: &str = "running.info.json"; -// get_runtime_path(STATUS_PATH) -// } +mk_config! { + use std::path::PathBuf; + use std::io::IsTerminal; + use std::time::Duration; + + use crate::shared::bytes::Bytes; + + use super::set_static_should_display_color; + + use super::paths::get_config_path; + use super::paths::get_runtime_path; + use super::paths::get_data_path; + use super::paths::ensure_parent_dir; + use super::paths::ensure_dir; + use super::paths::PREFIX; + + use super::non_empty_vec::NonEmptyVec; + use super::non_empty_vec::non_empty_vec; + + struct Config { + global: GlobalConfig = { + /// Whether to display colors. + display_colors: bool where display_color: Option<bool> =! {|config_value: Option<bool>| + Ok::<_, anyhow::Error>( + display_color + .unwrap_or( + config_value + .unwrap_or_else(|| std::io::stderr().is_terminal()) + ) + ) + } => set_static_should_display_color, + }, + select: SelectConfig = { + /// The playback speed to use, when it is not overridden. + playback_speed: f64 =: 2.7, + + /// The subtitle langs to download, when it is not overridden. + subtitle_langs: String =: String::new(), + }, + watch: WatchConfig = { + /// How many chars to display at most, when displaying information on mpv's local on screen + /// display. + local_displays_length: usize =: 1000, -// pub fn subscriptions() -> anyhow::Result<PathBuf> { -// const SUBSCRIPTIONS: &str = "subscriptions.json"; -// get_data_path(SUBSCRIPTIONS) -// } + /// How long to wait between saving the video watch progress. + progress_save_intervall: Duration =: Duration::from_secs(10), + }, + commands: CommandsConfig = { + /// Which command to execute, when showing the thumbnail. + /// + /// This command will be executed with the one argument, being the path to the image file to display. + image_show: NonEmptyVec<String> =: non_empty_vec!["imv".to_owned()], + + /// Which command to use, when spawing one of the external commands (e.g. + /// `yt-comments-external` from mpv). + /// + /// The command will be called with a series of args that should be executed. + /// For example, + /// `<your_specified_command> <path_to_yt_binary> --db-path <path_to_current_db_path> comments` + external_spawn: NonEmptyVec<String> =: non_empty_vec!["alacritty".to_owned(), "-e".to_owned()], + + /// Which command to use, when opening video urls (like in the `yt select url` case). + /// + /// This command will be called with one argument, being the url of the video to open. + url_opener: NonEmptyVec<String> =: non_empty_vec!["firefox".to_owned()], + }, + paths: PathsConfig = { + /// Where to store downloaded files. + download_dir: PathBuf =: { + // We download to the temp dir to avoid taxing the disk + let temp_dir = std::env::temp_dir(); + + temp_dir.join(PREFIX) + } => ensure_dir, + + /// Path to the mpv configuration file. + mpv_config_path: PathBuf =? get_config_path("mpv.conf") => ensure_parent_dir, + + /// Path to the mpv input configuration file. + mpv_input_path: PathBuf =? get_config_path("mpv.input.conf") => ensure_parent_dir, + + /// Which path to use for mpv ipc socket creation. + mpv_ipc_socket_path: PathBuf =? get_runtime_path("mpv.ipc.socket") => ensure_parent_dir, + + /// Path to the video database. + database_path: PathBuf where db_path: Option<PathBuf> =! {|config_value: Option<PathBuf>| { + db_path.map_or_else(|| config_value.map_or_else(|| get_data_path("videos.sqlite"), Ok), Ok) + }} => ensure_parent_dir, + + /// Where to store the selection file before applying it. + last_selection_path: PathBuf =? get_runtime_path("selected.yts") => ensure_parent_dir, + }, + download: DownloadConfig = { + /// The maximum cache size. + max_cache_size: Bytes =? "3 GiB".parse(), + }, + update: UpdateConfig = { + /// How many videos to download, when checking for new ones. + max_backlog: usize =: 20, + + /// How many threads to use in the thread pool for fetching new videos. + pool_size: usize =: 16, + + /// How many subscriptions to fetch at once. + /// + /// For example, 16 means, that we will fetch 16 subscriptions at the same time. + futures: usize =: 16 * 4, + }, + } +} diff --git a/crates/yt/src/config/non_empty_vec.rs b/crates/yt/src/config/non_empty_vec.rs new file mode 100644 index 0000000..bd2c5e3 --- /dev/null +++ b/crates/yt/src/config/non_empty_vec.rs @@ -0,0 +1,83 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + collections::VecDeque, + fmt::{Display, Write}, +}; + +use anyhow::bail; +use serde::{Deserialize, Serialize}; + +macro_rules! non_empty_vec { + ($first:expr $(, $($others:expr),+ $(,)?)?) => {{ + let inner: Vec<_> = vec![$first $(, $($others,)+)?]; + inner.try_into().expect("Has a first arg") + }} +} +pub(crate) use non_empty_vec; + +/// A vector that is non-empty. +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(try_from = "Vec<T>")] +#[serde(into = "Vec<T>")] +pub(crate) struct NonEmptyVec<T: Clone> { + first: T, + rest: Vec<T>, +} + +impl<T: Clone> TryFrom<Vec<T>> for NonEmptyVec<T> { + type Error = anyhow::Error; + + fn try_from(value: Vec<T>) -> Result<Self, Self::Error> { + let mut queue = VecDeque::from(value); + + if let Some(first) = queue.pop_front() { + Ok(Self { + first, + rest: queue.into(), + }) + } else { + bail!("You need to have at least one element in a non-empty vector.") + } + } +} + +impl<T: Clone> From<NonEmptyVec<T>> for Vec<T> { + fn from(value: NonEmptyVec<T>) -> Self { + let mut base = vec![value.first]; + base.extend(value.rest); + base + } +} + +impl<T: Clone> NonEmptyVec<T> { + pub(crate) fn first(&self) -> &T { + &self.first + } + + pub(crate) fn tail(&self) -> &[T] { + self.rest.as_ref() + } + + pub(crate) fn join(&self, sep: &str) -> String + where + T: Display, + { + let mut output = String::new(); + write!(output, "{}", self.first()).expect("In-memory, does not fail"); + + for elem in &self.rest { + write!(output, "{sep}{elem}").expect("In-memory, does not fail"); + } + + output + } +} diff --git a/crates/yt/src/config/paths.rs b/crates/yt/src/config/paths.rs new file mode 100644 index 0000000..66975dd --- /dev/null +++ b/crates/yt/src/config/paths.rs @@ -0,0 +1,58 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +pub(super) fn get_runtime_path(name: &'static str) -> Result<PathBuf> { + let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); + xdg_dirs + .place_runtime_file(name) + .with_context(|| format!("Failed to place runtime file: '{name}'")) +} +pub(super) fn get_data_path(name: &'static str) -> Result<PathBuf> { + let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); + xdg_dirs + .place_data_file(name) + .with_context(|| format!("Failed to place data file: '{name}'")) +} +pub(super) fn get_config_path(name: &'static str) -> Result<PathBuf> { + let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); + xdg_dirs + .place_config_file(name) + .with_context(|| format!("Failed to place config file: '{name}'")) +} + +pub(super) fn ensure_parent_dir(path: &Path) -> Result<()> { + if !path.exists() { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create the '{}' directory", path.display()))?; + } + } + + Ok(()) +} +pub(super) fn ensure_dir(path: &Path) -> Result<()> { + if !path.exists() { + std::fs::create_dir_all(path) + .with_context(|| format!("Failed to create the '{}' directory", path.display()))?; + } + + Ok(()) +} + +pub(super) fn config_path() -> Result<PathBuf> { + get_config_path("config.toml") +} + +pub(crate) const PREFIX: &str = "yt"; diff --git a/crates/yt/src/config/support.rs b/crates/yt/src/config/support.rs new file mode 100644 index 0000000..96e7ba4 --- /dev/null +++ b/crates/yt/src/config/support.rs @@ -0,0 +1,161 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +macro_rules! mk_config { + ( + $(use $usage_path:path;)* + + struct $name:ident { + $( + $(#[$attr0:meta])* + $subconfig_name:ident : $subconfig_type:ident = { + $( + $(#[$attr1:meta])* + $field_name:ident : $field_type:ty $( + where $extra_input:ident: $extra_input_type:ty + ),* = $errors:tt $default:expr $(=> $finalizer:ident)? + ),* + $(,)? + } + ),* + $(,)? + } + ) => { + mod _inner { + #![allow(non_snake_case)] + + $(use $usage_path;)* + + #[derive(serde::Serialize, Debug)] + pub(crate) struct $name { + $( + $(#[$attr0])* + pub(crate) $subconfig_name: $subconfig_type + ),* + } + + #[derive(Debug, serde::Deserialize, PartialEq)] + #[serde(deny_unknown_fields)] + #[allow(non_camel_case_types)] + struct config { + $( + $subconfig_name: Option<$subconfig_name> + ),* + } + + impl $name { + pub(crate) fn from_config_file( + config_file_path: Option<std::path::PathBuf>, + $( + $( + $( + $extra_input: $extra_input_type, + )* + )* + )* + ) -> anyhow::Result<Self> { + use anyhow::Context; + + let config_file_path = + config_file_path.map_or_else(|| -> anyhow::Result<_> { super::paths::config_path() }, Ok)?; + + let config: config = + toml::from_str(&std::fs::read_to_string(config_file_path).unwrap_or(String::new())) + .context("Failed to parse the config file as toml")?; + + Ok(Self { + $( + $subconfig_name: { + let toplevel = config.$subconfig_name.unwrap_or_default(); + $subconfig_type { + $( + $field_name: $field_name(toplevel.$field_name, $($extra_input),*)? + ),* + } + } + ),* + }) + } + + pub(crate) fn run_finalizers(&self) -> anyhow::Result<()> { + #[allow(unused_imports)] + use anyhow::Context; + + $( + $( + $( + $finalizer(&self.$subconfig_name.$field_name) + .context( + concat!( + "While running the finalizer for config value '", + stringify!($subconfig_name), + ".", + stringify!($field_name), + "'" + ) + )?; + )? + )* + )* + + Ok(()) + } + } + + $( + #[derive(serde::Serialize, Debug)] + pub(crate) struct $subconfig_type { + $( + $(#[$attr1])* + pub(crate) $field_name: $field_type + ),* + } + + #[derive(Debug, Default, serde::Deserialize, PartialEq)] + #[serde(deny_unknown_fields)] + #[allow(non_camel_case_types)] + struct $subconfig_name { + $( + $field_name: Option<$field_type> + ),* + } + + $( + fn $field_name( + config_value: Option<$field_type>, + $($extra_input: $extra_input_type),* + ) -> anyhow::Result<$field_type> { + use anyhow::Context; + + let expr = $crate::config::support::maybe_wrap_type!($field_type =$errors $default)(config_value); + + expr.context(concat!("Failed to generate default config value for '", stringify!($field_name),"'")) + } + )* + )* + } + pub(crate) use self::_inner::*; + }; +} + +macro_rules! maybe_wrap_type { + ($ty:ty =! $val:expr) => { + (|config_value: Option<$ty>| $val(config_value)) + }; + ($ty:ty =? $val:expr) => { + (|config_value: Option<$ty>| config_value.map_or_else(|| $val, Ok)) + }; + ($ty:ty =: $val:expr) => { + (|config_value: Option<$ty>| Ok::<_, anyhow::Error>(config_value.unwrap_or_else(|| $val))) + }; +} + +pub(crate) use maybe_wrap_type; +pub(crate) use mk_config; diff --git a/crates/yt/src/download/mod.rs b/crates/yt/src/download/mod.rs deleted file mode 100644 index 110bf55..0000000 --- a/crates/yt/src/download/mod.rs +++ /dev/null @@ -1,366 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{collections::HashMap, io, str::FromStr, sync::Arc, time::Duration}; - -use crate::{ - app::App, - download::download_options::download_opts, - storage::video_database::{ - Video, YtDlpOptions, - downloader::{get_next_uncached_video, set_video_cache_path}, - extractor_hash::ExtractorHash, - get::get_video_yt_dlp_opts, - notify::wait_for_cache_reduction, - }, - unreachable::Unreachable, -}; - -use anyhow::{Context, Result, bail}; -use bytes::Bytes; -use futures::{FutureExt, future::BoxFuture}; -use log::{debug, error, info, warn}; -use tokio::{fs, task::JoinHandle, time}; -use yt_dlp::{json_cast, json_get}; - -#[allow(clippy::module_name_repetitions)] -pub mod download_options; -pub mod progress_hook; - -#[derive(Debug)] -#[allow(clippy::module_name_repetitions)] -pub struct CurrentDownload { - task_handle: JoinHandle<Result<()>>, - extractor_hash: ExtractorHash, -} - -impl CurrentDownload { - fn new_from_video(app: Arc<App>, video: Video) -> Self { - let extractor_hash = video.extractor_hash; - - let task_handle = tokio::spawn(async move { - Downloader::actually_cache_video(&app, &video) - .await - .with_context(|| format!("Failed to cache video: '{}'", video.title))?; - Ok(()) - }); - - Self { - task_handle, - extractor_hash, - } - } -} - -enum CacheSizeCheck { - /// The video can be downloaded - Fits, - - /// The video and the current cache size together would exceed the size - TooLarge, - - /// The video would not even fit into the empty cache - ExceedsMaxCacheSize, -} - -#[derive(Debug)] -pub struct Downloader { - current_download: Option<CurrentDownload>, - video_size_cache: HashMap<ExtractorHash, u64>, - printed_warning: bool, - cached_cache_allocation: Option<Bytes>, -} - -impl Default for Downloader { - fn default() -> Self { - Self::new() - } -} - -impl Downloader { - #[must_use] - pub fn new() -> Self { - Self { - current_download: None, - video_size_cache: HashMap::new(), - printed_warning: false, - cached_cache_allocation: None, - } - } - - /// Check if enough cache is available. Will wait for 10s if it's not. - async fn is_enough_cache_available( - &mut self, - app: &App, - max_cache_size: u64, - next_video: &Video, - ) -> Result<CacheSizeCheck> { - if let Some(cdownload) = &self.current_download { - if cdownload.extractor_hash == next_video.extractor_hash { - // If the video is already being downloaded it will always fit. Otherwise the - // download would not have been started. - return Ok(CacheSizeCheck::Fits); - } - } - let cache_allocation = Self::get_current_cache_allocation(app).await?; - let video_size = self.get_approx_video_size(app, next_video)?; - - if video_size >= max_cache_size { - error!( - "The video '{}' ({}) exceeds the maximum cache size ({})! \ - Please set a bigger maximum (`--max-cache-size`) or skip it.", - next_video.title, - Bytes::new(video_size), - Bytes::new(max_cache_size) - ); - - return Ok(CacheSizeCheck::ExceedsMaxCacheSize); - } - - if cache_allocation.as_u64() + video_size >= max_cache_size { - if !self.printed_warning { - warn!( - "Can't download video: '{}' ({}) as it's too large for the cache ({} of {} allocated). \ - Waiting for cache size reduction..", - next_video.title, - Bytes::new(video_size), - &cache_allocation, - Bytes::new(max_cache_size) - ); - self.printed_warning = true; - - // Update this value immediately. - // This avoids printing the "Current cache size has changed .." warning below. - self.cached_cache_allocation = Some(cache_allocation); - } - - if let Some(cca) = self.cached_cache_allocation { - if cca != cache_allocation { - // Only print the warning if the display string has actually changed. - // Otherwise, we might confuse the user - if cca.to_string() != cache_allocation.to_string() { - warn!( - "Current cache size has changed, it's now: '{}'", - cache_allocation - ); - } - debug!( - "Cache size has changed: {} -> {}", - cca.as_u64(), - cache_allocation.as_u64() - ); - self.cached_cache_allocation = Some(cache_allocation); - } - } else { - unreachable!( - "The `printed_warning` should be false in this case, \ - and thus should have already set the `cached_cache_allocation`." - ); - } - - // Wait and hope, that a large video is deleted from the cache. - wait_for_cache_reduction(app).await?; - Ok(CacheSizeCheck::TooLarge) - } else { - self.printed_warning = false; - Ok(CacheSizeCheck::Fits) - } - } - - /// The entry point to the Downloader. - /// This Downloader will periodically check if the database has changed, and then also - /// change which videos it downloads. - /// This will run, until the database doesn't contain any watchable videos - pub async fn consume(&mut self, app: Arc<App>, max_cache_size: u64) -> Result<()> { - while let Some(next_video) = get_next_uncached_video(&app).await? { - match self - .is_enough_cache_available(&app, max_cache_size, &next_video) - .await? - { - CacheSizeCheck::Fits => (), - CacheSizeCheck::TooLarge => continue, - CacheSizeCheck::ExceedsMaxCacheSize => bail!("Giving up."), - }; - - if self.current_download.is_some() { - let current_download = self.current_download.take().unreachable("It is `Some`."); - - if current_download.task_handle.is_finished() { - current_download.task_handle.await??; - continue; - } - - if next_video.extractor_hash == current_download.extractor_hash { - // Reset the taken value - self.current_download = Some(current_download); - } else { - info!( - "Noticed, that the next video is not the video being downloaded, replacing it ('{}' vs. '{}')!", - next_video.extractor_hash.into_short_hash(&app).await?, - current_download - .extractor_hash - .into_short_hash(&app) - .await? - ); - - // Replace the currently downloading video - // FIXME(@bpeetz): This does not work (probably because of the python part.) <2025-02-21> - current_download.task_handle.abort(); - - let new_current_download = - CurrentDownload::new_from_video(Arc::clone(&app), next_video); - - self.current_download = Some(new_current_download); - } - } else { - info!( - "No video is being downloaded right now, setting it to '{}'", - next_video.title - ); - let new_current_download = - CurrentDownload::new_from_video(Arc::clone(&app), next_video); - self.current_download = Some(new_current_download); - } - - // TODO(@bpeetz): Why do we sleep here? <2025-02-21> - time::sleep(Duration::from_secs(1)).await; - } - - info!("Finished downloading!"); - Ok(()) - } - - pub async fn get_current_cache_allocation(app: &App) -> Result<Bytes> { - fn dir_size(mut dir: fs::ReadDir) -> BoxFuture<'static, Result<Bytes>> { - async move { - let mut acc = 0; - while let Some(entry) = dir.next_entry().await? { - let size = match entry.metadata().await? { - data if data.is_dir() => { - let path = entry.path(); - let read_dir = fs::read_dir(path).await?; - - dir_size(read_dir).await?.as_u64() - } - data => data.len(), - }; - acc += size; - } - Ok(Bytes::new(acc)) - } - .boxed() - } - - let read_dir_result = match fs::read_dir(&app.config.paths.download_dir).await { - Ok(ok) => ok, - Err(err) => match err.kind() { - io::ErrorKind::NotFound => { - fs::create_dir_all(&app.config.paths.download_dir) - .await - .with_context(|| { - format!( - "Failed to create download dir at: '{}'", - &app.config.paths.download_dir.display() - ) - })?; - - info!( - "Created empty download dir at '{}'", - &app.config.paths.download_dir.display(), - ); - - // The new dir should not contain anything (otherwise we would not have had to - // create it) - return Ok(Bytes::new(0)); - } - err => Err(io::Error::from(err)).with_context(|| { - format!( - "Failed to get dir size of download dir at: '{}'", - &app.config.paths.download_dir.display() - ) - })?, - }, - }; - - dir_size(read_dir_result).await - } - - fn get_approx_video_size(&mut self, app: &App, video: &Video) -> Result<u64> { - if let Some(value) = self.video_size_cache.get(&video.extractor_hash) { - Ok(*value) - } else { - // the subtitle file size should be negligible - let add_opts = YtDlpOptions { - subtitle_langs: String::new(), - }; - let yt_dlp = download_opts(app, &add_opts)?; - - let result = yt_dlp - .extract_info(&video.url, false, true) - .with_context(|| { - format!("Failed to extract video information: '{}'", video.title) - })?; - - let size = if let Some(val) = result.get("filesize") { - json_cast!(val, as_u64) - } else if let Some(val) = result.get("filesize_approx") { - json_cast!(val, as_u64) - } else if result.get("duration").is_some() && result.get("tbr").is_some() { - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - let duration = json_get!(result, "duration", as_f64).ceil() as u64; - - // TODO: yt_dlp gets this from the format - #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - let tbr = json_get!(result, "tbr", as_f64).ceil() as u64; - - duration * tbr * (1000 / 8) - } else { - let hardcoded_default = Bytes::from_str("250 MiB").expect("This is hardcoded"); - error!( - "Failed to find a filesize for video: '{}' (Using hardcoded value of {})", - video.title, hardcoded_default - ); - hardcoded_default.as_u64() - }; - - assert_eq!( - self.video_size_cache.insert(video.extractor_hash, size), - None - ); - - Ok(size) - } - } - - async fn actually_cache_video(app: &App, video: &Video) -> Result<()> { - debug!("Download started: {}", &video.title); - - let addional_opts = get_video_yt_dlp_opts(app, &video.extractor_hash).await?; - let yt_dlp = download_opts(app, &addional_opts)?; - - let result = yt_dlp - .download(&[video.url.to_owned()]) - .with_context(|| format!("Failed to download video: '{}'", video.title))?; - - assert_eq!(result.len(), 1); - let result = &result[0]; - - set_video_cache_path(app, &video.extractor_hash, Some(result)).await?; - - info!( - "Video '{}' was downlaoded to path: {}", - video.title, - result.display() - ); - - Ok(()) - } -} diff --git a/crates/yt/src/download/progress_hook.rs b/crates/yt/src/download/progress_hook.rs deleted file mode 100644 index b75ec00..0000000 --- a/crates/yt/src/download/progress_hook.rs +++ /dev/null @@ -1,188 +0,0 @@ -use std::{ - io::{Write, stderr}, - process, -}; - -use bytes::Bytes; -use log::{Level, log_enabled}; -use yt_dlp::mk_python_function; - -use crate::{ - ansi_escape_codes::{clear_whole_line, move_to_col}, - select::selection_file::duration::MaybeDuration, -}; - -/// # Panics -/// If expectations fail. -#[allow(clippy::too_many_lines, clippy::needless_pass_by_value)] -pub fn progress_hook( - input: serde_json::Map<String, serde_json::Value>, -) -> Result<(), std::io::Error> { - // Only add the handler, if the log-level is higher than Debug (this avoids covering debug - // messages). - if log_enabled!(Level::Debug) { - return Ok(()); - } - - macro_rules! get { - (@interrogate $item:ident, $type_fun:ident, $get_fun:ident, $name:expr) => {{ - let a = $item.get($name).expect(concat!( - "The field '", - stringify!($name), - "' should exist." - )); - - if a.$type_fun() { - a.$get_fun().expect( - "The should have been checked in the if guard, so unpacking here is fine", - ) - } else { - panic!( - "Value {} => \n{}\n is not of type: {}", - $name, - a, - stringify!($type_fun) - ); - } - }}; - - ($type_fun:ident, $get_fun:ident, $name1:expr, $name2:expr) => {{ - let a = get! {@interrogate input, is_object, as_object, $name1}; - let b = get! {@interrogate a, $type_fun, $get_fun, $name2}; - b - }}; - - ($type_fun:ident, $get_fun:ident, $name:expr) => {{ - get! {@interrogate input, $type_fun, $get_fun, $name} - }}; - } - - macro_rules! default_get { - (@interrogate $item:ident, $default:expr, $get_fun:ident, $name:expr) => {{ - let a = if let Some(field) = $item.get($name) { - field.$get_fun().unwrap_or($default) - } else { - $default - }; - a - }}; - - ($get_fun:ident, $default:expr, $name1:expr, $name2:expr) => {{ - let a = get! {@interrogate input, is_object, as_object, $name1}; - let b = default_get! {@interrogate a, $default, $get_fun, $name2}; - b - }}; - - ($get_fun:ident, $default:expr, $name:expr) => {{ - default_get! {@interrogate input, $default, $get_fun, $name} - }}; - } - - macro_rules! c { - ($color:expr, $format:expr) => { - format!("\x1b[{}m{}\x1b[0m", $color, $format) - }; - } - - #[allow(clippy::items_after_statements)] - fn format_bytes(bytes: u64) -> String { - let bytes = Bytes::new(bytes); - bytes.to_string() - } - - #[allow(clippy::items_after_statements)] - fn format_speed(speed: f64) -> String { - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let bytes = Bytes::new(speed.floor() as u64); - format!("{bytes}/s") - } - - let get_title = || -> String { - match get! {is_string, as_str, "info_dict", "ext"} { - "vtt" => { - format!( - "Subtitles ({})", - default_get! {as_str, "<No Subtitle Language>", "info_dict", "name"} - ) - } - "webm" | "mp4" | "mp3" | "m4a" => { - default_get! { as_str, "<No title>", "info_dict", "title"}.to_owned() - } - other => panic!("The extension '{other}' is not yet implemented"), - } - }; - - match get! {is_string, as_str, "status"} { - "downloading" => { - let elapsed = default_get! {as_f64, 0.0f64, "elapsed"}; - let eta = default_get! {as_f64, 0.0, "eta"}; - let speed = default_get! {as_f64, 0.0, "speed"}; - - let downloaded_bytes = get! {is_u64, as_u64, "downloaded_bytes"}; - let (total_bytes, bytes_is_estimate): (u64, &'static str) = { - let total_bytes = default_get!(as_u64, 0, "total_bytes"); - if total_bytes == 0 { - let maybe_estimate = default_get!(as_u64, 0, "total_bytes_estimate"); - - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - if maybe_estimate == 0 { - // The download speed should be in bytes per second and the eta in seconds. - // Thus multiplying them gets us the raw bytes (which were estimated by `yt_dlp`, from their `info.json`) - let bytes_still_needed = (speed * eta).ceil() as u64; - - (downloaded_bytes + bytes_still_needed, "~") - } else { - (maybe_estimate, "~") - } - } else { - (total_bytes, "") - } - }; - - let percent: f64 = { - if total_bytes == 0 { - 100.0 - } else { - #[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss - )] - { - (downloaded_bytes as f64 / total_bytes as f64) * 100.0 - } - } - }; - - clear_whole_line(); - move_to_col(1); - - eprint!( - "'{}' [{}/{} at {}] -> [{} of {}{} {}] ", - c!("34;1", get_title()), - c!("33;1", MaybeDuration::from_secs_f64(elapsed)), - c!("33;1", MaybeDuration::from_secs_f64(eta)), - c!("32;1", format_speed(speed)), - c!("31;1", format_bytes(downloaded_bytes)), - c!("31;1", bytes_is_estimate), - c!("31;1", format_bytes(total_bytes)), - c!("36;1", format!("{:.02}%", percent)) - ); - stderr().flush()?; - } - "finished" => { - eprintln!("-> Finished downloading."); - } - "error" => { - // TODO: This should probably return an Err. But I'm not so sure where the error would - // bubble up to (i.e., who would catch it) <2025-01-21> - eprintln!("-> Error while downloading: {}", get_title()); - process::exit(1); - } - other => unreachable!("'{other}' should not be a valid state!"), - } - - Ok(()) -} - -mk_python_function!(progress_hook, wrapped_progress_hook); diff --git a/crates/yt/src/main.rs b/crates/yt/src/main.rs index 930d269..705e642 100644 --- a/crates/yt/src/main.rs +++ b/crates/yt/src/main.rs @@ -11,51 +11,35 @@ // `yt` is not a library. Besides, the `anyhow::Result` type is really useless, if you're not going // to print it anyways. -#![allow(clippy::missing_errors_doc)] +#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)] -use std::{env::current_exe, sync::Arc}; - -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result}; use app::App; -use bytes::Bytes; -use cache::{invalidate, maintain}; -use clap::Parser; -use cli::{CacheCommand, SelectCommand, SubscriptionCommand, VideosCommand}; +use clap::{CommandFactory, Parser}; use config::Config; -use log::{error, info}; -use select::cmds::handle_select_cmd; -use storage::video_database::get::video_by_hash; -use tokio::{ - fs::File, - io::{BufReader, stdin}, - task::JoinHandle, -}; +use log::info; + +use crate::commands::Command; -use crate::{cli::Command, storage::subscriptions}; +pub(crate) mod output; +pub(crate) mod yt_dlp; -pub mod ansi_escape_codes; -pub mod app; -pub mod cli; -pub mod unreachable; +pub(crate) mod ansi_escape_codes; +pub(crate) mod app; +pub(crate) mod cli; +pub(crate) mod commands; +pub(crate) mod shared; -pub mod cache; -pub mod comments; -pub mod config; -pub mod constants; -pub mod download; -pub mod select; -pub mod status; -pub mod storage; -pub mod subscribe; -pub mod update; -pub mod version; -pub mod videos; -pub mod watch; +pub(crate) mod config; +pub(crate) mod select; +pub(crate) mod storage; +pub(crate) mod version; +pub(crate) mod videos; #[tokio::main] -// This is _the_ main function after all. It is not really good, but it sort of works. -#[allow(clippy::too_many_lines)] async fn main() -> Result<()> { + clap_complete::CompleteEnv::with_factory(cli::CliArgs::command).complete(); + let args = cli::CliArgs::parse(); // The default verbosity is 1 (Warn) @@ -82,219 +66,24 @@ async fn main() -> Result<()> { } }); - let config = Config::from_config_file(args.db_path, args.config_path, args.color)?; + let config = Config::from_config_file(args.config_path, args.color, args.db_path)?; if args.version { version::show(&config).await?; return Ok(()); } - let app = App::new(config, !args.no_migrate_db).await?; - - match args.command.unwrap_or(Command::default()) { - Command::Download { - force, - max_cache_size, - } => { - let max_cache_size = - max_cache_size.unwrap_or(app.config.download.max_cache_size.as_u64()); - info!("Max cache size: '{}'", Bytes::new(max_cache_size)); - - maintain(&app, false).await?; - if force { - invalidate(&app, true).await?; - } - - download::Downloader::new() - .consume(Arc::new(app), max_cache_size) - .await?; - } - Command::Select { cmd } => { - let cmd = cmd.unwrap_or(SelectCommand::default()); - - match cmd { - SelectCommand::File { - done, - use_last_selection, - split, - } => { - if split { - assert!(!use_last_selection); - Box::pin(select::select_split(&app, done)).await? - } else { - Box::pin(select::select_file(&app, done, use_last_selection)).await? - } - } - _ => Box::pin(handle_select_cmd(&app, cmd, None)).await?, - } - } - Command::Sedowa {} => { - Box::pin(select::select_file(&app, false, false)).await?; - - let arc_app = Arc::new(app); - dowa(arc_app).await?; - } - Command::Dowa {} => { - let arc_app = Arc::new(app); - dowa(arc_app).await?; - } - Command::Videos { cmd } => match cmd { - VideosCommand::List { - search_query, - limit, - } => { - videos::query(&app, limit, search_query) - .await - .context("Failed to query videos")?; - } - VideosCommand::Info { hash } => { - let video = video_by_hash(&app, &hash.realize(&app).await?).await?; - - print!( - "{}", - &video - .to_info_display(&app) - .await - .context("Failed to format video")? - ); - } - }, - Command::Update { - max_backlog, - subscriptions, - grouped, - current_progress, - total_number, - } => { - let all_subs = subscriptions::get(&app).await?; - - for sub in &subscriptions { - if !all_subs.0.contains_key(sub) { - bail!( - "Your specified subscription to update '{}' is not a subscription!", - sub - ) - } - } - - let max_backlog = max_backlog.unwrap_or(app.config.update.max_backlog); - - if grouped { - const CHUNK_SIZE: usize = 50; - - assert!(current_progress.is_none() && total_number.is_none()); - - let subs = { - if subscriptions.is_empty() { - all_subs.0.into_iter().map(|sub| sub.0).collect() - } else { - subscriptions - } - }; - - let total_number = subs.len(); - let mut current_progress = 0; - for chunk in subs.chunks(CHUNK_SIZE) { - info!( - "$ yt update {}", - chunk - .iter() - .map(|sub_name| format!("{sub_name:#?}")) - .collect::<Vec<_>>() - .join(" ") - ); - - let status = std::process::Command::new( - current_exe().context("Failed to get the current exe to re-execute")?, - ) - .arg("update") - .args(["--current-progress", current_progress.to_string().as_str()]) - .args(["--total-number", total_number.to_string().as_str()]) - .args(chunk) - .status()?; - - if !status.success() { - bail!("grouped yt update: Child process failed."); - } - - current_progress += CHUNK_SIZE; - } - } else { - update::update(&app, max_backlog, subscriptions, total_number, current_progress).await?; - } - } - Command::Subscriptions { cmd } => match cmd { - SubscriptionCommand::Add { name, url } => { - subscribe::subscribe(&app, name, url) - .await - .context("Failed to add a subscription")?; - } - SubscriptionCommand::Remove { name } => { - subscribe::unsubscribe(&app, name) - .await - .context("Failed to remove a subscription")?; - } - SubscriptionCommand::List {} => { - let all_subs = subscriptions::get(&app).await?; - - for (key, val) in all_subs.0 { - println!("{}: '{}'", key, val.url); - } - } - SubscriptionCommand::Export {} => { - let all_subs = subscriptions::get(&app).await?; - for val in all_subs.0.values() { - println!("{}", val.url); - } - } - SubscriptionCommand::Import { file, force } => { - if let Some(file) = file { - let f = File::open(file).await?; - - subscribe::import(&app, BufReader::new(f), force).await?; - } else { - subscribe::import(&app, BufReader::new(stdin()), force).await?; - } - } - }, - - Command::Watch {} => watch::watch(Arc::new(app)).await?, - Command::Playlist { watch } => watch::playlist::playlist(&app, watch).await?, - - Command::Status {} => status::show(&app).await?, - Command::Config {} => status::config(&app)?, - - Command::Database { command } => match command { - CacheCommand::Invalidate { hard } => invalidate(&app, hard).await?, - CacheCommand::Maintain { all } => maintain(&app, all).await?, - }, - - Command::Comments {} => { - comments::comments(&app).await?; - } - Command::Description {} => { - comments::description(&app).await?; - } - } - - Ok(()) -} - -async fn dowa(arc_app: Arc<App>) -> Result<()> { - let max_cache_size = arc_app.config.download.max_cache_size; - info!("Max cache size: '{max_cache_size}'"); + // Perform config finalization _after_ checking for the version + // so that version always works. + config + .run_finalizers() + .context("Failed to finalize config for usage")?; - let arc_app_clone = Arc::clone(&arc_app); - let download: JoinHandle<()> = tokio::spawn(async move { - let result = download::Downloader::new() - .consume(arc_app_clone, max_cache_size.as_u64()) - .await; + let app = App::new(config, !args.no_migrate_db).await?; - if let Err(err) = result { - error!("Error from downloader: {err:?}"); - } - }); + args.command + .unwrap_or(Command::default()) + .implm(app) + .await?; - watch::watch(arc_app).await?; - download.await?; Ok(()) } diff --git a/crates/yt/src/comments/output.rs b/crates/yt/src/output/mod.rs index cb3a9c4..2f74519 100644 --- a/crates/yt/src/comments/output.rs +++ b/crates/yt/src/output/mod.rs @@ -17,9 +17,7 @@ use std::{ use anyhow::{Context, Result}; use uu_fmt::{FmtOptions, process_text}; -use crate::unreachable::Unreachable; - -pub async fn display_fmt_and_less(input: String) -> Result<()> { +pub(crate) fn display_less(input: String) -> Result<()> { let mut less = Command::new("less") .args(["--raw-control-chars"]) .stdin(Stdio::piped()) @@ -27,12 +25,11 @@ pub async fn display_fmt_and_less(input: String) -> Result<()> { .spawn() .context("Failed to run less")?; - let input = format_text(&input); let mut stdin = less.stdin.take().context("Failed to open stdin")?; std::thread::spawn(move || { stdin .write_all(input.as_bytes()) - .unreachable("Should be able to write to the stdin of less"); + .expect("Should be able to write to the stdin of less"); }); let _ = less.wait().context("Failed to await less")?; @@ -40,9 +37,15 @@ pub async fn display_fmt_and_less(input: String) -> Result<()> { Ok(()) } +pub(crate) fn display_fmt_and_less(input: &str) -> Result<()> { + display_less(format_text(&input, None)) +} + #[must_use] -pub fn format_text(input: &str) -> String { - let width = termsize::get().map_or(90, |size| size.cols); +pub(crate) fn format_text(input: &str, termsize: Option<u16>) -> String { + let input = input.trim(); + + let width = termsize.unwrap_or_else(|| termsize::get().map_or(90, |size| size.cols)); let fmt_opts = FmtOptions { uniform: true, split_only: true, diff --git a/crates/yt/src/select/cmds/mod.rs b/crates/yt/src/select/cmds/mod.rs deleted file mode 100644 index aabcd3d..0000000 --- a/crates/yt/src/select/cmds/mod.rs +++ /dev/null @@ -1,111 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use crate::{ - app::App, - cli::{SelectCommand, SharedSelectionCommandArgs}, - storage::video_database::{ - Priority, VideoOptions, VideoStatus, - get::video_by_hash, - set::{set_video_options, video_status}, - }, -}; - -use anyhow::{Context, Result, bail}; - -mod add; - -pub async fn handle_select_cmd( - app: &App, - cmd: SelectCommand, - line_number: Option<i64>, -) -> Result<()> { - match cmd { - SelectCommand::Pick { shared } => { - handle_status_change(app, shared, line_number, VideoStatus::Pick).await?; - } - SelectCommand::Drop { shared } => { - handle_status_change(app, shared, line_number, VideoStatus::Drop).await?; - } - SelectCommand::Watched { shared } => { - handle_status_change(app, shared, line_number, VideoStatus::Watched).await?; - } - SelectCommand::Add { urls, start, stop } => { - Box::pin(add::add(app, urls, start, stop)).await?; - } - SelectCommand::Watch { shared } => { - let hash = shared.hash.clone().realize(app).await?; - - let video = video_by_hash(app, &hash).await?; - - if let VideoStatus::Cached { - cache_path, - is_focused, - } = video.status - { - handle_status_change( - app, - shared, - line_number, - VideoStatus::Cached { - cache_path, - is_focused, - }, - ) - .await?; - } else { - handle_status_change(app, shared, line_number, VideoStatus::Watch).await?; - } - } - - SelectCommand::Url { shared } => { - let Some(url) = shared.url else { - bail!("You need to provide a url to `select url ..`") - }; - - let mut firefox = std::process::Command::new("firefox"); - firefox.args(["-P", "timesinks.youtube"]); - firefox.arg(url.as_str()); - let _handle = firefox.spawn().context("Failed to run firefox")?; - } - SelectCommand::File { .. } => unreachable!("This should have been filtered out"), - } - Ok(()) -} - -async fn handle_status_change( - app: &App, - shared: SharedSelectionCommandArgs, - line_number: Option<i64>, - new_status: VideoStatus, -) -> Result<()> { - let hash = shared.hash.realize(app).await?; - let video_options = VideoOptions::new( - shared - .subtitle_langs - .unwrap_or(app.config.select.subtitle_langs.clone()), - shared.speed.unwrap_or(app.config.select.playback_speed), - ); - let priority = compute_priority(line_number, shared.priority); - - video_status(app, &hash, new_status, priority).await?; - set_video_options(app, &hash, &video_options).await?; - - Ok(()) -} - -fn compute_priority(line_number: Option<i64>, priority: Option<i64>) -> Option<Priority> { - if let Some(pri) = priority { - Some(Priority::from(pri)) - } else { - line_number.map(Priority::from) - } -} diff --git a/crates/yt/src/select/selection_file/duration.rs b/crates/yt/src/select/duration.rs index 668a0b8..f1de2ea 100644 --- a/crates/yt/src/select/selection_file/duration.rs +++ b/crates/yt/src/select/duration.rs @@ -20,51 +20,45 @@ const HOUR: u64 = 60 * MINUTE; const DAY: u64 = 24 * HOUR; #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct MaybeDuration { +pub(crate) struct MaybeDuration { time: Option<Duration>, } impl MaybeDuration { #[must_use] - pub fn from_std(d: Duration) -> Self { + pub(crate) fn from_std(d: Duration) -> Self { Self { time: Some(d) } } #[must_use] - pub fn from_secs_f64(d: f64) -> Self { + pub(crate) fn from_secs_f64(d: f64) -> Self { Self { time: Some(Duration::from_secs_f64(d)), } } #[must_use] - pub fn from_maybe_secs_f64(d: Option<f64>) -> Self { + pub(crate) fn from_maybe_secs_f64(d: Option<f64>) -> Self { Self { time: d.map(Duration::from_secs_f64), } } #[must_use] - pub fn from_secs(d: u64) -> Self { + #[cfg(test)] + pub(crate) fn from_secs(d: u64) -> Self { Self { time: Some(Duration::from_secs(d)), } } - #[must_use] - pub fn zero() -> Self { - Self { - time: Some(Duration::default()), - } - } - /// Try to return the current duration encoded as seconds. #[must_use] - pub fn as_secs(&self) -> Option<u64> { + pub(crate) fn as_secs(&self) -> Option<u64> { self.time.map(|v| v.as_secs()) } /// Try to return the current duration encoded as seconds and nanoseconds. #[must_use] - pub fn as_secs_f64(&self) -> Option<f64> { + pub(crate) fn as_secs_f64(&self) -> Option<f64> { self.time.map(|v| v.as_secs_f64()) } } @@ -209,7 +203,7 @@ impl std::fmt::Display for MaybeDuration { mod test { use std::str::FromStr; - use crate::select::selection_file::duration::{DAY, HOUR, MINUTE}; + use crate::select::duration::{DAY, HOUR, MINUTE}; use super::MaybeDuration; diff --git a/crates/yt/src/select/mod.rs b/crates/yt/src/select/mod.rs index 668ab02..b02677f 100644 --- a/crates/yt/src/select/mod.rs +++ b/crates/yt/src/select/mod.rs @@ -9,259 +9,7 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -use std::{ - collections::HashMap, - env::{self}, - fs::{self, File}, - io::{BufRead, BufReader, BufWriter, Write}, - iter, - path::Path, - string::String, -}; - -use crate::{ - app::App, - cli::CliArgs, - constants::HELP_STR, - storage::video_database::{Video, VideoStatusMarker, get}, - unreachable::Unreachable, -}; - -use anyhow::{Context, Result, bail}; -use clap::Parser; -use cmds::handle_select_cmd; -use futures::{TryStreamExt, stream::FuturesOrdered}; -use log::info; -use selection_file::process_line; -use tempfile::Builder; -use tokio::process::Command; - -pub mod cmds; -pub mod selection_file; - -pub async fn select_split(app: &App, done: bool) -> Result<()> { - let temp_dir = Builder::new() - .prefix("yt_video_select-") - .rand_bytes(6) - .tempdir() - .context("Failed to get tempdir")?; - - let matching_videos = get_videos(app, done).await?; - - let mut no_author = vec![]; - let mut author_map = HashMap::new(); - for video in matching_videos { - if let Some(sub) = &video.parent_subscription_name { - if author_map.contains_key(sub) { - let vec: &mut Vec<_> = author_map - .get_mut(sub) - .unreachable("This key is set, we checked in the if above"); - - vec.push(video); - } else { - author_map.insert(sub.to_owned(), vec![video]); - } - } else { - no_author.push(video); - } - } - - let author_map = { - let mut temp_vec: Vec<_> = author_map.into_iter().collect(); - - // PERFORMANCE: The clone here should not be neeed. <2025-06-15> - temp_vec.sort_by_key(|(name, _)| name.to_owned()); - - temp_vec - }; - - for (index, (name, videos)) in author_map - .into_iter() - .chain(iter::once(( - "<No parent subscription>".to_owned(), - no_author, - ))) - .enumerate() - { - let mut file_path = temp_dir.path().join(format!("{index:02}_{name}")); - file_path.set_extension("yts"); - - let tmp_file = File::create(&file_path) - .with_context(|| format!("Falied to create file at: {}", file_path.display()))?; - - write_videos_to_file(app, &tmp_file, &videos) - .await - .with_context(|| format!("Falied to populate file at: {}", file_path.display()))?; - } - - open_editor_at(temp_dir.path()).await?; - - let mut paths = vec![]; - for maybe_entry in temp_dir - .path() - .read_dir() - .context("Failed to open temp dir for reading")? - { - let entry = maybe_entry.context("Failed to read entry in temp dir")?; - - if !entry.file_type()?.is_file() { - bail!("Found non-file entry: {}", entry.path().display()); - } - - paths.push(entry.path()); - } - - paths.sort(); - - let mut processed = 0; - for path in paths { - let read_file = File::open(path)?; - processed = process_file(app, &read_file, processed).await?; - } - - info!("Processed {processed} records."); - temp_dir.close().context("Failed to close the temp dir")?; - Ok(()) -} - -pub async fn select_file(app: &App, done: bool, use_last_selection: bool) -> Result<()> { - let temp_file = Builder::new() - .prefix("yt_video_select-") - .suffix(".yts") - .rand_bytes(6) - .tempfile() - .context("Failed to get tempfile")?; - - if use_last_selection { - fs::copy(&app.config.paths.last_selection_path, &temp_file)?; - } else { - let matching_videos = get_videos(app, done).await?; - - write_videos_to_file(app, temp_file.as_file(), &matching_videos).await?; - } - - open_editor_at(temp_file.path()).await?; - - let read_file = temp_file.reopen()?; - fs::copy(temp_file.path(), &app.config.paths.last_selection_path) - .context("Failed to persist selection file")?; - - let processed = process_file(app, &read_file, 0).await?; - info!("Processed {processed} records."); - - Ok(()) -} - -async fn get_videos(app: &App, include_done: bool) -> Result<Vec<Video>> { - if include_done { - get::videos(app, VideoStatusMarker::ALL).await - } else { - get::videos( - app, - &[ - VideoStatusMarker::Pick, - // - VideoStatusMarker::Watch, - VideoStatusMarker::Cached, - ], - ) - .await - } -} - -async fn write_videos_to_file(app: &App, file: &File, videos: &[Video]) -> Result<()> { - // Warm-up the cache for the display rendering of the videos. - // Otherwise the futures would all try to warm it up at the same time. - if let Some(vid) = videos.first() { - drop(vid.to_line_display(app).await?); - } - - let mut edit_file = BufWriter::new(file); - - videos - .iter() - .map(|vid| vid.to_select_file_display(app)) - .collect::<FuturesOrdered<_>>() - .try_collect::<Vec<String>>() - .await? - .into_iter() - .try_for_each(|line| -> Result<()> { - edit_file - .write_all(line.as_bytes()) - .context("Failed to write to `edit_file`")?; - - Ok(()) - })?; - - edit_file.write_all(HELP_STR.as_bytes())?; - edit_file.flush().context("Failed to flush edit file")?; - - Ok(()) -} - -async fn process_file(app: &App, file: &File, processed: i64) -> Result<i64> { - let reader = BufReader::new(file); - - let mut line_number = -processed; - - for line in reader.lines() { - let line = line.context("Failed to read a line")?; - - if let Some(line) = process_line(&line)? { - line_number -= 1; - - // debug!( - // "Parsed command: `{}`", - // line.iter() - // .map(|val| format!("\"{}\"", val)) - // .collect::<Vec<String>>() - // .join(" ") - // ); - - let arg_line = ["yt", "select"] - .into_iter() - .chain(line.iter().map(String::as_str)); - - let args = CliArgs::parse_from(arg_line); - - let crate::cli::Command::Select { cmd } = args - .command - .unreachable("This will be some, as we constructed it above.") - else { - unreachable!("This is checked in the `filter_line` function") - }; - - Box::pin(handle_select_cmd( - app, - cmd.unreachable( - "This value should always be some \ - here, as it would otherwise thrown an error above.", - ), - Some(line_number), - )) - .await?; - } - } - - Ok(line_number * -1) -} - -async fn open_editor_at(path: &Path) -> Result<()> { - let editor = env::var("EDITOR").unwrap_or("nvim".to_owned()); - - let mut nvim = Command::new(&editor); - nvim.arg(path); - let status = nvim - .status() - .await - .with_context(|| format!("Falied to run editor: {editor}"))?; - - if status.success() { - Ok(()) - } else { - bail!("Editor ({editor}) exited with error status: {}", status) - } -} +pub(crate) mod duration; // // FIXME: There should be no reason why we need to re-run yt, just to get the help string. But I've // // yet to find a way to do it without the extra exec <2024-08-20> diff --git a/crates/yt/src/select/selection_file/mod.rs b/crates/yt/src/select/selection_file/mod.rs deleted file mode 100644 index abd26c4..0000000 --- a/crates/yt/src/select/selection_file/mod.rs +++ /dev/null @@ -1,32 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -//! The data structures needed to express the file, which the user edits - -use anyhow::{Context, Result}; -use trinitry::Trinitry; - -pub mod duration; - -pub fn process_line(line: &str) -> Result<Option<Vec<String>>> { - // Filter out comments and empty lines - if line.starts_with('#') || line.trim().is_empty() { - Ok(None) - } else { - let tri = Trinitry::new(line).with_context(|| format!("Failed to parse line '{line}'"))?; - - let mut vec = Vec::with_capacity(tri.arguments().len() + 1); - vec.push(tri.command().to_owned()); - vec.extend(tri.arguments().to_vec()); - - Ok(Some(vec)) - } -} diff --git a/crates/bytes/src/error.rs b/crates/yt/src/shared/bytes/error.rs index c9783d8..c9783d8 100644 --- a/crates/bytes/src/error.rs +++ b/crates/yt/src/shared/bytes/error.rs diff --git a/crates/bytes/src/lib.rs b/crates/yt/src/shared/bytes/mod.rs index 2a9248d..31e782e 100644 --- a/crates/bytes/src/lib.rs +++ b/crates/yt/src/shared/bytes/mod.rs @@ -16,6 +16,7 @@ )] use std::{fmt::Display, str::FromStr}; +use ::serde::{Deserialize, Serialize}; use error::BytesError; const B: u64 = 1; @@ -31,10 +32,11 @@ const MB: u64 = 1000 * KB; const GB: u64 = 1000 * MB; const TB: u64 = 1000 * GB; -pub mod error; -pub mod serde; +pub(crate) mod error; -#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Ord, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Ord, Eq, Deserialize, Serialize)] +#[serde(try_from = "String")] +#[serde(into = "String")] pub struct Bytes(u64); impl Bytes { @@ -131,6 +133,20 @@ impl Display for Bytes { } } +impl From<Bytes> for String { + fn from(value: Bytes) -> Self { + value.to_string() + } +} + +impl TryFrom<String> for Bytes { + type Error = BytesError; + + fn try_from(value: String) -> Result<Self, Self::Error> { + value.as_str().parse() + } +} + // taken from this stack overflow question: https://stackoverflow.com/a/76572321 /// Round to significant digits (rather than digits after the decimal). /// @@ -149,7 +165,7 @@ impl Display for Bytes { ///# } /// ``` #[must_use] -pub fn precision_f64(x: f64, decimals: u32) -> f64 { +pub(crate) fn precision_f64(x: f64, decimals: u32) -> f64 { if x == 0. || decimals == 0 { 0. } else { diff --git a/crates/yt/src/shared/mod.rs b/crates/yt/src/shared/mod.rs new file mode 100644 index 0000000..d3cc563 --- /dev/null +++ b/crates/yt/src/shared/mod.rs @@ -0,0 +1,11 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +pub(crate) mod bytes; diff --git a/crates/yt/src/status/mod.rs b/crates/yt/src/status/mod.rs deleted file mode 100644 index 18bef7d..0000000 --- a/crates/yt/src/status/mod.rs +++ /dev/null @@ -1,129 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::time::Duration; - -use crate::{ - app::App, - download::Downloader, - select::selection_file::duration::MaybeDuration, - storage::{ - subscriptions, - video_database::{VideoStatusMarker, get}, - }, -}; - -use anyhow::{Context, Result}; -use bytes::Bytes; - -macro_rules! get { - ($videos:expr, $status:ident) => { - $videos - .iter() - .filter(|vid| vid.status.as_marker() == VideoStatusMarker::$status) - .count() - }; - - (@collect $videos:expr, $status:ident) => { - $videos - .iter() - .filter(|vid| vid.status.as_marker() == VideoStatusMarker::$status) - .collect() - }; -} - -pub async fn show(app: &App) -> Result<()> { - let all_videos = get::videos(app, VideoStatusMarker::ALL).await?; - - // lengths - let picked_videos_len = get!(all_videos, Pick); - - let watch_videos_len = get!(all_videos, Watch); - let cached_videos_len = get!(all_videos, Cached); - let watched_videos_len = get!(all_videos, Watched); - let watched_videos: Vec<_> = get!(@collect all_videos, Watched); - - let drop_videos_len = get!(all_videos, Drop); - let dropped_videos_len = get!(all_videos, Dropped); - - let subscriptions = subscriptions::get(app).await?; - let subscriptions_len = subscriptions.0.len(); - - let watchtime_status = { - let total_watch_time_raw = watched_videos - .iter() - .fold(Duration::default(), |acc, vid| acc + vid.watch_progress); - - // Most things are watched at a speed of s (which is defined in the config file). - // Thus - // y = x * s -> y / s = x - let total_watch_time = Duration::from_secs_f64( - (total_watch_time_raw.as_secs_f64()) / app.config.select.playback_speed, - ); - - let speed = app.config.select.playback_speed; - - // Do not print the adjusted time, if the user has keep the speed level at 1. - #[allow(clippy::float_cmp)] - if speed == 1.0 { - format!( - "Total Watchtime: {}\n", - MaybeDuration::from_std(total_watch_time_raw) - ) - } else { - format!( - "Total Watchtime: {} (at {speed} speed: {})\n", - MaybeDuration::from_std(total_watch_time_raw), - MaybeDuration::from_std(total_watch_time), - ) - } - }; - - let watch_rate: f64 = { - fn to_f64(input: usize) -> f64 { - f64::from(u32::try_from(input).expect("This should never exceed u32::MAX")) - } - - let count = to_f64(watched_videos_len) / (to_f64(drop_videos_len) + to_f64(dropped_videos_len)); - count * 100.0 - }; - - let cache_usage_raw = Downloader::get_current_cache_allocation(app) - .await - .context("Failed to get current cache allocation")?; - let cache_usage: Bytes = cache_usage_raw; - println!( - "\ -Picked Videos: {picked_videos_len} - -Watch Videos: {watch_videos_len} -Cached Videos: {cached_videos_len} -Watched Videos: {watched_videos_len} (watch rate: {watch_rate:.2} %) - -Drop Videos: {drop_videos_len} -Dropped Videos: {dropped_videos_len} - -{watchtime_status} - - Subscriptions: {subscriptions_len} - Cache usage: {cache_usage}" - ); - - Ok(()) -} - -pub fn config(app: &App) -> Result<()> { - let config_str = toml::to_string(&app.config)?; - - print!("{config_str}"); - - Ok(()) -} diff --git a/crates/yt/src/storage/db/extractor_hash.rs b/crates/yt/src/storage/db/extractor_hash.rs new file mode 100644 index 0000000..3ad8273 --- /dev/null +++ b/crates/yt/src/storage/db/extractor_hash.rs @@ -0,0 +1,220 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{collections::HashSet, fmt::Display, str::FromStr}; + +use anyhow::{Context, Result, bail}; +use blake3::Hash; +use log::debug; +use serde::{Deserialize, Serialize}; +use tokio::sync::OnceCell; +use yt_dlp::{info_json::InfoJson, json_cast, json_get, json_try_get}; + +use crate::app::App; + +static EXTRACTOR_HASH_LENGTH: OnceCell<usize> = OnceCell::const_new(); + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy, Serialize, Deserialize)] +pub(crate) struct ExtractorHash { + hash: Hash, +} + +impl Display for ExtractorHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.hash.fmt(f) + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ShortHash(String); + +impl Display for ShortHash { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +#[derive(Debug, Clone)] +#[allow(clippy::module_name_repetitions)] +pub(crate) struct LazyExtractorHash { + value: ShortHash, +} + +impl FromStr for LazyExtractorHash { + type Err = anyhow::Error; + + fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { + // perform some cheap validation + if s.len() > 64 { + bail!("A hash can only contain 64 bytes!"); + } + + Ok(Self { + value: ShortHash(s.to_owned()), + }) + } +} + +impl LazyExtractorHash { + /// Turn the [`LazyExtractorHash`] into the [`ExtractorHash`] + pub(crate) async fn realize( + self, + app: &App, + all_hashes: Option<&[ExtractorHash]>, + ) -> Result<ExtractorHash> { + ExtractorHash::from_short_hash(app, &self.value, all_hashes).await + } +} + +impl ExtractorHash { + #[must_use] + pub(crate) fn from_hash(hash: Hash) -> Self { + Self { hash } + } + + pub(crate) async fn from_short_hash( + app: &App, + s: &ShortHash, + all_hashes: Option<&[Self]>, + ) -> Result<Self> { + let all_hashes = if let Some(all) = all_hashes { + all + } else { + &Self::get_all(app) + .await + .context("Failed to fetch all extractor-hashes from the database")? + }; + let needed_chars = s.0.len(); + for hash in all_hashes { + // PERFORMANCE(@bpeetz): This could avoid the string construction and just use a + // numeric equality check instead. <2025-07-15> + if hash.hash().to_hex()[..needed_chars] == s.0 { + return Ok(*hash); + } + } + bail!("Your shortend hash, does not match a real hash (this is probably a bug)!"); + } + + pub(crate) fn from_info_json(entry: &InfoJson) -> Self { + // HACK(@bpeetz): The code that follows is a gross hack. + // One would expect the `id` to be unique _and_ constant for each and every possible info JSON. + // But .. it's just not. The `ARDMediathek` extractor, will sometimes return different `id`s for the same + // video, effectively causing us to insert the same video again into the db (which fails, + // because the URL is still unique). + // + // As such we _should_ probably find a constant value for all extractors, but that just does + // not exist currently, without processing each entry (which is expensive and which I would + // like to avoid). + // + // Therefor, we simply special case the `ARDBetaMediathek` extractor. <2025-07-04> + + // NOTE(@bpeetz): `yt-dlp` apparently uses these two different names for the same thing <2025-07-04> + let ie_key = { + if let Some(ie_key) = json_try_get!(entry, "ie_key", as_str) { + ie_key + } else if let Some(extractor_key) = json_try_get!(entry, "extractor_key", as_str) { + extractor_key + } else { + unreachable!( + "Either `ie_key` or `extractor_key` \ + should be set on every entry info json" + ) + } + }; + + if ie_key == "ARDBetaMediathek" { + // NOTE(@bpeetz): The mediathek is changing their Id scheme, from an `short` old Id to the + // new id. As the new id is too long for some people, yt-dlp will be default return the old + // one (when it is still available!). The new one is called `display_id`. + // Therefore, we simply check if the new one is explicitly returned, and otherwise use the + // normal `id` value, as these are cases where the old one is no longer available. <2025-07-04> + let id = if let Some(display_id) = json_try_get!(entry, "display_id", as_str) { + display_id.as_bytes() + } else { + json_get!(entry, "id", as_str).as_bytes() + }; + + Self { + hash: blake3::hash(id), + } + } else { + Self { + hash: blake3::hash(json_get!(entry, "id", as_str).as_bytes()), + } + } + } + + #[must_use] + pub(crate) fn hash(&self) -> &Hash { + &self.hash + } + + pub(crate) async fn as_short_hash(&self, app: &App) -> Result<ShortHash> { + let needed_chars = if let Some(needed_chars) = EXTRACTOR_HASH_LENGTH.get() { + *needed_chars + } else { + let needed_chars = self + .get_needed_char_len(app) + .await + .context("Failed to calculate needed char length")?; + EXTRACTOR_HASH_LENGTH + .set(needed_chars) + .expect("This should work at this stage, as we checked above that it is empty."); + + needed_chars + }; + + Ok(ShortHash( + self.hash() + .to_hex() + .chars() + .take(needed_chars) + .collect::<String>(), + )) + } + + async fn get_needed_char_len(&self, app: &App) -> Result<usize> { + debug!("Calculating the needed hash char length"); + let all_hashes = Self::get_all(app) + .await + .context("Failed to fetch all extractor -hashesh from database")?; + + let all_char_vec_hashes = all_hashes + .into_iter() + .map(|hash| hash.hash().to_hex().chars().collect::<Vec<char>>()) + .collect::<Vec<Vec<_>>>(); + + // This value should be updated later, if not rust will panic in the assertion. + let mut needed_chars: usize = 1000; + 'outer: for i in 1..64 { + let i_chars: Vec<String> = all_char_vec_hashes + .iter() + .map(|vec| vec.iter().take(i).collect::<String>()) + .collect(); + + let mut uniqnes_hashmap: HashSet<String> = HashSet::new(); + for ch in i_chars { + if !uniqnes_hashmap.insert(ch) { + // The key was already in the hash map, thus we have a duplicated char and need + // at least one char more + continue 'outer; + } + } + + needed_chars = i; + break 'outer; + } + + assert!(needed_chars <= 64, "Hashes are only 64 bytes long"); + + Ok(needed_chars) + } +} diff --git a/crates/yt/src/storage/db/get/extractor_hash.rs b/crates/yt/src/storage/db/get/extractor_hash.rs new file mode 100644 index 0000000..c8e150a --- /dev/null +++ b/crates/yt/src/storage/db/get/extractor_hash.rs @@ -0,0 +1,68 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use anyhow::Result; +use blake3::Hash; +use sqlx::{SqliteConnection, query}; + +use crate::{ + app::App, + storage::db::{ + extractor_hash::ExtractorHash, + video::{Video, video_from_record}, + }, +}; + +impl ExtractorHash { + pub(crate) async fn get(&self, txn: &mut SqliteConnection) -> Result<Video> { + let extractor_hash = self.hash().to_string(); + + let base = query!( + r#" + SELECT * + FROM videos + WHERE extractor_hash = ? + "#, + extractor_hash + ) + .fetch_one(txn) + .await?; + + Ok(video_from_record!(base)) + } + + pub(crate) async fn get_with_app(&self, app: &App) -> Result<Video> { + let mut txn = app.database.begin().await?; + let out = self.get(&mut txn).await?; + txn.commit().await?; + + Ok(out) + } + + pub(crate) async fn get_all(app: &App) -> Result<Vec<Self>> { + let hashes_hex = query!( + r#" + SELECT extractor_hash + FROM videos; + "# + ) + .fetch_all(&app.database) + .await?; + + Ok(hashes_hex + .iter() + .map(|hash| { + Self::from_hash(Hash::from_hex(&hash.extractor_hash).expect( + "These values started as blake3 hashes, they should stay blake3 hashes", + )) + }) + .collect()) + } +} diff --git a/crates/yt/src/storage/db/get/mod.rs b/crates/yt/src/storage/db/get/mod.rs new file mode 100644 index 0000000..4bcd066 --- /dev/null +++ b/crates/yt/src/storage/db/get/mod.rs @@ -0,0 +1,15 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +pub(crate) mod extractor_hash; +pub(crate) mod playlist; +pub(crate) mod subscription; +pub(crate) mod txn_log; +pub(crate) mod video; diff --git a/crates/yt/src/storage/db/get/playlist.rs b/crates/yt/src/storage/db/get/playlist.rs new file mode 100644 index 0000000..5094523 --- /dev/null +++ b/crates/yt/src/storage/db/get/playlist.rs @@ -0,0 +1,68 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{ + app::App, + storage::db::{ + playlist::{Playlist, PlaylistIndex}, + video::{Video, VideoStatusMarker}, + }, +}; + +use anyhow::Result; + +impl Playlist { + /// Get an video based in its index. + #[must_use] + pub(crate) fn get_mut(&mut self, index: PlaylistIndex) -> Option<&mut Video> { + self.videos.get_mut(Into::<usize>::into(index)) + } + + /// Create a playlist, by loading it from the database. + pub(crate) async fn create(app: &App) -> Result<Self> { + let videos = Video::in_states(app, &[VideoStatusMarker::Cached]).await?; + + Ok(Self { videos }) + } + + /// Return the current playlist index. + /// + /// This effectively looks for the currently focused video and returns it's index. + /// + /// # Panics + /// Only if internal assertions fail. + pub(crate) fn current_index(&self) -> Option<PlaylistIndex> { + if let Some((index, _)) = self.get_focused() { + Some(index) + } else { + None + } + } + + /// Get the currently focused video, if it exists. + #[must_use] + pub(crate) fn get_focused_mut(&mut self) -> Option<(PlaylistIndex, &mut Video)> { + self.videos + .iter_mut() + .enumerate() + .find(|(_, v)| v.is_focused()) + .map(|(index, video)| (PlaylistIndex::from(index), video)) + } + + /// Get the currently focused video, if it exists. + #[must_use] + pub(crate) fn get_focused(&self) -> Option<(PlaylistIndex, &Video)> { + self.videos + .iter() + .enumerate() + .find(|(_, v)| v.is_focused()) + .map(|(index, video)| (PlaylistIndex::from(index), video)) + } +} diff --git a/crates/yt/src/storage/db/get/subscription.rs b/crates/yt/src/storage/db/get/subscription.rs new file mode 100644 index 0000000..1d0b660 --- /dev/null +++ b/crates/yt/src/storage/db/get/subscription.rs @@ -0,0 +1,65 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::collections::HashMap; + +use crate::{ + app::App, + storage::db::subscription::{Subscription, Subscriptions}, +}; + +use anyhow::Result; +use sqlx::query; +use url::Url; + +impl Subscriptions { + /// Get a list of subscriptions + pub(crate) async fn get(app: &App) -> Result<Self> { + let raw_subs = query!( + " + SELECT * + FROM subscriptions; + " + ) + .fetch_all(&app.database) + .await?; + + let subscriptions: HashMap<String, Subscription> = raw_subs + .into_iter() + .map(|sub| { + ( + sub.name.clone(), + Subscription::new( + sub.name, + Url::parse(&sub.url).expect("It was an URL, when we inserted it."), + if sub.is_active == 1 { + true + } else if sub.is_active == 0 { + false + } else { + unreachable!("These are the only two options") + }, + ), + ) + }) + .collect(); + + Ok(Subscriptions(subscriptions)) + } + + pub(crate) fn remove_inactive(self) -> Self { + Self( + self.0 + .into_iter() + .filter(|(_, sub)| sub.is_active) + .collect(), + ) + } +} diff --git a/crates/yt/src/storage/db/get/txn_log.rs b/crates/yt/src/storage/db/get/txn_log.rs new file mode 100644 index 0000000..1a6df2c --- /dev/null +++ b/crates/yt/src/storage/db/get/txn_log.rs @@ -0,0 +1,43 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{ + app::App, + storage::db::{insert::Committable, txn_log::TxnLog, video::TimeStamp}, +}; + +use anyhow::Result; +use sqlx::query; + +impl<O: Committable> TxnLog<O> { + /// Get the log of all operations that have been performed. + pub(crate) async fn get(app: &App) -> Result<Self> { + let raw_ops = query!( + " + SELECT * + FROM txn_log + ORDER BY timestamp ASC; + " + ) + .fetch_all(&app.database) + .await?; + + let inner = raw_ops + .into_iter() + .filter_map(|raw_op| { + serde_json::from_str(&raw_op.operation) + .map(|parsed_op| (TimeStamp::from_secs(raw_op.timestamp), parsed_op)) + .ok() + }) + .collect(); + + Ok(TxnLog::new(inner)) + } +} diff --git a/crates/yt/src/storage/db/get/video/mod.rs b/crates/yt/src/storage/db/get/video/mod.rs new file mode 100644 index 0000000..69adb6b --- /dev/null +++ b/crates/yt/src/storage/db/get/video/mod.rs @@ -0,0 +1,261 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{fs::File, path::PathBuf}; + +use anyhow::{Context, Result, bail}; +use log::debug; +use sqlx::query; +use yt_dlp::{info_json::InfoJson, json_cast, json_try_get}; + +use crate::{ + app::App, + storage::db::video::{ + Video, VideoStatus, VideoStatusMarker, + comments::{Comments, raw::RawComment}, + video_from_record, + }, +}; + +impl Video { + /// Returns to next video which should be downloaded. This respects the priority assigned by select. + /// It does not return videos, which are already downloaded. + /// + /// # Panics + /// Only if assertions fail. + pub(crate) async fn next_to_download(app: &App) -> Result<Option<Self>> { + let status = VideoStatus::Watch.as_marker().as_db_integer(); + + // NOTE: The ORDER BY statement should be the same as the one in [`in_states`]. <2024-08-22> + let result = query!( + r#" + SELECT * + FROM videos + WHERE status = ? AND cache_path IS NULL + ORDER BY priority DESC, publish_date DESC + LIMIT 1; + "#, + status + ) + .fetch_one(&app.database) + .await; + + if let Err(sqlx::Error::RowNotFound) = result { + Ok(None) + } else { + let base = result?; + + Ok(Some(video_from_record!(base))) + } + } + + /// Returns the description of the current video. + /// The returned description will be set to `<No description>` in the absence of one. + /// + /// # Errors + /// If no current video exists. + /// + /// # Panics + /// If the current video lacks the `info.json` file. + pub(crate) async fn get_current_description(app: &App) -> Result<String> { + let Some(currently_playing_video) = Video::currently_focused(app).await? else { + bail!("Could not find a currently playing video!"); + }; + + let info_json = ¤tly_playing_video.get_info_json()?.expect( + "A currently *playing* must be cached. \ + And thus the info.json should be available.", + ); + + let description = json_try_get!(info_json, "description", as_str) + .unwrap_or("<No description>") + .to_owned(); + + Ok(description) + } + + /// Returns the comments of the current video. + /// The returned [`Comments`] will be empty in the absence of comments. + /// + /// # Errors + /// If no current video exists. + /// + /// # Panics + /// If the current video lacks the `info.json` file. + pub(crate) async fn get_current_comments(app: &App) -> Result<Comments> { + let Some(currently_playing_video) = Video::currently_focused(app).await? else { + bail!("Could not find a currently playing video!"); + }; + + let info_json = ¤tly_playing_video.get_info_json()?.expect( + "A currently *playing* video must be cached. \ + And thus the info.json should be available.", + ); + + let raw_comments = if let Some(comments) = json_try_get!(info_json, "comments", as_array) { + comments + .iter() + .cloned() + .map(serde_json::from_value) + .collect::<Result<Vec<RawComment>, _>>()? + } else { + // TODO(@bpeetz): We could display a `<No-comments>` here. <2025-07-15> + + bail!( + "The video ('{}') does not have comments!", + json_try_get!(info_json, "title", as_str).unwrap_or("<No Title>") + ) + }; + + Ok(Comments::from_raw(raw_comments)) + } + + /// Optionally returns the video that is currently focused. + /// + /// # Panics + /// Only if assertions fail. + pub(crate) async fn currently_focused(app: &App) -> Result<Option<Self>> { + let status = VideoStatusMarker::Cached.as_db_integer(); + + let result = query!( + r#" + SELECT * + FROM videos + WHERE status = ? AND is_focused = 1 + "#, + status + ) + .fetch_one(&app.database) + .await; + + if let Err(sqlx::Error::RowNotFound) = result { + Ok(None) + } else { + let base = result?; + + Ok(Some(video_from_record!(base))) + } + } + + /// Calculate the [`info_json`] location on-disk for this video. + /// + /// Will return [`None`], if the video does not have an downloaded [`info_json`] + pub(crate) fn info_json_path(&self) -> Result<Option<PathBuf>> { + if let VideoStatus::Cached { mut cache_path, .. } = self.status.clone() { + if !cache_path.set_extension("info.json") { + bail!( + "Failed to change path extension to 'info.json': {}", + cache_path.display() + ); + } + + Ok(Some(cache_path)) + } else { + Ok(None) + } + } + + /// Fetch the [`info_json`], downloaded on-disk for this video. + /// + /// Will return [`None`], if the video does not have an downloaded [`info_json`] + pub(crate) fn get_info_json(&self) -> Result<Option<InfoJson>> { + if let Some(path) = self.info_json_path()? { + let info_json_string = File::open(path)?; + let info_json = serde_json::from_reader(&info_json_string)?; + + Ok(Some(info_json)) + } else { + Ok(None) + } + } + + /// Returns this videos `is_focused` flag if it is set. + /// + /// Will return `false` for not-downloaded videos. + pub(crate) fn is_focused(&self) -> bool { + if let VideoStatus::Cached { is_focused, .. } = &self.status { + *is_focused + } else { + false + } + } + + /// Returns the videos that are in the `allowed_states`. + /// + /// # Panics + /// Only, if assertions fail. + pub(crate) async fn in_states( + app: &App, + allowed_states: &[VideoStatusMarker], + ) -> Result<Vec<Video>> { + fn test(all_states: &[VideoStatusMarker], check: VideoStatusMarker) -> Option<i64> { + if all_states.contains(&check) { + Some(check.as_db_integer()) + } else { + None + } + } + fn states_to_string(allowed_states: &[VideoStatusMarker]) -> String { + let mut states = allowed_states + .iter() + .fold(String::from("&["), |mut acc, state| { + acc.push_str(state.as_str()); + acc.push_str(", "); + acc + }); + states = states.trim().to_owned(); + states = states.trim_end_matches(',').to_owned(); + states.push(']'); + states + } + + debug!( + "Fetching videos in the states: '{}'", + states_to_string(allowed_states) + ); + let active_pick: Option<i64> = test(allowed_states, VideoStatusMarker::Pick); + let active_watch: Option<i64> = test(allowed_states, VideoStatusMarker::Watch); + let active_cached: Option<i64> = test(allowed_states, VideoStatusMarker::Cached); + let active_watched: Option<i64> = test(allowed_states, VideoStatusMarker::Watched); + let active_drop: Option<i64> = test(allowed_states, VideoStatusMarker::Drop); + let active_dropped: Option<i64> = test(allowed_states, VideoStatusMarker::Dropped); + + // NOTE: The ORDER BY statement should be the same as the one in [`next_to_download`]. <2024-08-22> + let videos = query!( + r" + SELECT * + FROM videos + WHERE status IN (?,?,?,?,?,?) + ORDER BY priority DESC, publish_date DESC; + ", + active_pick, + active_watch, + active_cached, + active_watched, + active_drop, + active_dropped, + ) + .fetch_all(&app.database) + .await + .with_context(|| { + format!( + "Failed to query videos with states: '{}'", + states_to_string(allowed_states) + ) + })?; + + let real_videos: Vec<Video> = videos + .iter() + .map(|base| -> Video { video_from_record!(base) }) + .collect(); + + Ok(real_videos) + } +} diff --git a/crates/yt/src/storage/db/insert/maintenance.rs b/crates/yt/src/storage/db/insert/maintenance.rs new file mode 100644 index 0000000..d87c1ae --- /dev/null +++ b/crates/yt/src/storage/db/insert/maintenance.rs @@ -0,0 +1,38 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{ + app::App, + storage::db::{ + insert::Operations, + video::{Video, VideoStatus, VideoStatusMarker}, + }, +}; + +use anyhow::Result; + +/// Remove the downloaded paths from videos in the db, that no longer exist on the file system. +pub(crate) async fn clear_stale_downloaded_paths(app: &App) -> Result<()> { + let mut cached_videos = Video::in_states(app, &[VideoStatusMarker::Cached]).await?; + + let mut ops = Operations::new("DbMaintain: init"); + for vid in &mut cached_videos { + if let VideoStatus::Cached { cache_path, .. } = &vid.status { + if !cache_path.exists() { + vid.remove_download_path(&mut ops); + } + } else { + unreachable!("We only asked for cached videos.") + } + } + ops.commit(app).await?; + + Ok(()) +} diff --git a/crates/yt/src/storage/db/insert/mod.rs b/crates/yt/src/storage/db/insert/mod.rs new file mode 100644 index 0000000..3458608 --- /dev/null +++ b/crates/yt/src/storage/db/insert/mod.rs @@ -0,0 +1,115 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::mem; + +use crate::app::App; + +use anyhow::Result; +use chrono::Utc; +use log::{debug, trace}; +use serde::{Serialize, de::DeserializeOwned}; +use sqlx::{SqliteConnection, query}; + +pub(crate) mod maintenance; +pub(crate) mod playlist; +pub(crate) mod subscription; +pub(crate) mod video; + +pub(crate) trait Committable: + Sized + std::fmt::Debug + Serialize + DeserializeOwned +{ + async fn commit(self, txn: &mut SqliteConnection) -> Result<()>; +} + +#[derive(Debug)] +pub(crate) struct Operations<O: Committable> { + name: &'static str, + ops: Vec<O>, +} + +impl<O: Committable> Default for Operations<O> { + fn default() -> Self { + Self::new("<default impl>") + } +} + +impl<O: Committable> Operations<O> { + #[must_use] + pub(crate) fn new(name: &'static str) -> Self { + Self { + name, + ops: Vec::new(), + } + } + + pub(crate) async fn commit(mut self, app: &App) -> Result<()> { + let ops = mem::take(&mut self.ops); + + if ops.is_empty() { + return Ok(()); + } + + trace!("Begin commit of {}", self.name); + let mut txn = app.database.begin().await?; + + for op in ops { + trace!("Commiting operation: {op:?}"); + add_operation_to_txn_log(&op, &mut txn).await?; + op.commit(&mut txn).await?; + } + + txn.commit().await?; + trace!("End commit of {}", self.name); + + Ok(()) + } + + pub(crate) fn push(&mut self, op: O) { + self.ops.push(op); + } +} + +impl<O: Committable> Drop for Operations<O> { + fn drop(&mut self) { + assert!( + self.ops.is_empty(), + "Trying to drop uncommitted operations (name: {}) ({:#?}). This is a bug.", + self.name, + self.ops + ); + } +} + +async fn add_operation_to_txn_log<O: Committable>( + operation: &O, + txn: &mut SqliteConnection, +) -> Result<()> { + debug!("Adding operation to txn log: {operation:?}"); + + let now = Utc::now().timestamp(); + let operation = serde_json::to_string(&operation).expect("should be serializable"); + + query!( + r#" + INSERT INTO txn_log ( + timestamp, + operation + ) + VALUES (?, ?); + "#, + now, + operation, + ) + .execute(txn) + .await?; + + Ok(()) +} diff --git a/crates/yt/src/storage/db/insert/playlist.rs b/crates/yt/src/storage/db/insert/playlist.rs new file mode 100644 index 0000000..4d3e140 --- /dev/null +++ b/crates/yt/src/storage/db/insert/playlist.rs @@ -0,0 +1,222 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{cmp::Ordering, time::Duration}; + +use anyhow::{Context, Result}; +use colors::Colorize; +use libmpv2::Mpv; +use log::{debug, trace}; + +use crate::{ + app::App, + storage::db::{ + insert::{Operations, video::Operation}, + playlist::{Playlist, PlaylistIndex}, + video::VideoStatus, + }, +}; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum VideoTransition { + Watched, + Picked, +} + +impl Playlist { + pub(crate) fn mark_current_done( + &mut self, + app: &App, + mpv: &Mpv, + new_state: VideoTransition, + ops: &mut Operations<Operation>, + ) -> Result<()> { + let (current_index, current_video) = self + .get_focused_mut() + .expect("This should be some at this point"); + + debug!( + "Playlist handler will mark video '{}' {:?}.", + current_video.title, new_state + ); + + match new_state { + VideoTransition::Watched => current_video.set_watched(ops), + VideoTransition::Picked => current_video.set_status(VideoStatus::Pick, ops), + } + + self.save_watch_progress(mpv, current_index, ops); + + self.videos.remove(Into::<usize>::into(current_index)); + + { + // Decide which video to mark focused now. + let index = usize::from(current_index); + let playlist_length = self.len(); + + if playlist_length == 0 { + // There are no new videos to mark focused. + } else { + let index = match index.cmp(&playlist_length) { + Ordering::Greater => { + unreachable!( + "The index '{index}' cannot exceed the \ + playlist length '{playlist_length}' as indices are 0 based." + ); + } + Ordering::Less => { + // The index is still valid. + // Therefore, we keep the user at this position. + index + } + Ordering::Equal => { + // The index is pointing to the end of the playlist. We could either go the second + // to last entry (i.e., one entry back) or wrap around to the start. + // We wrap around. + 0 + } + }; + + let next = self + .get_mut(PlaylistIndex::from(index)) + .expect("We checked that the index is still good"); + next.set_focused(true, ops); + } + + // Tell mpv about our decision. + self.resync_with_mpv(app, mpv)?; + } + + Ok(()) + } + + /// Sync the mpv playlist with this playlist. + pub(crate) fn resync_with_mpv(&self, app: &App, mpv: &Mpv) -> Result<()> { + fn get_playlist_count(mpv: &Mpv) -> Result<usize> { + mpv.get_property::<i64>("playlist/count") + .context("Failed to get mpv playlist len") + .map(|count| { + usize::try_from(count).expect("The playlist_count should always be positive") + }) + } + + if get_playlist_count(mpv)? != 0 { + // We could also use `loadlist`, but that would require use to start a unix socket or even + // write all the video paths to a file beforehand + mpv.command("playlist-clear", &[])?; + mpv.command("playlist-remove", &["current"])?; + } + + assert_eq!( + get_playlist_count(mpv)?, + 0, + "The playlist should be empty at this point." + ); + + debug!("MpvReload: Adding {} videos to playlist.", self.len()); + + self.videos + .iter() + .enumerate() + .try_for_each(|(index, video)| { + let VideoStatus::Cached { + cache_path, + is_focused, + } = &video.status + else { + unreachable!("All of the videos in a playlist are cached"); + }; + + let options = format!( + "speed={},start={}", + video + .playback_speed + .unwrap_or(app.config.select.playback_speed), + i64::try_from(video.watch_progress.as_secs()) + .expect("This should not overflow"), + ); + + mpv.command( + "loadfile", + &[ + cache_path.to_str().with_context(|| { + format!( + "Failed to parse the video cache path ('{}') as valid utf8", + cache_path.display() + ) + })?, + "append-play", + "-1", // Not used for `append-play`, but needed for the next args to take effect. + options.as_str(), + ], + )?; + + if *is_focused { + debug!("MpvReload: Setting playlist position to {index}"); + mpv.set_property("playlist-pos", index.to_string().as_str())?; + } + + Ok::<(), anyhow::Error>(()) + })?; + + Ok(()) + } + + pub(crate) fn save_current_watch_progress( + &mut self, + mpv: &Mpv, + ops: &mut Operations<Operation>, + ) { + let (index, _) = self + .get_focused_mut() + .expect("This should be some at this point"); + + self.save_watch_progress(mpv, index, ops); + } + + /// Saves the `watch_progress` of a video at the index. + pub(crate) fn save_watch_progress( + &mut self, + mpv: &Mpv, + at: PlaylistIndex, + ops: &mut Operations<Operation>, + ) { + let current_video = self + .get_mut(at) + .expect("We should never produce invalid playlist indices"); + + let watch_progress = match mpv.get_property::<i64>("time-pos") { + Ok(time) => u64::try_from(time) + .expect("This conversion should never fail as the `time-pos` property is positive"), + Err(err) => { + // We cannot hard error here, as we would open us to an race condition between mpv + // changing the current video and we saving it. + trace!( + "While trying to save the watch progress for the current video: \ + Failed to get the watchprogress of the currently playling video: \ + (This is probably expected, nevertheless showing the raw error) \ + {err}" + ); + + return; + } + }; + + let watch_progress = Duration::from_secs(watch_progress); + + debug!( + "Setting the watch progress for the current_video '{}' to {}s", + current_video.title_fmt().render(false), + watch_progress.as_secs(), + ); + + current_video.set_watch_progress(watch_progress, ops); + } +} diff --git a/crates/yt/src/storage/db/insert/subscription.rs b/crates/yt/src/storage/db/insert/subscription.rs new file mode 100644 index 0000000..54409a9 --- /dev/null +++ b/crates/yt/src/storage/db/insert/subscription.rs @@ -0,0 +1,128 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::storage::db::{ + insert::{Committable, Operations}, + subscription::{Subscription, Subscriptions}, +}; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use sqlx::query; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) enum Operation { + Add(Subscription), + Remove(Subscription), + SetIsActive { + target: Subscription, + is_active: bool, + }, +} + +impl Committable for Operation { + async fn commit(self, txn: &mut sqlx::SqliteConnection) -> Result<()> { + match self { + Operation::Add(subscription) => { + let url = subscription.url.as_str(); + + query!( + " + INSERT INTO subscriptions ( + name, + url + ) VALUES (?, ?); + ", + subscription.name, + url + ) + .execute(txn) + .await?; + + println!( + "Subscribed to '{}' at '{}'", + subscription.name, subscription.url + ); + Ok(()) + } + Operation::Remove(subscription) => { + let output = query!( + " + DELETE FROM subscriptions + WHERE name = ? + ", + subscription.name, + ) + .execute(txn) + .await?; + + assert_eq!( + output.rows_affected(), + 1, + "The removed subscription query did effect more (or less) than one row. This is a bug." + ); + + println!( + "Unsubscribed from '{}' at '{}'", + subscription.name, subscription.url + ); + + Ok(()) + } + Operation::SetIsActive { target, is_active } => { + query!( + " + UPDATE subscriptions + SET is_active = ? + WHERE name = ?; + ", + is_active, + target.name + ) + .execute(txn) + .await?; + + println!( + "Marked '{}' as '{}'", + target.name, + if is_active { "active" } else { "inactive" } + ); + Ok(()) + } + } + } +} + +impl Subscription { + pub(crate) fn add(self, ops: &mut Operations<Operation>) { + ops.push(Operation::Add(self)); + } + + pub(crate) fn remove(self, ops: &mut Operations<Operation>) { + ops.push(Operation::Remove(self)); + } + + pub(crate) fn set_is_active(self, is_active: bool, ops: &mut Operations<Operation>) { + if self.is_active != is_active { + ops.push(Operation::SetIsActive { + target: self, + is_active, + }); + } + } +} + +impl Subscriptions { + pub(crate) fn remove(self, ops: &mut Operations<Operation>) { + for sub in self.0.into_values() { + ops.push(Operation::Remove(sub)); + } + } +} diff --git a/crates/yt/src/storage/db/insert/video/mod.rs b/crates/yt/src/storage/db/insert/video/mod.rs new file mode 100644 index 0000000..da62e37 --- /dev/null +++ b/crates/yt/src/storage/db/insert/video/mod.rs @@ -0,0 +1,610 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + path::{Path, PathBuf}, + time, +}; + +use crate::storage::db::{ + extractor_hash::ExtractorHash, + insert::{Committable, Operations}, + video::{Priority, Video, VideoStatus, VideoStatusMarker}, +}; + +use anyhow::{Context, Result}; +use chrono::Utc; +use log::debug; +use serde::{Deserialize, Serialize}; +use sqlx::query; +use tokio::fs; + +use super::super::video::TimeStamp; + +const fn is_focused_to_value(is_focused: bool) -> Option<i8> { + if is_focused { Some(1) } else { None } +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) enum Operation { + Add { + description: Option<String>, + title: String, + parent_subscription_name: Option<String>, + thumbnail_url: Option<String>, + url: String, + extractor_hash: String, + status: i64, + cache_path: Option<String>, + is_focused: Option<i8>, + duration: Option<f64>, + last_status_change: i64, + publish_date: Option<i64>, + watch_progress: i64, + }, + // TODO(@bpeetz): Could both the {`Set`,`Remove`}`DownloadPath` ops, be merged into SetStatus + // {`Cached`,`Watch`}? <2025-07-14> + SetDownloadPath { + video: ExtractorHash, + path: PathBuf, + }, + RemoveDownloadPath { + video: ExtractorHash, + }, + SetStatus { + video: ExtractorHash, + status: VideoStatus, + }, + SetPriority { + video: ExtractorHash, + priority: Priority, + }, + SetPlaybackSpeed { + video: ExtractorHash, + playback_speed: f64, + }, + SetSubtitleLangs { + video: ExtractorHash, + subtitle_langs: String, + }, + SetWatchProgress { + video: ExtractorHash, + watch_progress: time::Duration, + }, + SetIsFocused { + video: ExtractorHash, + is_focused: bool, + }, +} + +impl Committable for Operation { + #[allow(clippy::too_many_lines)] + async fn commit(self, txn: &mut sqlx::SqliteConnection) -> Result<()> { + match self { + Operation::SetDownloadPath { video, path } => { + debug!("Setting cache path from '{video}' to '{}'", path.display()); + + let path_str = path.display().to_string(); + let extractor_hash = video.hash().to_string(); + let status = VideoStatusMarker::Cached.as_db_integer(); + + query!( + r#" + UPDATE videos + SET cache_path = ?, status = ? + WHERE extractor_hash = ?; + "#, + path_str, + status, + extractor_hash + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::RemoveDownloadPath { video } => { + let extractor_hash = video.hash().to_string(); + let status = VideoStatus::Watch.as_marker().as_db_integer(); + + let old = video.get(&mut *txn).await?; + + debug!("Deleting download path of '{video}' ({}).", old.title); + + if let VideoStatus::Cached { cache_path, .. } = &old.status { + if let Ok(true) = cache_path.try_exists() { + fs::remove_file(cache_path).await?; + } + + { + let info_json_path = old.info_json_path()?.expect("Is downloaded"); + + if let Ok(true) = info_json_path.try_exists() { + fs::remove_file(info_json_path).await?; + } + } + + { + if old.subtitle_langs.is_some() { + // TODO(@bpeetz): Also clean-up the downloaded subtitle files. <2025-07-05> + } + } + } else { + unreachable!( + "A video cannot have a download path deletion \ + queued without being marked as Cached." + ); + } + + query!( + r#" + UPDATE videos + SET cache_path = NULL, status = ?, is_focused = ? + WHERE extractor_hash = ?; + "#, + status, + None::<i32>, + extractor_hash + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetStatus { video, status } => { + let extractor_hash = video.hash().to_string(); + + let old = video.get(&mut *txn).await?; + + let old_marker = old.status.as_marker(); + + let (cache_path, is_focused) = { + fn cache_path_to_string(path: &Path) -> Result<String> { + Ok(path + .to_str() + .with_context(|| { + format!( + "Failed to parse cache path ('{}') as utf8 string", + path.display() + ) + })? + .to_owned()) + } + + if let VideoStatus::Cached { + cache_path, + is_focused, + } = &status + { + ( + Some(cache_path_to_string(cache_path)?), + is_focused_to_value(*is_focused), + ) + } else { + (None, None) + } + }; + + let new_status = status.as_marker(); + + assert_ne!( + old_marker, new_status, + "We should have never generated this operation" + ); + + let now = Utc::now().timestamp(); + + debug!( + "Changing status of video ('{}' {extractor_hash}) \ + from {old_marker:#?} to {new_status:#?}.", + old.title + ); + + let new_status = new_status.as_db_integer(); + query!( + r#" + UPDATE videos + SET status = ?, last_status_change = ?, cache_path = ?, is_focused = ? + WHERE extractor_hash = ?; + "#, + new_status, + now, + cache_path, + is_focused, + extractor_hash, + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetPriority { video, priority } => { + let extractor_hash = video.hash().to_string(); + + let new_priority = priority.as_db_integer(); + query!( + r#" + UPDATE videos + SET priority = ? + WHERE extractor_hash = ?; + "#, + new_priority, + extractor_hash + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetWatchProgress { + video, + watch_progress, + } => { + let video_extractor_hash = video.hash().to_string(); + let watch_progress = i64::try_from(watch_progress.as_secs()) + .expect("This should never exceed its bounds"); + + query!( + r#" + UPDATE videos + SET watch_progress = ? + WHERE extractor_hash = ?; + "#, + watch_progress, + video_extractor_hash, + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetPlaybackSpeed { + video, + playback_speed, + } => { + let extractor_hash = video.hash().to_string(); + + query!( + r#" + UPDATE videos + SET playback_speed = ? + WHERE extractor_hash = ?; + "#, + playback_speed, + extractor_hash + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetSubtitleLangs { + video, + subtitle_langs, + } => { + let extractor_hash = video.hash().to_string(); + + query!( + r#" + UPDATE videos + SET subtitle_langs = ? + WHERE extractor_hash = ?; + "#, + subtitle_langs, + extractor_hash + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::SetIsFocused { video, is_focused } => { + debug!("Set is_focused of video: '{video}' to {is_focused}"); + let new_hash = video.hash().to_string(); + let new_is_focused = is_focused_to_value(is_focused); + + query!( + r#" + UPDATE videos + SET is_focused = ? + WHERE extractor_hash = ?; + "#, + new_is_focused, + new_hash, + ) + .execute(txn) + .await?; + + Ok(()) + } + Operation::Add { + parent_subscription_name, + thumbnail_url, + url, + extractor_hash, + status, + cache_path, + is_focused, + duration, + last_status_change, + publish_date, + watch_progress, + description, + title, + } => { + query!( + r#" + INSERT INTO videos ( + description, + duration, + extractor_hash, + is_focused, + last_status_change, + parent_subscription_name, + publish_date, + status, + thumbnail_url, + title, + url, + watch_progress, + cache_path + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + "#, + description, + duration, + extractor_hash, + is_focused, + last_status_change, + parent_subscription_name, + publish_date, + status, + thumbnail_url, + title, + url, + watch_progress, + cache_path, + ) + .execute(txn) + .await?; + + Ok(()) + } + } + } +} + +impl Video { + /// Add this in-memory video to the db. + pub(crate) fn add(self, ops: &mut Operations<Operation>) -> Result<Self> { + let description = self.description.clone(); + let title = self.title.clone(); + let parent_subscription_name = self.parent_subscription_name.clone(); + + let thumbnail_url = self.thumbnail_url.as_ref().map(ToString::to_string); + + let url = self.url.to_string(); + let extractor_hash = self.extractor_hash.hash().to_string(); + + let status = self.status.as_marker().as_db_integer(); + let (cache_path, is_focused) = if let VideoStatus::Cached { + cache_path, + is_focused, + } = &self.status + { + ( + Some( + cache_path + .to_str() + .with_context(|| { + format!( + "Failed to prase cache path '{}' as utf-8 string", + cache_path.display() + ) + })? + .to_string(), + ), + is_focused_to_value(*is_focused), + ) + } else { + (None, None) + }; + + let duration: Option<f64> = self.duration.as_secs_f64(); + let last_status_change: i64 = self.last_status_change.as_secs(); + let publish_date: Option<i64> = self.publish_date.map(TimeStamp::as_secs); + let watch_progress: i64 = + i64::try_from(self.watch_progress.as_secs()).expect("This should never exceed a u32"); + + ops.push(Operation::Add { + description, + title, + parent_subscription_name, + thumbnail_url, + url, + extractor_hash, + status, + cache_path, + is_focused, + duration, + last_status_change, + publish_date, + watch_progress, + }); + + Ok(self) + } + + /// Set the download path of a video. + /// + /// # Note + /// This will also set the status to `Cached`. + pub(crate) fn set_download_path(&mut self, path: &Path, ops: &mut Operations<Operation>) { + if let VideoStatus::Cached { cache_path, .. } = &mut self.status { + if cache_path != path { + // Update the in-memory video. + path.clone_into(cache_path); + + ops.push(Operation::SetDownloadPath { + video: self.extractor_hash, + path: path.to_owned(), + }); + } + } else { + self.status = VideoStatus::Cached { + cache_path: path.to_owned(), + is_focused: false, + }; + + ops.push(Operation::SetDownloadPath { + video: self.extractor_hash, + path: path.to_owned(), + }); + } + } + + /// Remove the download path of a video. + /// + /// # Note + /// This will also set the status to `Watch`. + /// + /// # Panics + /// If the status is not `Cached`. + pub(crate) fn remove_download_path(&mut self, ops: &mut Operations<Operation>) { + if let VideoStatus::Cached { .. } = &mut self.status { + self.status = VideoStatus::Watch; + ops.push(Operation::RemoveDownloadPath { + video: self.extractor_hash, + }); + } else { + unreachable!("Can only remove the path from a `Cached` video"); + } + } + + /// Update the `is_focused` flag of this video. + /// + /// # Note + /// It will only actually add operations, if the `is_focused` flag is different. + /// + /// # Panics + /// If the status is not `Cached`. + pub(crate) fn set_focused(&mut self, new_is_focused: bool, ops: &mut Operations<Operation>) { + if let VideoStatus::Cached { is_focused, .. } = &mut self.status { + if *is_focused != new_is_focused { + *is_focused = new_is_focused; + + ops.push(Operation::SetIsFocused { + video: self.extractor_hash, + is_focused: new_is_focused, + }); + } + } else { + unreachable!("Can only change `is_focused` on a Cached video."); + } + } + + /// Set the status of this video. + /// + /// # Note + /// This will not actually add any operations, if the new status equals the old one. + pub(crate) fn set_status(&mut self, status: VideoStatus, ops: &mut Operations<Operation>) { + if self.status != status { + status.clone_into(&mut self.status); + + ops.push(Operation::SetStatus { + video: self.extractor_hash, + status, + }); + } + } + + /// Set the priority of this video. + /// + /// # Note + /// This will not actually add any operations, if the new priority equals the old one. + pub(crate) fn set_priority(&mut self, priority: Priority, ops: &mut Operations<Operation>) { + if self.priority != priority { + self.priority = priority; + + ops.push(Operation::SetPriority { + video: self.extractor_hash, + priority, + }); + } + } + + /// Set the watch progress. + /// + /// # Note + /// This will not actually add any operations, + /// if the new watch progress equals the old one. + pub(crate) fn set_watch_progress( + &mut self, + watch_progress: time::Duration, + ops: &mut Operations<Operation>, + ) { + if self.watch_progress != watch_progress { + self.watch_progress = watch_progress; + + ops.push(Operation::SetWatchProgress { + video: self.extractor_hash, + watch_progress, + }); + } + } + + /// Set the playback speed of this video. + /// + /// # Note + /// This will not actually add any operations, if the new speed equals the old one. + pub(crate) fn set_playback_speed( + &mut self, + playback_speed: f64, + ops: &mut Operations<Operation>, + ) { + if self.playback_speed != Some(playback_speed) { + self.playback_speed = Some(playback_speed); + + ops.push(Operation::SetPlaybackSpeed { + video: self.extractor_hash, + playback_speed, + }); + } + } + + /// Set the subtitle langs of this video. + /// + /// # Note + /// This will not actually add any operations, if the new langs equal the old one. + pub(crate) fn set_subtitle_langs( + &mut self, + subtitle_langs: String, + ops: &mut Operations<Operation>, + ) { + if self.subtitle_langs.as_ref() != Some(&subtitle_langs) { + self.subtitle_langs = Some(subtitle_langs.clone()); + + ops.push(Operation::SetSubtitleLangs { + video: self.extractor_hash, + subtitle_langs, + }); + } + } + + /// Mark this video watched. + /// This will both set the status to `Watched` and the `cache_path` to Null. + /// + /// # Panics + /// Only if assertions fail. + pub(crate) fn set_watched(&mut self, ops: &mut Operations<Operation>) { + self.remove_download_path(ops); + self.set_status(VideoStatus::Watched, ops); + } +} diff --git a/crates/yt/src/storage/db/mod.rs b/crates/yt/src/storage/db/mod.rs new file mode 100644 index 0000000..926bab0 --- /dev/null +++ b/crates/yt/src/storage/db/mod.rs @@ -0,0 +1,18 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +pub(crate) mod get; +pub(crate) mod insert; + +pub(crate) mod extractor_hash; +pub(crate) mod playlist; +pub(crate) mod subscription; +pub(crate) mod txn_log; +pub(crate) mod video; diff --git a/crates/yt/src/storage/db/playlist/mod.rs b/crates/yt/src/storage/db/playlist/mod.rs new file mode 100644 index 0000000..7366e8e --- /dev/null +++ b/crates/yt/src/storage/db/playlist/mod.rs @@ -0,0 +1,59 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::ops::Add; + +use crate::storage::db::video::Video; + +/// Zero-based index into the internal playlist. +#[derive(Debug, Clone, Copy)] +pub(crate) struct PlaylistIndex(usize); + +impl From<PlaylistIndex> for usize { + fn from(value: PlaylistIndex) -> Self { + value.0 + } +} + +impl From<usize> for PlaylistIndex { + fn from(value: usize) -> Self { + Self(value) + } +} + +impl Add<usize> for PlaylistIndex { + type Output = Self; + + fn add(self, rhs: usize) -> Self::Output { + Self(self.0 + rhs) + } +} + +impl Add for PlaylistIndex { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +/// A representation of the internal Playlist +#[derive(Debug)] +pub(crate) struct Playlist { + pub(crate) videos: Vec<Video>, +} + +impl Playlist { + /// Returns the number of videos in the playlist + #[must_use] + pub(crate) fn len(&self) -> usize { + self.videos.len() + } +} diff --git a/crates/yt/src/storage/db/subscription.rs b/crates/yt/src/storage/db/subscription.rs new file mode 100644 index 0000000..39385b9 --- /dev/null +++ b/crates/yt/src/storage/db/subscription.rs @@ -0,0 +1,58 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::collections::HashMap; + +use anyhow::Result; +use log::debug; +use serde::{Deserialize, Serialize}; +use url::Url; +use yt_dlp::{json_cast, json_try_get, options::YoutubeDLOptions}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct Subscription { + /// The human readable name of this subscription + pub(crate) name: String, + + /// The URL this subscription subscribes to + pub(crate) url: Url, + + pub(crate) is_active: bool, +} + +impl Subscription { + #[must_use] + pub(crate) fn new(name: String, url: Url, is_active: bool) -> Self { + Self { + name, + url, + is_active, + } + } +} + +#[derive(Default, Debug)] +pub(crate) struct Subscriptions(pub(crate) HashMap<String, Subscription>); + +/// Check whether an URL could be used as a subscription URL +pub(crate) async fn check_url(url: Url) -> Result<bool> { + let yt_dlp = YoutubeDLOptions::new() + .set("playliststart", 1) + .set("playlistend", 10) + .set("noplaylist", false) + .set("extract_flat", "in_playlist") + .build()?; + + let info = yt_dlp.extract_info(&url, false, false)?; + + debug!("{info:#?}"); + + Ok(json_try_get!(info, "_type", as_str) == Some("playlist")) +} diff --git a/crates/yt/src/storage/db/txn_log.rs b/crates/yt/src/storage/db/txn_log.rs new file mode 100644 index 0000000..64884b0 --- /dev/null +++ b/crates/yt/src/storage/db/txn_log.rs @@ -0,0 +1,24 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::storage::db::{insert::Committable, video::TimeStamp}; + +pub(crate) struct TxnLog<O: Committable> { + inner: Vec<(TimeStamp, O)>, +} + +impl<O: Committable> TxnLog<O> { + pub(crate) fn new(inner: Vec<(TimeStamp, O)>) -> Self { + Self { inner } + } + pub(crate) fn inner(&self) -> &[(TimeStamp, O)] { + &self.inner + } +} diff --git a/crates/yt/src/comments/display.rs b/crates/yt/src/storage/db/video/comments/display.rs index 6166b2b..c372603 100644 --- a/crates/yt/src/comments/display.rs +++ b/crates/yt/src/storage/db/video/comments/display.rs @@ -1,6 +1,5 @@ // yt - A fully featured command line YouTube client // -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> // Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> // SPDX-License-Identifier: GPL-3.0-or-later // @@ -13,27 +12,22 @@ use std::fmt::Write; use chrono::{Local, TimeZone}; use chrono_humanize::{Accuracy, HumanTime, Tense}; +use colors::{Colorize, IntoCanvas}; -use crate::comments::comment::CommentExt; - -use super::comment::Comments; +use crate::{ + output::format_text, + storage::db::video::comments::{Comment, Comments}, +}; impl Comments { - pub fn render(&self, color: bool) -> String { - self.render_help(color).expect("This should never fail.") + pub(crate) fn render(&self, use_color: bool) -> String { + self.render_help(use_color) + .expect("This should never fail.") } - fn render_help(&self, color: bool) -> Result<String, std::fmt::Error> { - macro_rules! c { - ($color_str:expr, $write:ident, $color:expr) => { - if $color { - $write.write_str(concat!("\x1b[", $color_str, "m"))? - } - }; - } - + fn render_help(&self, use_color: bool) -> Result<String, std::fmt::Error> { fn format( - comment: &CommentExt, + comment: &Comment, f: &mut String, ident_count: u32, color: bool, @@ -43,14 +37,16 @@ impl Comments { f.write_str(ident)?; - if value.author_is_uploader { - c!("91;1", f, color); - } else { - c!("35", f, color); - } + write!( + f, + "{}", + if value.author_is_uploader { + (&value.author).bold().bright_red().render(color) + } else { + (&value.author).purple().render(color) + } + )?; - f.write_str(&value.author)?; - c!("0", f, color); if value.edited || value.is_favorited { f.write_str("[")?; if value.edited { @@ -65,7 +61,6 @@ impl Comments { f.write_str("]")?; } - c!("36;1", f, color); write!( f, " {}", @@ -76,17 +71,31 @@ impl Comments { .expect("This should be valid") ) .to_text_en(Accuracy::Rough, Tense::Past) + .bold() + .cyan() + .render(color) )?; - c!("0", f, color); - // c!("31;1", f); - // f.write_fmt(format_args!(" [{}]", comment.value.like_count))?; - // c!("0", f); + write!( + f, + " [{}]", + comment.value.like_count.bold().red().render(color) + )?; f.write_str(":\n")?; f.write_str(ident)?; - f.write_str(&value.text.replace('\n', &format!("\n{ident}")))?; + f.write_str( + &format_text( + value.text.trim(), + Some( + termsize::get().map_or(90, |ts| ts.cols) + - u16::try_from(ident_count).expect("Should never overflow"), + ), + ) + .trim() + .replace('\n', &format!("\n{ident}")), + )?; f.write_str("\n")?; if comment.replies.is_empty() { @@ -105,12 +114,12 @@ impl Comments { let mut f = String::new(); - if !&self.vec.is_empty() { - let mut children = self.vec.clone(); + if !&self.inner.is_empty() { + let mut children = self.inner.clone(); children.sort_by(|a, b| b.value.like_count.cmp(&a.value.like_count)); for child in children { - format(&child, &mut f, 0, color)?; + format(&child, &mut f, 0, use_color)?; } } Ok(f) diff --git a/crates/yt/src/storage/db/video/comments/mod.rs b/crates/yt/src/storage/db/video/comments/mod.rs new file mode 100644 index 0000000..41a03be --- /dev/null +++ b/crates/yt/src/storage/db/video/comments/mod.rs @@ -0,0 +1,202 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::mem; + +use regex::{Captures, Regex}; + +use crate::storage::db::video::comments::raw::{Parent, RawComment}; + +pub(crate) mod display; +pub(crate) mod raw; + +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Comment { + value: RawComment, + replies: Vec<Self>, +} + +#[derive(Debug, Default, PartialEq)] +pub(crate) struct Comments { + inner: Vec<Comment>, +} + +impl Comments { + pub(crate) fn from_raw(raw: Vec<RawComment>) -> Self { + let mut me = Self::default(); + + // Apply the parent -> child mapping yt provides us with. + for raw_comment in raw { + if let Parent::Id(id) = &raw_comment.parent { + me.insert(&(id.clone()), Comment::from(raw_comment)); + } else { + me.inner.push(Comment::from(raw_comment)); + } + } + + { + // Sort the final comments chronologically. + // This ensures that replies are matched with the comment they actually replied to and + // not a later comment from the same author. + for comment in &mut me.inner { + comment + .replies + .sort_by_key(|comment| comment.value.timestamp); + + for reply in &comment.replies { + assert!(reply.replies.is_empty()); + } + } + } + + { + let find_reply_indicator = + Regex::new(r"\u{200b}?(@[^\t\s]+)\u{200b}?").expect("This is hardcoded"); + + // Try to re-construct the replies for the reply comments. + for comment in &mut me.inner { + let previous_replies = mem::take(&mut comment.replies); + + let mut reply_tree = Comments::default(); + + for reply in previous_replies { + // We try to reconstruct the parent child relation ship by looking (naively) + // for a reply indicator. Currently, this is just the `@<some_name>`, as yt + // seems to insert that by default if you press `reply-to` in their clients. + // + // This follows these steps: + // - Does this reply have a “reply indicator”? + // - If yes, try to resolve the indicator. + // - If it is resolvable, add this reply to the [`Comment`] it resolved to. + // - If not, keep the comment as reply. + + if let Some(reply_indicator_matches) = + find_reply_indicator.captures(&reply.value.text.clone()) + { + // We found a reply indicator. + // First we traverse the current `reply_tree` in reversed order to find a + // match, than we check if the reply indicator matches the reply tree root + // and afterward we declare it unmatching and add it as toplevel. + + let reply_target_author = reply_indicator_matches + .get(1) + .expect("This should also exist") + .as_str(); + + if let Some(parent) = reply_tree.find_author_mut(reply_target_author) { + parent + .replies + .push(comment_from_reply(reply, &reply_indicator_matches)); + } else if comment.value.author == reply_target_author { + reply_tree + .add_toplevel(comment_from_reply(reply, &reply_indicator_matches)); + } else { + eprintln!( + "Failed to find a parent for ('{}') both directly \ + and via replies! The reply text was:\n'{}'\n", + reply_target_author, reply.value.text + ); + reply_tree.add_toplevel(reply); + } + } else { + // The comment text did not contain a reply indicator, so add it as + // toplevel. + reply_tree.add_toplevel(reply); + } + } + + comment.replies = reply_tree.inner; + } + } + + me + } + + fn add_toplevel(&mut self, value: Comment) { + self.inner.push(value); + } + + fn insert(&mut self, id: &str, value: Comment) { + let parent = self + .inner + .iter_mut() + .find(|c| c.value.id.id == id) + .expect("One of these should exist"); + + parent.replies.push(value); + } + + fn find_author_mut(&mut self, reply_target_author: &str) -> Option<&mut Comment> { + fn perform_check<'a>( + comment: &'a mut Comment, + reply_target_author: &str, + ) -> Option<&'a mut Comment> { + // TODO(@bpeetz): This is a workaround until rust has lexiographic lifetime support. <2025-07-18> + fn find_in_replies<'a>( + comment: &'a mut Comment, + reply_target_author: &str, + ) -> Option<&'a mut Comment> { + comment + .replies + .iter_mut() + .rev() + .find_map(|reply: &mut Comment| perform_check(reply, reply_target_author)) + } + let comment_author_matches_target = comment.value.author == reply_target_author; + + match find_in_replies(comment, reply_target_author) { + Some(_) => Some( + // PERFORMANCE(@bpeetz): We should not need to run this code twice. <2025-07-18> + find_in_replies(comment, reply_target_author) + .expect("We already had a Some result for this."), + ), + None if comment_author_matches_target => Some(comment), + None => None, + } + } + + for comment in self.inner.iter_mut().rev() { + if let Some(output) = perform_check(comment, reply_target_author) { + return Some(output); + } + } + + None + } +} +fn comment_from_reply(reply: Comment, reply_indicator_matches: &Captures<'_>) -> Comment { + Comment::from(RawComment { + text: { + // Remove the `@<some_name>` for the comment text. + let full_match = reply_indicator_matches + .get(0) + .expect("This will always exist"); + + let text = reply.value.text[0..full_match.start()].to_owned() + + &reply.value.text[full_match.end()..]; + + text.trim_matches(|c: char| c == '\u{200b}' || c == '\u{2060}' || c.is_whitespace()) + .to_owned() + }, + ..reply.value + }) +} + +impl From<RawComment> for Comment { + fn from(value: RawComment) -> Self { + Self { + value, + replies: vec![], + } + } +} diff --git a/crates/yt/src/storage/db/video/comments/raw.rs b/crates/yt/src/storage/db/video/comments/raw.rs new file mode 100644 index 0000000..3b7f40f --- /dev/null +++ b/crates/yt/src/storage/db/video/comments/raw.rs @@ -0,0 +1,87 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use serde::{Deserialize, Deserializer}; +use url::Url; + +#[derive(Debug, Deserialize, Clone, Eq, PartialEq, PartialOrd, Ord)] +#[serde(from = "String")] +#[serde(deny_unknown_fields)] +pub(crate) struct Id { + pub(crate) id: String, +} +impl From<String> for Id { + fn from(value: String) -> Self { + Self { + // Take the last element if the string is split with dots, otherwise take the full id + id: value.split('.').next_back().unwrap_or(&value).to_owned(), + } + } +} + +#[derive(Debug, Deserialize, Clone, Eq, PartialEq, PartialOrd, Ord)] +#[serde(from = "String")] +#[serde(deny_unknown_fields)] +pub(crate) enum Parent { + Root, + Id(String), +} + +impl From<String> for Parent { + fn from(value: String) -> Self { + if value == "root" { + Self::Root + } else { + Self::Id(value) + } + } +} + +#[derive(Debug, Deserialize, Clone, Eq, PartialEq, PartialOrd, Ord)] +#[allow(clippy::struct_excessive_bools)] +pub(crate) struct RawComment { + pub(crate) id: Id, + pub(crate) text: String, + #[serde(default = "zero")] + pub(crate) like_count: u32, + pub(crate) is_pinned: bool, + pub(crate) author_id: String, + #[serde(default = "unknown")] + pub(crate) author: String, + pub(crate) author_is_verified: bool, + pub(crate) author_thumbnail: Url, + pub(crate) parent: Parent, + #[serde(deserialize_with = "edited_from_time_text", alias = "_time_text")] + pub(crate) edited: bool, + // Can't also be deserialized, as it's already used in 'edited' + // _time_text: String, + pub(crate) timestamp: i64, + pub(crate) author_url: Option<Url>, + pub(crate) author_is_uploader: bool, + pub(crate) is_favorited: bool, +} + +fn unknown() -> String { + "<Unknown>".to_string() +} +fn zero() -> u32 { + 0 +} +fn edited_from_time_text<'de, D>(d: D) -> Result<bool, D::Error> +where + D: Deserializer<'de>, +{ + let s = String::deserialize(d)?; + if s.contains(" (edited)") { + Ok(true) + } else { + Ok(false) + } +} diff --git a/crates/yt/src/storage/db/video/comments/tests.rs b/crates/yt/src/storage/db/video/comments/tests.rs new file mode 100644 index 0000000..03e3597 --- /dev/null +++ b/crates/yt/src/storage/db/video/comments/tests.rs @@ -0,0 +1,249 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use pretty_assertions::assert_eq; +use url::Url; + +use crate::storage::db::video::comments::{ + Comment, Comments, RawComment, + raw::{Id, Parent}, +}; + +/// Generate both an [`expected`] and an [`input`] value from an expected comment expression. +macro_rules! mk_comments { + () => {{ + let input: Vec<RawComment> = vec![]; + let expected: Comments = Comments { + inner: vec![], + }; + + (input, expected) + }}; + + ( + $( + parent: $parent:expr, $actual_parent:ident, + ( + @ $name:ident : $comment:literal + $( + $reply_chain:tt + )* + ) + )+ + ) => {{ + let (nested_input, _) = mk_comments!( + $( + $( + parent: $parent, $name, + $reply_chain + )* + )+ + ); + + let mut input: Vec<RawComment> = vec![ + $( + mk_comments!(@to_raw input $name $comment $parent, $actual_parent) + ),+ + ]; + input.extend(nested_input); + + let expected: Comments = Comments { + inner: vec![ + $( + Comment { + value: mk_comments!(@to_raw expected $name $comment $parent, $actual_parent), + replies: { + let (_, nested_expected) = mk_comments!( + $( + parent: $parent, $name, + $reply_chain + )* + ); + + nested_expected.inner + }, + } + ),+ + ] + }; + + (input, expected) + }}; + ( + $( + ( + @ $name:ident : $comment:literal + $( + $reply_chain:tt + )* + ) + )+ + ) => {{ + let (nested_input, _) = mk_comments!( + $( + $( + parent: mk_comments!(@mk_id $name $comment), $name, + $reply_chain + )* + )+ + ); + + let mut input: Vec<RawComment> = vec![ + $( + mk_comments!(@to_raw input $name $comment) + ),+ + ]; + input.extend(nested_input); + + let expected: Comments = Comments { + inner: vec![ + $( + Comment { + value: mk_comments!(@to_raw expected $name $comment), + replies: { + let (_, nested_expected) = mk_comments!( + $( + parent: mk_comments!(@mk_id $name $comment), $name, + $reply_chain + )* + ); + + nested_expected.inner + }, + } + ),+ + ] + }; + + (input, expected) + }}; + + (@mk_id $name:ident $comment:literal) => {{ + use std::hash::{Hash, Hasher}; + + let input = format!("{}{}", stringify!($name), $comment); + + let mut digest = std::hash::DefaultHasher::new(); + input.hash(&mut digest); + Id { id: digest.finish().to_string() } + }}; + + (@to_raw $state:ident $name:ident $comment:literal $($parent:expr, $actual_parent:ident)?) => { + RawComment { + id: mk_comments!(@mk_id $name $comment), + text: mk_comments!(@mk_text $state $comment $(, $actual_parent)?), + like_count: 0, + is_pinned: false, + author_id: stringify!($name).to_owned(), + author: format!("@{}", stringify!($name)), + author_is_verified: false, + author_thumbnail: Url::from_file_path("/dev/null").unwrap(), + parent: mk_comments!(@mk_parent $($parent)?), + edited: false, + timestamp: 0, + author_url: None, + author_is_uploader: false, + is_favorited: false, + } + }; + + (@mk_parent) => { + Parent::Root + }; + (@mk_parent $parent:expr) => { + Parent::Id($parent.id) + }; + + (@mk_text input $text:expr) => { + $text.to_owned() + }; + (@mk_text input $text:expr, $actual_parent:ident) => { + format!("@{} {}", stringify!($actual_parent), $text) + }; + (@mk_text expected $text:expr $(, $_:tt)?) => { + $text.to_owned() + }; +} + +#[test] +fn test_comments_toplevel() { + let (input, expected) = mk_comments!( + (@kant: "I think, that using the results of an action to determine morality is flawed.") + (@hume: "I think, that we should use our feeling for morality more.") + (@lock: "I think, that we should rely on the sum of happiness caused by an action to determine it's morality.") + ); + + assert_eq!(Comments::from_raw(input), expected); +} + +#[test] +fn test_comments_replies_1_level() { + let (input, expected) = mk_comments!( + (@hume: "I think, that we should use our feeling for morality more." + (@kant: "This is so wrong! I shall now dedicate my next 7? years to writing books that prove this.") + (@lock: "It feels not very applicable, no? We should focus on something that can be used in the court of law!")) + ); + + assert_eq!( + Comments::from_raw(input).render(true), + expected.render(true) + ); +} + +#[test] +fn test_comments_replies_2_levels() { + let (input, expected) = mk_comments!( + (@singer: "We perform medical studies on animals; Children have lower or similar mental ability as these animals.." + (@singer: "Therefore, we should perform these studies on children instead, if we were to follow our own principals" + (@james: "This is ridiculous! I will not entertain this thought.") + (@singer: "Although one could also use this argument to argue for abortion _after_ birth."))) + ); + + assert_eq!( + Comments::from_raw(input).render(true), + expected.render(true) + ); +} + +#[test] +fn test_comments_replies_3_levels() { + let (input, expected) = mk_comments!( + (@singer: "We perform medical studies on animals; Children have lower or similar mental ability as these animals.." + (@singer: "Therefore, we should perform these studies on children instead, if we were to follow our own principals" + (@james: "This is ridiculous! I will not entertain this thought." + (@singer: "You know that I am not actually suggesting that? This is but a way to critizise the society")) + (@singer: "Although one could also use this argument to argue for abortion _after_ birth."))) + ); + + assert_eq!( + Comments::from_raw(input).render(true), + expected.render(true) + ); +} + +#[test] +fn test_comments_sub_answer_selection() { + let (input, expected) = mk_comments!( + (@coffeewolfproductions9113: "I mean, brothels and sex workers in of themselves are not a bad thing." + (@aikikaname6508: "probably not so much in the 50s, pre contraception") + (@as_ri1mb: "it’s an incredibly sad, degrading line of work, often resulting in self loathing and self-deletion." + (@coffeewolfproductions9113: "Are you speaking from experience?" + (@as_ri1mb: "what an immature response, as expected." + (@coffeewolfproductions9113: "I literally just asked if you were talking from experience."))))) + + ); + + eprintln!("{}", expected.render(true)); + + assert_eq!( + Comments::from_raw(input).render(true), + expected.render(true) + ); +} diff --git a/crates/yt/src/storage/video_database/mod.rs b/crates/yt/src/storage/db/video/mod.rs index 74d09f0..deeb82c 100644 --- a/crates/yt/src/storage/video_database/mod.rs +++ b/crates/yt/src/storage/db/video/mod.rs @@ -1,6 +1,5 @@ // yt - A fully featured command line YouTube client // -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> // Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> // SPDX-License-Identifier: GPL-3.0-or-later // @@ -9,55 +8,108 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -use std::{ - fmt::{Display, Write}, - path::PathBuf, - time::Duration, -}; +use std::{fmt::Display, path::PathBuf, time::Duration}; use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; use url::Url; -use crate::{ - app::App, select::selection_file::duration::MaybeDuration, - storage::video_database::extractor_hash::ExtractorHash, -}; +use crate::{select::duration::MaybeDuration, storage::db::extractor_hash::ExtractorHash}; -pub mod downloader; -pub mod extractor_hash; -pub mod get; -pub mod notify; -pub mod set; +pub(crate) mod comments; + +macro_rules! video_from_record { + ($record:expr) => { + $crate::storage::db::video::Video { + description: $record.description.clone(), + duration: $crate::select::duration::MaybeDuration::from_maybe_secs_f64( + $record.duration, + ), + extractor_hash: $crate::storage::db::extractor_hash::ExtractorHash::from_hash( + $record + .extractor_hash + .parse() + .expect("The db hash should be a valid blake3 hash"), + ), + last_status_change: $crate::storage::db::video::TimeStamp::from_secs( + $record.last_status_change, + ), + parent_subscription_name: $record.parent_subscription_name.clone(), + publish_date: $record + .publish_date + .map(|pd| $crate::storage::db::video::TimeStamp::from_secs(pd)), + status: { + let marker = + $crate::storage::db::video::VideoStatusMarker::from_db_integer($record.status); + let optional = if let Some(cache_path) = &$record.cache_path { + Some(( + std::path::PathBuf::from(cache_path), + if $record.is_focused == Some(1) { + true + } else { + false + }, + )) + } else { + None + }; + $crate::storage::db::video::VideoStatus::from_marker(marker, optional) + }, + thumbnail_url: if let Some(url) = &$record.thumbnail_url { + Some(url::Url::parse(url).expect("Parsing this as url should always work")) + } else { + None + }, + title: $record.title.clone(), + url: url::Url::parse(&$record.url).expect("Parsing this as url should always work"), + priority: $crate::storage::db::video::Priority::from($record.priority), + watch_progress: std::time::Duration::from_secs( + u64::try_from($record.watch_progress).expect("The record is positive i64"), + ), + subtitle_langs: $record.subtitle_langs.clone(), + playback_speed: $record.playback_speed, + } + }; +} +pub(crate) use video_from_record; #[derive(Debug, Clone)] -pub struct Video { - pub description: Option<String>, - pub duration: MaybeDuration, - pub extractor_hash: ExtractorHash, - pub last_status_change: TimeStamp, +pub(crate) struct Video { + pub(crate) description: Option<String>, + pub(crate) duration: MaybeDuration, + pub(crate) extractor_hash: ExtractorHash, + pub(crate) last_status_change: TimeStamp, /// The associated subscription this video was fetched from (null, when the video was `add`ed) - pub parent_subscription_name: Option<String>, - pub priority: Priority, - pub publish_date: Option<TimeStamp>, - pub status: VideoStatus, - pub thumbnail_url: Option<Url>, - pub title: String, - pub url: Url, + pub(crate) parent_subscription_name: Option<String>, + pub(crate) priority: Priority, + pub(crate) publish_date: Option<TimeStamp>, + pub(crate) status: VideoStatus, + pub(crate) thumbnail_url: Option<Url>, + pub(crate) title: String, + pub(crate) url: Url, /// The seconds the user has already watched the video - pub watch_progress: Duration, + pub(crate) watch_progress: Duration, + + /// Which subtitles to include, when downloading this video. + /// In the form of `lang1,lang2,lang3` (e.g. `en,de,sv`) + pub(crate) subtitle_langs: Option<String>, + + /// The playback speed to use, when watching this video. + /// Value is in percent, so 1 is 100%, 2.7 is 270%, and so on. + pub(crate) playback_speed: Option<f64>, } /// The priority of a [`Video`]. -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub struct Priority { +#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct Priority { value: i64, } impl Priority { /// Return the underlying value to insert that into the database #[must_use] - pub fn as_db_integer(&self) -> i64 { + pub(crate) fn as_db_integer(self) -> i64 { self.value } } @@ -74,25 +126,25 @@ impl Display for Priority { /// An UNIX time stamp. #[derive(Debug, Default, Clone, Copy)] -pub struct TimeStamp { +pub(crate) struct TimeStamp { value: i64, } impl TimeStamp { /// Return the seconds since the UNIX epoch for this [`TimeStamp`]. #[must_use] - pub fn as_secs(&self) -> i64 { + pub(crate) fn as_secs(self) -> i64 { self.value } /// Construct a [`TimeStamp`] from a count of seconds since the UNIX epoch. #[must_use] - pub fn from_secs(value: i64) -> Self { + pub(crate) fn from_secs(value: i64) -> Self { Self { value } } /// Construct a [`TimeStamp`] from the current time. #[must_use] - pub fn from_now() -> Self { + pub(crate) fn from_now() -> Self { Self { value: Utc::now().timestamp(), } @@ -107,49 +159,6 @@ impl Display for TimeStamp { } } -#[derive(Debug)] -pub struct VideoOptions { - pub yt_dlp: YtDlpOptions, - pub mpv: MpvOptions, -} -impl VideoOptions { - pub(crate) fn new(subtitle_langs: String, playback_speed: f64) -> Self { - let yt_dlp = YtDlpOptions { subtitle_langs }; - let mpv = MpvOptions { playback_speed }; - Self { yt_dlp, mpv } - } - - /// This will write out the options that are different from the defaults. - /// Beware, that this does not set the priority. - #[must_use] - pub fn to_cli_flags(self, app: &App) -> String { - let mut f = String::new(); - - if (self.mpv.playback_speed - app.config.select.playback_speed).abs() > f64::EPSILON { - write!(f, " --speed '{}'", self.mpv.playback_speed).expect("Works"); - } - if self.yt_dlp.subtitle_langs != app.config.select.subtitle_langs { - write!(f, " --subtitle-langs '{}'", self.yt_dlp.subtitle_langs).expect("Works"); - } - - f.trim().to_owned() - } -} - -#[derive(Debug, Clone, Copy)] -/// Additionally settings passed to mpv on watch -pub struct MpvOptions { - /// The playback speed. (1 is 100%, 2.7 is 270%, and so on) - pub playback_speed: f64, -} - -#[derive(Debug)] -/// Additionally configuration options, passed to yt-dlp on download -pub struct YtDlpOptions { - /// In the form of `lang1,lang2,lang3` (e.g. `en,de,sv`) - pub subtitle_langs: String, -} - /// # Video Lifetime (words in <brackets> are commands): /// <Pick> /// / \ @@ -158,8 +167,8 @@ pub struct YtDlpOptions { /// Cache // yt cache /// | /// Watched // yt watch -#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] -pub enum VideoStatus { +#[derive(Default, Serialize, Deserialize, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +pub(crate) enum VideoStatus { #[default] Pick, @@ -186,7 +195,10 @@ impl VideoStatus { /// # Panics /// Only if internal expectations fail. #[must_use] - pub fn from_marker(marker: VideoStatusMarker, optional: Option<(PathBuf, bool)>) -> Self { + pub(crate) fn from_marker( + marker: VideoStatusMarker, + optional: Option<(PathBuf, bool)>, + ) -> Self { match marker { VideoStatusMarker::Pick => Self::Pick, VideoStatusMarker::Watch => Self::Watch, @@ -204,26 +216,9 @@ impl VideoStatus { } } - /// Turn the [`VideoStatus`] to its internal parts. This is only really useful for the database - /// functions. - #[must_use] - pub fn to_parts_for_db(self) -> (VideoStatusMarker, Option<(PathBuf, bool)>) { - match self { - VideoStatus::Pick => (VideoStatusMarker::Pick, None), - VideoStatus::Watch => (VideoStatusMarker::Watch, None), - VideoStatus::Cached { - cache_path, - is_focused, - } => (VideoStatusMarker::Cached, Some((cache_path, is_focused))), - VideoStatus::Watched => (VideoStatusMarker::Watched, None), - VideoStatus::Drop => (VideoStatusMarker::Drop, None), - VideoStatus::Dropped => (VideoStatusMarker::Dropped, None), - } - } - /// Return the associated [`VideoStatusMarker`] for this [`VideoStatus`]. #[must_use] - pub fn as_marker(&self) -> VideoStatusMarker { + pub(crate) fn as_marker(&self) -> VideoStatusMarker { match self { VideoStatus::Pick => VideoStatusMarker::Pick, VideoStatus::Watch => VideoStatusMarker::Watch, @@ -237,7 +232,7 @@ impl VideoStatus { /// Unit only variant of [`VideoStatus`] #[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -pub enum VideoStatusMarker { +pub(crate) enum VideoStatusMarker { #[default] Pick, @@ -255,7 +250,7 @@ pub enum VideoStatusMarker { } impl VideoStatusMarker { - pub const ALL: &'static [Self; 6] = &[ + pub(crate) const ALL: &'static [Self; 6] = &[ Self::Pick, // Self::Watch, @@ -267,7 +262,7 @@ impl VideoStatusMarker { ]; #[must_use] - pub fn as_command(&self) -> &str { + pub(crate) fn as_command(&self) -> &str { // NOTE: Keep the serialize able variants synced with the main `select` function <2024-06-14> // Also try to ensure, that the strings have the same length match self { @@ -281,7 +276,7 @@ impl VideoStatusMarker { } #[must_use] - pub fn as_db_integer(&self) -> i64 { + pub(crate) fn as_db_integer(self) -> i64 { // These numbers should not change their mapping! // Oh, and keep them in sync with the SQLite check constraint. match self { @@ -296,7 +291,7 @@ impl VideoStatusMarker { } } #[must_use] - pub fn from_db_integer(num: i64) -> Self { + pub(crate) fn from_db_integer(num: i64) -> Self { match num { 0 => Self::Pick, @@ -314,7 +309,7 @@ impl VideoStatusMarker { } #[must_use] - pub fn as_str(&self) -> &'static str { + pub(crate) fn as_str(self) -> &'static str { match self { Self::Pick => "Pick", diff --git a/crates/yt/src/storage/migrate/mod.rs b/crates/yt/src/storage/migrate/mod.rs index 953d079..c5187ee 100644 --- a/crates/yt/src/storage/migrate/mod.rs +++ b/crates/yt/src/storage/migrate/mod.rs @@ -75,7 +75,7 @@ macro_rules! make_upgrade { } #[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] -pub enum DbVersion { +pub(crate) enum DbVersion { /// The database is not yet initialized. Empty, @@ -91,8 +91,17 @@ pub enum DbVersion { /// Introduced: 2025-03-21. Three, + + /// Introduced: 2025-07-05. + Four, + + /// Introduced: 2025-07-20. + Five, + + /// Introduced: 2025-08-26. + Six, } -const CURRENT_VERSION: DbVersion = DbVersion::Three; +const CURRENT_VERSION: DbVersion = DbVersion::Six; async fn add_error_context( function: impl Future<Output = Result<()>>, @@ -143,6 +152,9 @@ impl DbVersion { DbVersion::One => 1, DbVersion::Two => 2, DbVersion::Three => 3, + DbVersion::Four => 4, + DbVersion::Five => 5, + DbVersion::Six => 6, DbVersion::Empty => unreachable!("A empty version does not have an associated integer"), } @@ -154,11 +166,17 @@ impl DbVersion { (1, "yt") => Ok(DbVersion::One), (2, "yt") => Ok(DbVersion::Two), (3, "yt") => Ok(DbVersion::Three), + (4, "yt") => Ok(DbVersion::Four), + (5, "yt") => Ok(DbVersion::Five), + (6, "yt") => Ok(DbVersion::Six), (0, other) => bail!("Db version is Zero, but got unknown namespace: '{other}'"), (1, other) => bail!("Db version is One, but got unknown namespace: '{other}'"), (2, other) => bail!("Db version is Two, but got unknown namespace: '{other}'"), (3, other) => bail!("Db version is Three, but got unknown namespace: '{other}'"), + (4, other) => bail!("Db version is Four, but got unknown namespace: '{other}'"), + (5, other) => bail!("Db version is Five, but got unknown namespace: '{other}'"), + (6, other) => bail!("Db version is Six, but got unknown namespace: '{other}'"), (other, "yt") => bail!("Got unkown version for 'yt' namespace: {other}"), (num, nasp) => bail!("Got unkown version number ({num}) and namespace ('{nasp}')"), @@ -188,8 +206,20 @@ impl DbVersion { make_upgrade! {app, Self::Two, Self::Three, "./sql/3_Two_to_Three.sql"} } - // This is the current_version Self::Three => { + make_upgrade! {app, Self::Three, Self::Four, "./sql/4_Three_to_Four.sql"} + } + + Self::Four => { + make_upgrade! {app, Self::Four, Self::Five, "./sql/5_Four_to_Five.sql"} + } + + Self::Five => { + make_upgrade! {app, Self::Five, Self::Six, "./sql/6_Five_to_Six.sql"} + } + + // This is the current_version + Self::Six => { assert_eq!(self, CURRENT_VERSION); assert_eq!(self, get_version(app).await?); Ok(()) @@ -222,9 +252,10 @@ fn get_current_date() -> i64 { /// /// # Panics /// Only if internal assertions fail. -pub async fn get_version(app: &App) -> Result<DbVersion> { +pub(crate) async fn get_version(app: &App) -> Result<DbVersion> { get_version_db(&app.database).await } + /// Return the current database version. /// /// In contrast to the [`get_version`] function, this function does not @@ -232,13 +263,19 @@ pub async fn get_version(app: &App) -> Result<DbVersion> { /// /// # Panics /// Only if internal assertions fail. -pub async fn get_version_db(pool: &SqlitePool) -> Result<DbVersion> { +pub(crate) async fn get_version_db(pool: &SqlitePool) -> Result<DbVersion> { let version_table_exists = { let query = query!( - "SELECT 1 as result FROM sqlite_master WHERE type = 'table' AND name = 'version'" + " + SELECT 1 as result + FROM sqlite_master + WHERE type = 'table' + AND name = 'version' + " ) .fetch_optional(pool) .await?; + if let Some(output) = query { assert_eq!(output.result, 1); true @@ -246,13 +283,16 @@ pub async fn get_version_db(pool: &SqlitePool) -> Result<DbVersion> { false } }; + if !version_table_exists { return Ok(DbVersion::Empty); } let current_version = query!( " - SELECT namespace, number FROM version WHERE valid_to IS NULL; + SELECT namespace, number + FROM version + WHERE valid_to IS NULL; " ) .fetch_one(pool) @@ -262,7 +302,7 @@ pub async fn get_version_db(pool: &SqlitePool) -> Result<DbVersion> { DbVersion::from_db(current_version.number, current_version.namespace.as_str()) } -pub async fn migrate_db(app: &App) -> Result<()> { +pub(crate) async fn migrate_db(app: &App) -> Result<()> { let current_version = get_version(app) .await .context("Failed to determine initial version")?; diff --git a/crates/yt/src/storage/migrate/sql/4_Three_to_Four.sql b/crates/yt/src/storage/migrate/sql/4_Three_to_Four.sql new file mode 100644 index 0000000..9c283a1 --- /dev/null +++ b/crates/yt/src/storage/migrate/sql/4_Three_to_Four.sql @@ -0,0 +1,24 @@ +-- yt - A fully featured command line YouTube client +-- +-- Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +-- SPDX-License-Identifier: GPL-3.0-or-later +-- +-- This file is part of Yt. +-- +-- You should have received a copy of the License along with this program. +-- If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +ALTER TABLE videos +ADD COLUMN subtitle_langs TEXT; + +ALTER TABLE videos +ADD COLUMN playback_speed REAL CHECK (playback_speed >= 0); + +UPDATE videos + SET playback_speed = video_options.playback_speed, + subtitle_langs = video_options.subtitle_langs + FROM video_options + WHERE videos.extractor_hash = video_options.extractor_hash; + + +DROP TABLE video_options; diff --git a/crates/yt/src/storage/migrate/sql/5_Four_to_Five.sql b/crates/yt/src/storage/migrate/sql/5_Four_to_Five.sql new file mode 100644 index 0000000..6c4b7cc --- /dev/null +++ b/crates/yt/src/storage/migrate/sql/5_Four_to_Five.sql @@ -0,0 +1,15 @@ +-- yt - A fully featured command line YouTube client +-- +-- Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +-- SPDX-License-Identifier: GPL-3.0-or-later +-- +-- This file is part of Yt. +-- +-- You should have received a copy of the License along with this program. +-- If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + + +CREATE TABLE txn_log ( + timestamp INTEGER NOT NULL, + operation TEXT NOT NULL +) STRICT; diff --git a/crates/yt/src/storage/migrate/sql/6_Five_to_Six.sql b/crates/yt/src/storage/migrate/sql/6_Five_to_Six.sql new file mode 100644 index 0000000..6a2cbcc --- /dev/null +++ b/crates/yt/src/storage/migrate/sql/6_Five_to_Six.sql @@ -0,0 +1,12 @@ +-- yt - A fully featured command line YouTube client +-- +-- Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +-- SPDX-License-Identifier: GPL-3.0-or-later +-- +-- This file is part of Yt. +-- +-- You should have received a copy of the License along with this program. +-- If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +ALTER TABLE subscriptions +ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)); diff --git a/crates/yt/src/storage/mod.rs b/crates/yt/src/storage/mod.rs index d352b41..6dcff74 100644 --- a/crates/yt/src/storage/mod.rs +++ b/crates/yt/src/storage/mod.rs @@ -9,6 +9,6 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -pub mod migrate; -pub mod subscriptions; -pub mod video_database; +pub(crate) mod db; +pub(crate) mod migrate; +pub(crate) mod notify; diff --git a/crates/yt/src/storage/video_database/notify.rs b/crates/yt/src/storage/notify.rs index b55c00a..e0ee4e9 100644 --- a/crates/yt/src/storage/video_database/notify.rs +++ b/crates/yt/src/storage/notify.rs @@ -26,7 +26,7 @@ use tokio::task; /// This functions registers a watcher for the database and only returns once a write was /// registered for the database. -pub async fn wait_for_db_write(app: &App) -> Result<()> { +pub(crate) async fn wait_for_db_write(app: &App) -> Result<()> { let db_path: PathBuf = app.config.paths.database_path.clone(); task::spawn_blocking(move || wait_for_db_write_sync(&db_path)).await? } @@ -53,7 +53,7 @@ fn wait_for_db_write_sync(db_path: &Path) -> Result<()> { } /// This functions registers a watcher for the cache path and returns once a file was removed -pub async fn wait_for_cache_reduction(app: &App) -> Result<()> { +pub(crate) async fn wait_for_cache_reduction(app: &App) -> Result<()> { let download_directory: PathBuf = app.config.paths.download_dir.clone(); task::spawn_blocking(move || wait_for_cache_reduction_sync(&download_directory)).await? } diff --git a/crates/yt/src/storage/subscriptions.rs b/crates/yt/src/storage/subscriptions.rs deleted file mode 100644 index 6c0d08a..0000000 --- a/crates/yt/src/storage/subscriptions.rs +++ /dev/null @@ -1,141 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -//! Handle subscriptions - -use std::collections::HashMap; - -use anyhow::Result; -use log::debug; -use sqlx::query; -use url::Url; -use yt_dlp::YoutubeDLOptions; - -use crate::{app::App, unreachable::Unreachable}; - -#[derive(Clone, Debug)] -pub struct Subscription { - /// The human readable name of this subscription - pub name: String, - - /// The URL this subscription subscribes to - pub url: Url, -} - -impl Subscription { - #[must_use] - pub fn new(name: String, url: Url) -> Self { - Self { name, url } - } -} - -/// Check whether an URL could be used as a subscription URL -pub async fn check_url(url: Url) -> Result<bool> { - let yt_dlp = YoutubeDLOptions::new() - .set("playliststart", 1) - .set("playlistend", 10) - .set("noplaylist", false) - .set("extract_flat", "in_playlist") - .build()?; - - let info = yt_dlp.extract_info(&url, false, false)?; - - debug!("{:#?}", info); - - Ok(info.get("_type") == Some(&serde_json::Value::String("Playlist".to_owned()))) -} - -#[derive(Default, Debug)] -pub struct Subscriptions(pub(crate) HashMap<String, Subscription>); - -/// Remove all subscriptions -pub async fn remove_all(app: &App) -> Result<()> { - query!( - " - DELETE FROM subscriptions; - ", - ) - .execute(&app.database) - .await?; - - Ok(()) -} - -/// Get a list of subscriptions -pub async fn get(app: &App) -> Result<Subscriptions> { - let raw_subs = query!( - " - SELECT * - FROM subscriptions; - " - ) - .fetch_all(&app.database) - .await?; - - let subscriptions: HashMap<String, Subscription> = raw_subs - .into_iter() - .map(|sub| { - ( - sub.name.clone(), - Subscription::new( - sub.name, - Url::parse(&sub.url).unreachable("It was an URL, when we inserted it."), - ), - ) - }) - .collect(); - - Ok(Subscriptions(subscriptions)) -} - -pub async fn add_subscription(app: &App, sub: &Subscription) -> Result<()> { - let url = sub.url.to_string(); - - query!( - " - INSERT INTO subscriptions ( - name, - url - ) VALUES (?, ?); - ", - sub.name, - url - ) - .execute(&app.database) - .await?; - - println!("Subscribed to '{}' at '{}'", sub.name, sub.url); - Ok(()) -} - -/// # Panics -/// Only if assertions fail -pub async fn remove_subscription(app: &App, sub: &Subscription) -> Result<()> { - let output = query!( - " - DELETE FROM subscriptions - WHERE name = ? - ", - sub.name, - ) - .execute(&app.database) - .await?; - - assert_eq!( - output.rows_affected(), - 1, - "The remove subscriptino query did effect more (or less) than one row. This is a bug." - ); - - println!("Unsubscribed from '{}' at '{}'", sub.name, sub.url); - - Ok(()) -} diff --git a/crates/yt/src/storage/video_database/downloader.rs b/crates/yt/src/storage/video_database/downloader.rs deleted file mode 100644 index a95081e..0000000 --- a/crates/yt/src/storage/video_database/downloader.rs +++ /dev/null @@ -1,130 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::path::{Path, PathBuf}; - -use anyhow::Result; -use log::debug; -use sqlx::query; - -use crate::{ - app::App, - storage::video_database::{VideoStatus, VideoStatusMarker}, - unreachable::Unreachable, - video_from_record, -}; - -use super::{ExtractorHash, Video}; - -/// Returns to next video which should be downloaded. This respects the priority assigned by select. -/// It does not return videos, which are already cached. -/// -/// # Panics -/// Only if assertions fail. -pub async fn get_next_uncached_video(app: &App) -> Result<Option<Video>> { - let status = VideoStatus::Watch.as_marker().as_db_integer(); - - // NOTE: The ORDER BY statement should be the same as the one in [`get::videos`].<2024-08-22> - let result = query!( - r#" - SELECT * - FROM videos - WHERE status = ? AND cache_path IS NULL - ORDER BY priority DESC, publish_date DESC - LIMIT 1; - "#, - status - ) - .fetch_one(&app.database) - .await; - - if let Err(sqlx::Error::RowNotFound) = result { - Ok(None) - } else { - let base = result?; - - Ok(Some(video_from_record! {base})) - } -} - -/// Update the cached path of a video. Will be set to NULL if the path is None -/// This will also set the status to `Cached` when path is Some, otherwise it set's the status to -/// `Watch`. -pub async fn set_video_cache_path( - app: &App, - video: &ExtractorHash, - path: Option<&Path>, -) -> Result<()> { - if let Some(path) = path { - debug!( - "Setting cache path from '{}' to '{}'", - video.into_short_hash(app).await?, - path.display() - ); - - let path_str = path.display().to_string(); - let extractor_hash = video.hash().to_string(); - let status = VideoStatusMarker::Cached.as_db_integer(); - - query!( - r#" - UPDATE videos - SET cache_path = ?, status = ? - WHERE extractor_hash = ?; - "#, - path_str, - status, - extractor_hash - ) - .execute(&app.database) - .await?; - - Ok(()) - } else { - debug!( - "Setting cache path from '{}' to NULL", - video.into_short_hash(app).await?, - ); - - let extractor_hash = video.hash().to_string(); - let status = VideoStatus::Watch.as_marker().as_db_integer(); - - query!( - r#" - UPDATE videos - SET cache_path = NULL, status = ? - WHERE extractor_hash = ?; - "#, - status, - extractor_hash - ) - .execute(&app.database) - .await?; - - Ok(()) - } -} - -/// Returns the number of cached videos -pub async fn get_allocated_cache(app: &App) -> Result<u32> { - let count = query!( - r#" - SELECT COUNT(cache_path) as count - FROM videos - WHERE cache_path IS NOT NULL; -"#, - ) - .fetch_one(&app.database) - .await?; - - Ok(u32::try_from(count.count) - .unreachable("The value should be strictly positive (and bolow `u32::Max`)")) -} diff --git a/crates/yt/src/storage/video_database/extractor_hash.rs b/crates/yt/src/storage/video_database/extractor_hash.rs deleted file mode 100644 index df545d7..0000000 --- a/crates/yt/src/storage/video_database/extractor_hash.rs +++ /dev/null @@ -1,163 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{collections::HashSet, fmt::Display, str::FromStr}; - -use anyhow::{Context, Result, bail}; -use blake3::Hash; -use log::debug; -use tokio::sync::OnceCell; - -use crate::{app::App, storage::video_database::get::get_all_hashes, unreachable::Unreachable}; - -static EXTRACTOR_HASH_LENGTH: OnceCell<usize> = OnceCell::const_new(); - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)] -pub struct ExtractorHash { - hash: Hash, -} - -impl Display for ExtractorHash { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.hash.fmt(f) - } -} - -#[derive(Debug, Clone)] -pub struct ShortHash(String); - -impl Display for ShortHash { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -#[derive(Debug, Clone)] -#[allow(clippy::module_name_repetitions)] -pub struct LazyExtractorHash { - value: ShortHash, -} - -impl FromStr for LazyExtractorHash { - type Err = anyhow::Error; - - fn from_str(s: &str) -> std::result::Result<Self, Self::Err> { - // perform some cheap validation - if s.len() > 64 { - bail!("A hash can only contain 64 bytes!"); - } - - Ok(Self { - value: ShortHash(s.to_owned()), - }) - } -} - -impl LazyExtractorHash { - /// Turn the [`LazyExtractorHash`] into the [`ExtractorHash`] - pub async fn realize(self, app: &App) -> Result<ExtractorHash> { - ExtractorHash::from_short_hash(app, &self.value).await - } -} - -impl ExtractorHash { - #[must_use] - pub fn from_hash(hash: Hash) -> Self { - Self { hash } - } - pub async fn from_short_hash(app: &App, s: &ShortHash) -> Result<Self> { - Ok(Self { - hash: Self::short_hash_to_full_hash(app, s).await?, - }) - } - - #[must_use] - pub fn hash(&self) -> &Hash { - &self.hash - } - - pub async fn into_short_hash(&self, app: &App) -> Result<ShortHash> { - let needed_chars = if let Some(needed_chars) = EXTRACTOR_HASH_LENGTH.get() { - *needed_chars - } else { - let needed_chars = self - .get_needed_char_len(app) - .await - .context("Failed to calculate needed char length")?; - EXTRACTOR_HASH_LENGTH.set(needed_chars).unreachable( - "This should work at this stage, as we checked above that it is empty.", - ); - - needed_chars - }; - - Ok(ShortHash( - self.hash() - .to_hex() - .chars() - .take(needed_chars) - .collect::<String>(), - )) - } - - async fn short_hash_to_full_hash(app: &App, s: &ShortHash) -> Result<Hash> { - let all_hashes = get_all_hashes(app) - .await - .context("Failed to fetch all extractor -hashesh from database")?; - - let needed_chars = s.0.len(); - - for hash in all_hashes { - if hash.to_hex()[..needed_chars] == s.0 { - return Ok(hash); - } - } - - bail!("Your shortend hash, does not match a real hash (this is probably a bug)!"); - } - - async fn get_needed_char_len(&self, app: &App) -> Result<usize> { - debug!("Calculating the needed hash char length"); - let all_hashes = get_all_hashes(app) - .await - .context("Failed to fetch all extractor -hashesh from database")?; - - let all_char_vec_hashes = all_hashes - .into_iter() - .map(|hash| hash.to_hex().chars().collect::<Vec<char>>()) - .collect::<Vec<Vec<_>>>(); - - // This value should be updated later, if not rust will panic in the assertion. - let mut needed_chars: usize = 1000; - 'outer: for i in 1..64 { - let i_chars: Vec<String> = all_char_vec_hashes - .iter() - .map(|vec| vec.iter().take(i).collect::<String>()) - .collect(); - - let mut uniqnes_hashmap: HashSet<String> = HashSet::new(); - for ch in i_chars { - if !uniqnes_hashmap.insert(ch) { - // The key was already in the hash map, thus we have a duplicated char and need - // at least one char more - continue 'outer; - } - } - - needed_chars = i; - break 'outer; - } - - assert!(needed_chars <= 64, "Hashes are only 64 bytes long"); - - Ok(needed_chars) - } -} diff --git a/crates/yt/src/storage/video_database/get/mod.rs b/crates/yt/src/storage/video_database/get/mod.rs deleted file mode 100644 index 0456cd3..0000000 --- a/crates/yt/src/storage/video_database/get/mod.rs +++ /dev/null @@ -1,307 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -//! These functions interact with the storage db in a read-only way. They are added on-demand (as -//! you could theoretically just could do everything with the `get_videos` function), as -//! performance or convince requires. -use std::{fs::File, path::PathBuf}; - -use anyhow::{Context, Result, bail}; -use blake3::Hash; -use log::{debug, trace}; -use sqlx::query; -use yt_dlp::InfoJson; - -use crate::{ - app::App, - storage::{ - subscriptions::Subscription, - video_database::{Video, extractor_hash::ExtractorHash}, - }, - unreachable::Unreachable, -}; - -use super::{MpvOptions, VideoOptions, VideoStatus, VideoStatusMarker, YtDlpOptions}; - -mod playlist; -pub use playlist::*; - -#[macro_export] -macro_rules! video_from_record { - ($record:expr) => { - Video { - description: $record.description.clone(), - duration: $crate::storage::video_database::MaybeDuration::from_maybe_secs_f64( - $record.duration, - ), - extractor_hash: - $crate::storage::video_database::extractor_hash::ExtractorHash::from_hash( - $record - .extractor_hash - .parse() - .expect("The db hash should be a valid blake3 hash"), - ), - last_status_change: $crate::storage::video_database::TimeStamp::from_secs( - $record.last_status_change, - ), - parent_subscription_name: $record.parent_subscription_name.clone(), - publish_date: $record - .publish_date - .map(|pd| $crate::storage::video_database::TimeStamp::from_secs(pd)), - status: { - let marker = $crate::storage::video_database::VideoStatusMarker::from_db_integer( - $record.status, - ); - - let optional = if let Some(cache_path) = &$record.cache_path { - Some(( - PathBuf::from(cache_path), - if $record.is_focused == Some(1) { - true - } else { - false - }, - )) - } else { - None - }; - - $crate::storage::video_database::VideoStatus::from_marker(marker, optional) - }, - thumbnail_url: if let Some(url) = &$record.thumbnail_url { - Some(url::Url::parse(&url).expect("Parsing this as url should always work")) - } else { - None - }, - title: $record.title.clone(), - url: url::Url::parse(&$record.url).expect("Parsing this as url should always work"), - priority: $crate::storage::video_database::Priority::from($record.priority), - - watch_progress: std::time::Duration::from_secs( - u64::try_from($record.watch_progress).expect("The record is positive i64"), - ), - } - }; -} - -/// Returns the videos that are in the `allowed_states`. -/// -/// # Panics -/// Only, if assertions fail. -pub async fn videos(app: &App, allowed_states: &[VideoStatusMarker]) -> Result<Vec<Video>> { - fn test(all_states: &[VideoStatusMarker], check: VideoStatusMarker) -> Option<i64> { - if all_states.contains(&check) { - trace!("State '{check:?}' marked as active"); - Some(check.as_db_integer()) - } else { - trace!("State '{check:?}' marked as inactive"); - None - } - } - fn states_to_string(allowed_states: &[VideoStatusMarker]) -> String { - let mut states = allowed_states - .iter() - .fold(String::from("&["), |mut acc, state| { - acc.push_str(state.as_str()); - acc.push_str(", "); - acc - }); - states = states.trim().to_owned(); - states = states.trim_end_matches(',').to_owned(); - states.push(']'); - states - } - - debug!( - "Fetching videos in the states: '{}'", - states_to_string(allowed_states) - ); - let active_pick: Option<i64> = test(allowed_states, VideoStatusMarker::Pick); - let active_watch: Option<i64> = test(allowed_states, VideoStatusMarker::Watch); - let active_cached: Option<i64> = test(allowed_states, VideoStatusMarker::Cached); - let active_watched: Option<i64> = test(allowed_states, VideoStatusMarker::Watched); - let active_drop: Option<i64> = test(allowed_states, VideoStatusMarker::Drop); - let active_dropped: Option<i64> = test(allowed_states, VideoStatusMarker::Dropped); - - let videos = query!( - r" - SELECT * - FROM videos - WHERE status IN (?,?,?,?,?,?) - ORDER BY priority DESC, publish_date DESC; - ", - active_pick, - active_watch, - active_cached, - active_watched, - active_drop, - active_dropped, - ) - .fetch_all(&app.database) - .await - .with_context(|| { - format!( - "Failed to query videos with states: '{}'", - states_to_string(allowed_states) - ) - })?; - - let real_videos: Vec<Video> = videos - .iter() - .map(|base| -> Video { - video_from_record! {base} - }) - .collect(); - - Ok(real_videos) -} - -pub fn video_info_json(video: &Video) -> Result<Option<InfoJson>> { - if let VideoStatus::Cached { mut cache_path, .. } = video.status.clone() { - if !cache_path.set_extension("info.json") { - bail!( - "Failed to change path extension to 'info.json': {}", - cache_path.display() - ); - } - let info_json_string = File::open(cache_path)?; - let info_json: InfoJson = serde_json::from_reader(&info_json_string)?; - - Ok(Some(info_json)) - } else { - Ok(None) - } -} - -pub async fn video_by_hash(app: &App, hash: &ExtractorHash) -> Result<Video> { - let ehash = hash.hash().to_string(); - - let raw_video = query!( - " - SELECT * FROM videos WHERE extractor_hash = ?; - ", - ehash - ) - .fetch_one(&app.database) - .await?; - - Ok(video_from_record! {raw_video}) -} - -pub async fn get_all_hashes(app: &App) -> Result<Vec<Hash>> { - let hashes_hex = query!( - r#" - SELECT extractor_hash - FROM videos; - "# - ) - .fetch_all(&app.database) - .await?; - - Ok(hashes_hex - .iter() - .map(|hash| { - Hash::from_hex(&hash.extractor_hash).unreachable( - "These values started as blake3 hashes, they should stay blake3 hashes", - ) - }) - .collect()) -} - -pub async fn get_video_hashes(app: &App, subs: &Subscription) -> Result<Vec<Hash>> { - let hashes_hex = query!( - r#" - SELECT extractor_hash - FROM videos - WHERE parent_subscription_name = ?; - "#, - subs.name - ) - .fetch_all(&app.database) - .await?; - - Ok(hashes_hex - .iter() - .map(|hash| { - Hash::from_hex(&hash.extractor_hash).unreachable( - "These values started as blake3 hashes, they should stay blake3 hashes", - ) - }) - .collect()) -} - -pub async fn get_video_yt_dlp_opts(app: &App, hash: &ExtractorHash) -> Result<YtDlpOptions> { - let ehash = hash.hash().to_string(); - - let yt_dlp_options = query!( - r#" - SELECT subtitle_langs - FROM video_options - WHERE extractor_hash = ?; - "#, - ehash - ) - .fetch_one(&app.database) - .await - .with_context(|| { - format!("Failed to fetch the `yt_dlp_video_opts` for video with hash: '{hash}'",) - })?; - - Ok(YtDlpOptions { - subtitle_langs: yt_dlp_options.subtitle_langs, - }) -} -pub async fn video_mpv_opts(app: &App, hash: &ExtractorHash) -> Result<MpvOptions> { - let ehash = hash.hash().to_string(); - - let mpv_options = query!( - r#" - SELECT playback_speed - FROM video_options - WHERE extractor_hash = ?; - "#, - ehash - ) - .fetch_one(&app.database) - .await - .with_context(|| { - format!("Failed to fetch the `mpv_video_opts` for video with hash: '{hash}'") - })?; - - Ok(MpvOptions { - playback_speed: mpv_options.playback_speed, - }) -} - -pub async fn get_video_opts(app: &App, hash: &ExtractorHash) -> Result<VideoOptions> { - let ehash = hash.hash().to_string(); - - let opts = query!( - r#" - SELECT playback_speed, subtitle_langs - FROM video_options - WHERE extractor_hash = ?; - "#, - ehash - ) - .fetch_one(&app.database) - .await - .with_context(|| format!("Failed to fetch the `video_opts` for video with hash: '{hash}'"))?; - - let mpv = MpvOptions { - playback_speed: opts.playback_speed, - }; - let yt_dlp = YtDlpOptions { - subtitle_langs: opts.subtitle_langs, - }; - - Ok(VideoOptions { yt_dlp, mpv }) -} diff --git a/crates/yt/src/storage/video_database/get/playlist/iterator.rs b/crates/yt/src/storage/video_database/get/playlist/iterator.rs deleted file mode 100644 index 4c45bf7..0000000 --- a/crates/yt/src/storage/video_database/get/playlist/iterator.rs +++ /dev/null @@ -1,101 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{ - collections::VecDeque, - path::{Path, PathBuf}, -}; - -use crate::storage::video_database::{Video, VideoStatus}; - -use super::Playlist; - -/// Turn a cached video into it's `cache_path` -fn to_cache_video(video: Video) -> PathBuf { - if let VideoStatus::Cached { cache_path, .. } = video.status { - cache_path - } else { - unreachable!("ALl of these videos should be cached.") - } -} - -#[derive(Debug)] -pub struct PlaylistIterator { - paths: VecDeque<PathBuf>, -} - -impl Iterator for PlaylistIterator { - type Item = <Playlist as IntoIterator>::Item; - - fn next(&mut self) -> Option<Self::Item> { - self.paths.pop_front() - } -} - -impl DoubleEndedIterator for PlaylistIterator { - fn next_back(&mut self) -> Option<Self::Item> { - self.paths.pop_back() - } -} - -impl IntoIterator for Playlist { - type Item = PathBuf; - - type IntoIter = PlaylistIterator; - - fn into_iter(self) -> Self::IntoIter { - let paths = self.videos.into_iter().map(to_cache_video).collect(); - Self::IntoIter { paths } - } -} - -#[derive(Debug)] -pub struct PlaylistIteratorBorrowed<'a> { - paths: Vec<&'a Path>, - index: usize, -} - -impl<'a> Iterator for PlaylistIteratorBorrowed<'a> { - type Item = <&'a Playlist as IntoIterator>::Item; - - fn next(&mut self) -> Option<Self::Item> { - let output = self.paths.get(self.index); - self.index += 1; - output.map(|v| &**v) - } -} - -impl<'a> Playlist { - #[must_use] - pub fn iter(&'a self) -> PlaylistIteratorBorrowed<'a> { - <&Self as IntoIterator>::into_iter(self) - } -} - -impl<'a> IntoIterator for &'a Playlist { - type Item = &'a Path; - - type IntoIter = PlaylistIteratorBorrowed<'a>; - - fn into_iter(self) -> Self::IntoIter { - let paths = self - .videos - .iter() - .map(|vid| { - if let VideoStatus::Cached { cache_path, .. } = &vid.status { - cache_path.as_path() - } else { - unreachable!("ALl of these videos should be cached.") - } - }) - .collect(); - Self::IntoIter { paths, index: 0 } - } -} diff --git a/crates/yt/src/storage/video_database/get/playlist/mod.rs b/crates/yt/src/storage/video_database/get/playlist/mod.rs deleted file mode 100644 index f6aadbf..0000000 --- a/crates/yt/src/storage/video_database/get/playlist/mod.rs +++ /dev/null @@ -1,167 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -//! This file contains the getters for the internal playlist - -use std::{ops::Add, path::PathBuf}; - -use crate::{ - app::App, - storage::video_database::{Video, VideoStatusMarker, extractor_hash::ExtractorHash}, - video_from_record, -}; - -use anyhow::Result; -use sqlx::query; - -pub mod iterator; - -/// Zero-based index into the internal playlist. -#[derive(Debug, Clone, Copy)] -pub struct PlaylistIndex(usize); - -impl From<PlaylistIndex> for usize { - fn from(value: PlaylistIndex) -> Self { - value.0 - } -} - -impl From<usize> for PlaylistIndex { - fn from(value: usize) -> Self { - Self(value) - } -} - -impl Add<usize> for PlaylistIndex { - type Output = Self; - - fn add(self, rhs: usize) -> Self::Output { - Self(self.0 + rhs) - } -} - -impl Add for PlaylistIndex { - type Output = Self; - - fn add(self, rhs: Self) -> Self::Output { - Self(self.0 + rhs.0) - } -} - -/// A representation of the internal Playlist -#[derive(Debug)] -pub struct Playlist { - videos: Vec<Video>, -} - -impl Playlist { - /// Return the videos of this playlist. - #[must_use] - pub fn as_videos(&self) -> &[Video] { - &self.videos - } - - /// Turn this playlist to it's videos - #[must_use] - pub fn to_videos(self) -> Vec<Video> { - self.videos - } - - /// Find the index of the video specified by the `video_hash`. - /// - /// # Panics - /// Only if internal assertions fail. - #[must_use] - pub fn find_index(&self, video_hash: &ExtractorHash) -> Option<PlaylistIndex> { - if let Some((index, value)) = self - .videos - .iter() - .enumerate() - .find(|(_, other)| other.extractor_hash == *video_hash) - { - assert_eq!(value.extractor_hash, *video_hash); - Some(PlaylistIndex(index)) - } else { - None - } - } - - /// Select a video based on it's index - #[must_use] - pub fn get(&self, index: PlaylistIndex) -> Option<&Video> { - self.videos.get(index.0) - } - - /// Returns the number of videos in the playlist - #[must_use] - pub fn len(&self) -> usize { - self.videos.len() - } - /// Is the playlist empty? - #[must_use] - pub fn is_empty(&self) -> bool { - self.videos.is_empty() - } -} - -/// Return the current playlist index. -/// -/// This effectively looks for the currently focused video and returns it's index. -/// -/// # Panics -/// Only if internal assertions fail. -pub async fn current_playlist_index(app: &App) -> Result<Option<PlaylistIndex>> { - if let Some(focused) = currently_focused_video(app).await? { - let playlist = playlist(app).await?; - let index = playlist - .find_index(&focused.extractor_hash) - .expect("All focused videos must also be in the playlist"); - Ok(Some(index)) - } else { - Ok(None) - } -} - -/// Return the video in the playlist at the position `index`. -pub async fn playlist_entry(app: &App, index: PlaylistIndex) -> Result<Option<Video>> { - let playlist = playlist(app).await?; - - if let Some(vid) = playlist.get(index) { - Ok(Some(vid.to_owned())) - } else { - Ok(None) - } -} - -pub async fn playlist(app: &App) -> Result<Playlist> { - let videos = super::videos(app, &[VideoStatusMarker::Cached]).await?; - - Ok(Playlist { videos }) -} - -/// This returns the video with the `is_focused` flag set. -/// # Panics -/// Only if assertions fail. -pub async fn currently_focused_video(app: &App) -> Result<Option<Video>> { - let cached_status = VideoStatusMarker::Cached.as_db_integer(); - let record = query!( - "SELECT * FROM videos WHERE is_focused = 1 AND status = ?", - cached_status - ) - .fetch_one(&app.database) - .await; - - if let Err(sqlx::Error::RowNotFound) = record { - Ok(None) - } else { - let base = record?; - Ok(Some(video_from_record! {base})) - } -} diff --git a/crates/yt/src/storage/video_database/set/mod.rs b/crates/yt/src/storage/video_database/set/mod.rs deleted file mode 100644 index 8c1be4a..0000000 --- a/crates/yt/src/storage/video_database/set/mod.rs +++ /dev/null @@ -1,333 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -//! These functions change the database. They are added on a demand basis. - -use std::path::{Path, PathBuf}; - -use anyhow::{Context, Result}; -use chrono::Utc; -use log::{debug, info}; -use sqlx::query; -use tokio::fs; - -use crate::{app::App, storage::video_database::extractor_hash::ExtractorHash, video_from_record}; - -use super::{Priority, Video, VideoOptions, VideoStatus}; - -mod playlist; -pub use playlist::*; - -const fn is_focused_to_value(is_focused: bool) -> Option<i8> { - if is_focused { Some(1) } else { None } -} - -/// Set a new status for a video. -/// This will only update the status time stamp/priority when the status or the priority has changed . -pub async fn video_status( - app: &App, - video_hash: &ExtractorHash, - new_status: VideoStatus, - new_priority: Option<Priority>, -) -> Result<()> { - let video_hash = video_hash.hash().to_string(); - - let old = { - let base = query!( - r#" - SELECT * - FROM videos - WHERE extractor_hash = ? - "#, - video_hash - ) - .fetch_one(&app.database) - .await?; - - video_from_record! {base} - }; - - let old_marker = old.status.as_marker(); - let (cache_path, is_focused) = { - fn cache_path_to_string(path: &Path) -> Result<String> { - Ok(path - .to_str() - .with_context(|| { - format!( - "Failed to parse cache path ('{}') as utf8 string", - path.display() - ) - })? - .to_owned()) - } - - if let VideoStatus::Cached { - cache_path, - is_focused, - } = &new_status - { - ( - Some(cache_path_to_string(cache_path)?), - is_focused_to_value(*is_focused), - ) - } else { - (None, None) - } - }; - - let new_status = new_status.as_marker(); - - if let Some(new_priority) = new_priority { - if old_marker == new_status && old.priority == new_priority { - return Ok(()); - } - - let now = Utc::now().timestamp(); - - debug!( - "Running status change: {:#?} -> {:#?}...", - old_marker, new_status, - ); - - let new_status = new_status.as_db_integer(); - let new_priority = new_priority.as_db_integer(); - query!( - r#" - UPDATE videos - SET status = ?, last_status_change = ?, priority = ?, cache_path = ?, is_focused = ? - WHERE extractor_hash = ?; - "#, - new_status, - now, - new_priority, - cache_path, - is_focused, - video_hash - ) - .execute(&app.database) - .await?; - } else { - if old_marker == new_status { - return Ok(()); - } - - let now = Utc::now().timestamp(); - - debug!( - "Running status change: {:#?} -> {:#?}...", - old_marker, new_status, - ); - - let new_status = new_status.as_db_integer(); - query!( - r#" - UPDATE videos - SET status = ?, last_status_change = ?, cache_path = ?, is_focused = ? - WHERE extractor_hash = ?; - "#, - new_status, - now, - cache_path, - is_focused, - video_hash - ) - .execute(&app.database) - .await?; - } - - debug!("Finished status change."); - Ok(()) -} - -/// Mark a video as watched. -/// This will both set the status to `Watched` and the `cache_path` to Null. -/// -/// # Panics -/// Only if assertions fail. -pub async fn video_watched(app: &App, video: &ExtractorHash) -> Result<()> { - let old = { - let video_hash = video.hash().to_string(); - - let base = query!( - r#" - SELECT * - FROM videos - WHERE extractor_hash = ? - "#, - video_hash - ) - .fetch_one(&app.database) - .await?; - - video_from_record! {base} - }; - - info!("Will set video watched: '{}'", old.title); - - if let VideoStatus::Cached { cache_path, .. } = &old.status { - if let Ok(true) = cache_path.try_exists() { - fs::remove_file(cache_path).await?; - } - } else { - unreachable!("The video must be marked as Cached before it can be marked Watched"); - } - - video_status(app, video, VideoStatus::Watched, None).await?; - - Ok(()) -} - -pub(crate) async fn video_watch_progress( - app: &App, - extractor_hash: &ExtractorHash, - watch_progress: u32, -) -> std::result::Result<(), anyhow::Error> { - let video_extractor_hash = extractor_hash.hash().to_string(); - - query!( - r#" - UPDATE videos - SET watch_progress = ? - WHERE extractor_hash = ?; - "#, - watch_progress, - video_extractor_hash, - ) - .execute(&app.database) - .await?; - - Ok(()) -} - -pub async fn set_video_options( - app: &App, - hash: &ExtractorHash, - video_options: &VideoOptions, -) -> Result<()> { - let video_extractor_hash = hash.hash().to_string(); - let playback_speed = video_options.mpv.playback_speed; - let subtitle_langs = &video_options.yt_dlp.subtitle_langs; - - query!( - r#" - UPDATE video_options - SET playback_speed = ?, subtitle_langs = ? - WHERE extractor_hash = ?; - "#, - playback_speed, - subtitle_langs, - video_extractor_hash, - ) - .execute(&app.database) - .await?; - - Ok(()) -} - -/// # Panics -/// Only if internal expectations fail. -pub async fn add_video(app: &App, video: Video) -> Result<()> { - let parent_subscription_name = video.parent_subscription_name; - - let thumbnail_url = video.thumbnail_url.map(|val| val.to_string()); - - let url = video.url.to_string(); - let extractor_hash = video.extractor_hash.hash().to_string(); - - let default_subtitle_langs = &app.config.select.subtitle_langs; - let default_mpv_playback_speed = app.config.select.playback_speed; - - let status = video.status.as_marker().as_db_integer(); - let (cache_path, is_focused) = if let VideoStatus::Cached { - cache_path, - is_focused, - } = video.status - { - ( - Some( - cache_path - .to_str() - .with_context(|| { - format!( - "Failed to prase cache path '{}' as utf-8 string", - cache_path.display() - ) - })? - .to_string(), - ), - is_focused_to_value(is_focused), - ) - } else { - (None, None) - }; - - let duration: Option<f64> = video.duration.as_secs_f64(); - let last_status_change: i64 = video.last_status_change.as_secs(); - let publish_date: Option<i64> = video.publish_date.map(|pd| pd.as_secs()); - let watch_progress: i64 = - i64::try_from(video.watch_progress.as_secs()).expect("This should never exceed a u32"); - - let mut tx = app.database.begin().await?; - query!( - r#" - INSERT INTO videos ( - description, - duration, - extractor_hash, - is_focused, - last_status_change, - parent_subscription_name, - publish_date, - status, - thumbnail_url, - title, - url, - watch_progress, - cache_path - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); - "#, - video.description, - duration, - extractor_hash, - is_focused, - last_status_change, - parent_subscription_name, - publish_date, - status, - thumbnail_url, - video.title, - url, - watch_progress, - cache_path, - ) - .execute(&mut *tx) - .await?; - - query!( - r#" - INSERT INTO video_options ( - extractor_hash, - subtitle_langs, - playback_speed) - VALUES (?, ?, ?); - "#, - extractor_hash, - default_subtitle_langs, - default_mpv_playback_speed - ) - .execute(&mut *tx) - .await?; - - tx.commit().await?; - - Ok(()) -} diff --git a/crates/yt/src/storage/video_database/set/playlist.rs b/crates/yt/src/storage/video_database/set/playlist.rs deleted file mode 100644 index 547df21..0000000 --- a/crates/yt/src/storage/video_database/set/playlist.rs +++ /dev/null @@ -1,101 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use anyhow::Result; -use log::debug; -use sqlx::query; - -use crate::{ - app::App, - storage::video_database::{extractor_hash::ExtractorHash, get}, -}; - -/// Set a video to be focused. -/// This optionally takes another video hash, which marks the old focused video. -/// This will then be disabled. -/// -/// # Panics -/// Only if internal assertions fail. -pub async fn focused( - app: &App, - new_video_hash: &ExtractorHash, - old_video_hash: Option<&ExtractorHash>, -) -> Result<()> { - unfocused(app, old_video_hash).await?; - - debug!("Focusing video: '{new_video_hash}'"); - let new_hash = new_video_hash.hash().to_string(); - query!( - r#" - UPDATE videos - SET is_focused = 1 - WHERE extractor_hash = ?; - "#, - new_hash, - ) - .execute(&app.database) - .await?; - - assert_eq!( - *new_video_hash, - get::currently_focused_video(app) - .await? - .expect("This is some at this point") - .extractor_hash - ); - Ok(()) -} - -/// Set a video to be no longer focused. -/// This will use the supplied `video_hash` if it is [`Some`], otherwise it will simply un-focus -/// the currently focused video. -/// -/// # Panics -/// Only if internal assertions fail. -pub async fn unfocused(app: &App, video_hash: Option<&ExtractorHash>) -> Result<()> { - let hash = if let Some(hash) = video_hash { - hash.hash().to_string() - } else { - let output = query!( - r#" - SELECT extractor_hash - FROM videos - WHERE is_focused = 1; - "#, - ) - .fetch_optional(&app.database) - .await?; - - if let Some(output) = output { - output.extractor_hash - } else { - // There is no unfocused video right now. - return Ok(()); - } - }; - debug!("Unfocusing video: '{hash}'"); - - query!( - r#" - UPDATE videos - SET is_focused = NULL - WHERE extractor_hash = ?; - "#, - hash - ) - .execute(&app.database) - .await?; - - assert!( - get::currently_focused_video(app).await?.is_none(), - "We assumed that the video we just removed was actually a focused one." - ); - Ok(()) -} diff --git a/crates/yt/src/subscribe/mod.rs b/crates/yt/src/subscribe/mod.rs deleted file mode 100644 index 7ac0be4..0000000 --- a/crates/yt/src/subscribe/mod.rs +++ /dev/null @@ -1,184 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::str::FromStr; - -use anyhow::{Context, Result, bail}; -use futures::FutureExt; -use log::warn; -use tokio::io::{AsyncBufRead, AsyncBufReadExt}; -use url::Url; -use yt_dlp::{YoutubeDLOptions, json_get}; - -use crate::{ - app::App, - storage::subscriptions::{ - Subscription, add_subscription, check_url, get, remove_all, remove_subscription, - }, - unreachable::Unreachable, -}; - -pub async fn unsubscribe(app: &App, name: String) -> Result<()> { - let present_subscriptions = get(app).await?; - - if let Some(subscription) = present_subscriptions.0.get(&name) { - remove_subscription(app, subscription).await?; - } else { - bail!("Couldn't find subscription: '{}'", &name); - } - - Ok(()) -} - -pub async fn import<W: AsyncBufRead + AsyncBufReadExt + Unpin>( - app: &App, - reader: W, - force: bool, -) -> Result<()> { - if force { - remove_all(app).await?; - } - - let mut lines = reader.lines(); - while let Some(line) = lines.next_line().await? { - let url = - Url::from_str(&line).with_context(|| format!("Failed to parse '{line}' as url"))?; - match subscribe(app, None, url) - .await - .with_context(|| format!("Failed to subscribe to: '{line}'")) - { - Ok(()) => (), - Err(err) => eprintln!( - "Error while subscribing to '{}': '{}'", - line, - err.source().unreachable("Should have a source") - ), - } - } - - Ok(()) -} - -pub async fn subscribe(app: &App, name: Option<String>, url: Url) -> Result<()> { - if !(url.as_str().ends_with("videos") - || url.as_str().ends_with("streams") - || url.as_str().ends_with("shorts") - || url.as_str().ends_with("videos/") - || url.as_str().ends_with("streams/") - || url.as_str().ends_with("shorts/")) - && url.as_str().contains("youtube.com") - { - warn!( - "Your youtbe url does not seem like it actually tracks a channels playlist (videos, streams, shorts). Adding subscriptions for each of them..." - ); - - let url = Url::parse(&(url.as_str().to_owned() + "/")) - .unreachable("This was an url, it should stay one"); - - if let Some(name) = name { - let out: Result<()> = async move { - actual_subscribe( - app, - Some(name.clone() + " {Videos}"), - url.join("videos/") - .unreachable("The url should allow being joined onto"), - ) - .await - .with_context(|| { - format!("Failed to subscribe to '{}'", name.clone() + " {Videos}") - })?; - - actual_subscribe( - app, - Some(name.clone() + " {Streams}"), - url.join("streams/").unreachable("See above."), - ) - .await - .with_context(|| { - format!("Failed to subscribe to '{}'", name.clone() + " {Streams}") - })?; - - actual_subscribe( - app, - Some(name.clone() + " {Shorts}"), - url.join("shorts/").unreachable("See above."), - ) - .await - .with_context(|| format!("Failed to subscribe to '{}'", name + " {Shorts}"))?; - - Ok(()) - } - .boxed() - .await; - - out?; - } else { - actual_subscribe(app, None, url.join("videos/").unreachable("See above.")) - .await - .with_context(|| format!("Failed to subscribe to the '{}' variant", "{Videos}"))?; - - actual_subscribe(app, None, url.join("streams/").unreachable("See above.")) - .await - .with_context(|| format!("Failed to subscribe to the '{}' variant", "{Streams}"))?; - - actual_subscribe(app, None, url.join("shorts/").unreachable("See above.")) - .await - .with_context(|| format!("Failed to subscribe to the '{}' variant", "{Shorts}"))?; - } - } else { - actual_subscribe(app, name, url).await?; - } - - Ok(()) -} - -async fn actual_subscribe(app: &App, name: Option<String>, url: Url) -> Result<()> { - if !check_url(url.clone()).await? { - bail!("The url ('{}') does not represent a playlist!", &url) - } - - let name = if let Some(name) = name { - name - } else { - let yt_dlp = YoutubeDLOptions::new() - .set("playliststart", 1) - .set("playlistend", 10) - .set("noplaylist", false) - .set("extract_flat", "in_playlist") - .build()?; - - let info = yt_dlp.extract_info(&url, false, false)?; - - if info.get("_type") == Some(&serde_json::Value::String("Playlist".to_owned())) { - json_get!(info, "title", as_str).to_owned() - } else { - bail!("The url ('{}') does not represent a playlist!", &url) - } - }; - - let present_subscriptions = get(app).await?; - - if let Some(subs) = present_subscriptions.0.get(&name) { - bail!( - "The subscription '{}' could not be added, \ - as another one with the same name ('{}') already exists. It links to the Url: '{}'", - name, - name, - subs.url - ); - } - - let sub = Subscription { name, url }; - - add_subscription(app, &sub).await?; - - Ok(()) -} diff --git a/crates/yt/src/unreachable.rs b/crates/yt/src/unreachable.rs deleted file mode 100644 index 436fbb6..0000000 --- a/crates/yt/src/unreachable.rs +++ /dev/null @@ -1,50 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -// This has been taken from: https://gitlab.torproject.org/tpo/core/arti/-/issues/950 - -// The functions here should be annotated with `#[inline(always)]`. -#![allow(clippy::inline_always)] - -use std::fmt::Debug; - -/// Trait for something that can possibly be unwrapped, like a Result or Option. -/// It provides semantic information, that unwrapping here should never happen. -pub trait Unreachable<T> { - /// Like `expect()`, but does not trigger clippy. - /// - /// # Usage - /// - /// This method only exists so that we can use it without hitting - /// `clippy::missing_panics_docs`. Therefore, we should only use it - /// for situations where we are certain that the panic cannot occur - /// unless something else is very broken. Consider instead using - /// `expect()` and adding a `Panics` section to your function - /// documentation. - /// - /// # Panics - /// - /// Panics if this is not an object that can be unwrapped, such as - /// None or an Err. - fn unreachable(self, msg: &str) -> T; -} -impl<T> Unreachable<T> for Option<T> { - #[inline(always)] - fn unreachable(self, msg: &str) -> T { - self.expect(msg) - } -} -impl<T, E: Debug> Unreachable<T> for Result<T, E> { - #[inline(always)] - fn unreachable(self, msg: &str) -> T { - self.expect(msg) - } -} diff --git a/crates/yt/src/update/mod.rs b/crates/yt/src/update/mod.rs deleted file mode 100644 index d866882..0000000 --- a/crates/yt/src/update/mod.rs +++ /dev/null @@ -1,204 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{str::FromStr, time::Duration}; - -use anyhow::{Context, Ok, Result}; -use chrono::{DateTime, Utc}; -use log::warn; -use url::Url; -use yt_dlp::{InfoJson, json_cast, json_get}; - -use crate::{ - app::App, - select::selection_file::duration::MaybeDuration, - storage::{ - subscriptions::{self, Subscription}, - video_database::{ - Priority, TimeStamp, Video, VideoStatus, extractor_hash::ExtractorHash, - get::get_all_hashes, set::add_video, - }, - }, -}; - -mod updater; -use updater::Updater; - -pub async fn update( - app: &App, - max_backlog: usize, - subscription_names_to_update: Vec<String>, - total_number: Option<usize>, - current_progress: Option<usize>, -) -> Result<()> { - let subscriptions = subscriptions::get(app).await?; - - let subs: Vec<Subscription> = if subscription_names_to_update.is_empty() { - subscriptions.0.into_values().collect() - } else { - subscriptions - .0 - .into_values() - .filter(|sub| subscription_names_to_update.contains(&sub.name)) - .collect() - }; - - // We can get away with not having to re-fetch the hashes every time, as the returned video - // should not contain duplicates. - let hashes = get_all_hashes(app).await?; - - let updater = Updater::new(max_backlog, hashes); - updater - .update(app, subs, total_number, current_progress) - .await?; - - Ok(()) -} - -#[allow(clippy::too_many_lines)] -pub fn video_entry_to_video(entry: &InfoJson, sub: Option<&Subscription>) -> Result<Video> { - fn fmt_context(date: &str, extended: Option<&str>) -> String { - let f = format!( - "Failed to parse the `upload_date` of the entry ('{date}'). \ - Expected `YYYY-MM-DD`, has the format changed?" - ); - if let Some(date_string) = extended { - format!("{f}\nThe parsed '{date_string}' can't be turned to a valid UTC date.'") - } else { - f - } - } - - let publish_date = if let Some(date) = &entry.get("upload_date") { - let date = json_cast!(date, as_str); - - let year: u32 = date - .chars() - .take(4) - .collect::<String>() - .parse() - .with_context(|| fmt_context(date, None))?; - let month: u32 = date - .chars() - .skip(4) - .take(2) - .collect::<String>() - .parse() - .with_context(|| fmt_context(date, None))?; - let day: u32 = date - .chars() - .skip(4 + 2) - .take(2) - .collect::<String>() - .parse() - .with_context(|| fmt_context(date, None))?; - - let date_string = format!("{year:04}-{month:02}-{day:02}T00:00:00Z"); - Some( - DateTime::<Utc>::from_str(&date_string) - .with_context(|| fmt_context(date, Some(&date_string)))? - .timestamp(), - ) - } else { - warn!( - "The video '{}' lacks it's upload date!", - json_get!(entry, "title", as_str) - ); - None - }; - - let thumbnail_url = match (&entry.get("thumbnails"), &entry.get("thumbnail")) { - (None, None) => None, - (None, Some(thumbnail)) => Some(Url::from_str(json_cast!(thumbnail, as_str))?), - - // TODO: The algorithm is not exactly the best <2024-05-28> - (Some(thumbnails), None) => { - if let Some(thumbnail) = json_cast!(thumbnails, as_array).first() { - Some(Url::from_str(json_get!( - json_cast!(thumbnail, as_object), - "url", - as_str - ))?) - } else { - None - } - } - (Some(_), Some(thumnail)) => Some(Url::from_str(json_cast!(thumnail, as_str))?), - }; - - let url = { - let smug_url: Url = json_get!(entry, "webpage_url", as_str).parse()?; - // TODO(@bpeetz): We should probably add this? <2025-06-14> - // if '#__youtubedl_smuggle' not in smug_url: - // return smug_url, default - // url, _, sdata = smug_url.rpartition('#') - // jsond = urllib.parse.parse_qs(sdata)['__youtubedl_smuggle'][0] - // data = json.loads(jsond) - // return url, data - - smug_url - }; - - let extractor_hash = blake3::hash(json_get!(entry, "id", as_str).as_bytes()); - - let subscription_name = if let Some(sub) = sub { - Some(sub.name.clone()) - } else if let Some(uploader) = entry.get("uploader").map(|val| json_cast!(val, as_str)) { - if entry - .get("webpage_url_domain") - .map(|val| json_cast!(val, as_str)) - == Some("youtube.com") - { - Some(format!("{uploader} - Videos")) - } else { - Some(uploader.to_owned()) - } - } else { - None - }; - - let video = Video { - description: entry - .get("description") - .map(|val| json_cast!(val, as_str).to_owned()), - duration: MaybeDuration::from_maybe_secs_f64( - entry.get("duration").map(|val| json_cast!(val, as_f64)), - ), - extractor_hash: ExtractorHash::from_hash(extractor_hash), - last_status_change: TimeStamp::from_now(), - parent_subscription_name: subscription_name, - priority: Priority::default(), - publish_date: publish_date.map(TimeStamp::from_secs), - status: VideoStatus::Pick, - thumbnail_url, - title: json_get!(entry, "title", as_str).to_owned(), - url, - watch_progress: Duration::default(), - }; - Ok(video) -} - -async fn process_subscription(app: &App, sub: Subscription, entry: InfoJson) -> Result<()> { - let video = video_entry_to_video(&entry, Some(&sub)) - .context("Failed to parse search entry as Video")?; - - add_video(app, video.clone()) - .await - .with_context(|| format!("Failed to add video to database: '{}'", video.title))?; - println!( - "{}", - &video - .to_line_display(app) - .await - .with_context(|| format!("Failed to format video: '{}'", video.title))? - ); - Ok(()) -} diff --git a/crates/yt/src/update/updater.rs b/crates/yt/src/update/updater.rs deleted file mode 100644 index 04bcaa1..0000000 --- a/crates/yt/src/update/updater.rs +++ /dev/null @@ -1,187 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{ - io::{Write, stderr}, - sync::atomic::{AtomicUsize, Ordering}, -}; - -use anyhow::{Context, Result}; -use blake3::Hash; -use futures::{StreamExt, future::join_all, stream}; -use log::{Level, debug, error, log_enabled}; -use serde_json::json; -use tokio_util::task::LocalPoolHandle; -use yt_dlp::{InfoJson, YoutubeDLOptions, json_cast, json_get, process_ie_result}; - -use crate::{ - ansi_escape_codes::{clear_whole_line, move_to_col}, - app::App, - storage::subscriptions::Subscription, -}; - -use super::process_subscription; - -pub(super) struct Updater { - max_backlog: usize, - hashes: Vec<Hash>, - pool: LocalPoolHandle, -} - -static REACHED_NUMBER: AtomicUsize = const { AtomicUsize::new(1) }; - -impl Updater { - pub(super) fn new(max_backlog: usize, hashes: Vec<Hash>) -> Self { - // TODO(@bpeetz): The number should not be hardcoded. <2025-06-14> - let pool = LocalPoolHandle::new(16); - - Self { - max_backlog, - hashes, - pool, - } - } - - pub(super) async fn update( - self, - app: &App, - subscriptions: Vec<Subscription>, - total_number: Option<usize>, - current_progress: Option<usize>, - ) -> Result<()> { - let total_number = total_number.unwrap_or(subscriptions.len()); - - if let Some(current_progress) = current_progress { - REACHED_NUMBER.store(current_progress, Ordering::Relaxed); - } - - let mut stream = stream::iter(subscriptions) - .map(|sub| self.get_new_entries(sub, total_number)) - .buffer_unordered(16 * 4); - - while let Some(output) = stream.next().await { - let mut entries = output?; - - if let Some(next) = entries.next() { - let (sub, entry) = next; - process_subscription(app, sub, entry).await?; - - join_all(entries.map(|(sub, entry)| process_subscription(app, sub, entry))) - .await - .into_iter() - .collect::<Result<(), _>>()?; - } - } - - Ok(()) - } - - async fn get_new_entries( - &self, - sub: Subscription, - total_number: usize, - ) -> Result<impl Iterator<Item = (Subscription, InfoJson)>> { - let max_backlog = self.max_backlog; - let hashes = self.hashes.clone(); - - let yt_dlp = YoutubeDLOptions::new() - .set("playliststart", 1) - .set("playlistend", max_backlog) - .set("noplaylist", false) - .set( - "extractor_args", - json! {{"youtubetab": {"approximate_date": [""]}}}, - ) - // TODO: This also removes unlisted and other stuff. Find a good way to remove the - // members-only videos from the feed. <2025-04-17> - .set("match-filter", "availability=public") - .build()?; - - self.pool - .spawn_pinned(move || { - async move { - if !log_enabled!(Level::Debug) { - clear_whole_line(); - move_to_col(1); - eprint!( - "({}/{total_number}) Checking playlist {}...", - REACHED_NUMBER.fetch_add(1, Ordering::Relaxed), - sub.name - ); - move_to_col(1); - stderr().flush()?; - } - - let info = yt_dlp - .extract_info(&sub.url, false, false) - .with_context(|| format!("Failed to get playlist '{}'.", sub.name))?; - - let empty = vec![]; - let entries = info - .get("entries") - .map_or(&empty, |val| json_cast!(val, as_array)); - - let valid_entries: Vec<(Subscription, InfoJson)> = entries - .iter() - .take(max_backlog) - .filter_map(|entry| -> Option<(Subscription, InfoJson)> { - let id = json_get!(entry, "id", as_str); - let extractor_hash = blake3::hash(id.as_bytes()); - - if hashes.contains(&extractor_hash) { - debug!( - "Skipping entry, as it is already present: '{extractor_hash}'", - ); - None - } else { - Some((sub.clone(), json_cast!(entry, as_object).to_owned())) - } - }) - .collect(); - - Ok(valid_entries - .into_iter() - .map(|(sub, entry)| { - let inner_yt_dlp = YoutubeDLOptions::new() - .set("noplaylist", true) - .build() - .expect("Worked before, should work now"); - - match inner_yt_dlp.process_ie_result(entry, false) { - Ok(output) => Ok((sub, output)), - Err(err) => Err(err), - } - }) - // Don't fail the whole update, if one of the entries fails to fetch. - .filter_map(|base| match base { - Ok(ok) => Some(ok), - Err(err) => { - let process_ie_result::Error::Python(err) = &err; - - if err.contains( - "Join this channel to get access to members-only content ", - ) { - // Hide this error - } else { - // Show the error, but don't fail. - let error = err - .strip_prefix("DownloadError: \u{1b}[0;31mERROR:\u{1b}[0m ") - .unwrap_or(err); - error!("{error}"); - } - - None - } - })) - } - }) - .await? - } -} diff --git a/crates/yt/src/version/mod.rs b/crates/yt/src/version/mod.rs index 9a91f3b..b12eadd 100644 --- a/crates/yt/src/version/mod.rs +++ b/crates/yt/src/version/mod.rs @@ -10,11 +10,11 @@ use anyhow::{Context, Result}; use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; -use yt_dlp::YoutubeDLOptions; +use yt_dlp::options::YoutubeDLOptions; use crate::{config::Config, storage::migrate::get_version_db}; -pub async fn show(config: &Config) -> Result<()> { +pub(crate) async fn show(config: &Config) -> Result<()> { let db_version = { let options = SqliteConnectOptions::new() .filename(&config.paths.database_path) @@ -30,17 +30,20 @@ pub async fn show(config: &Config) -> Result<()> { .context("Failed to determine database version")? }; - let yt_dlp_version = { + let (yt_dlp, python) = { let yt_dlp = YoutubeDLOptions::new().build()?; - yt_dlp.version() + yt_dlp.version()? }; + let python = python.replace('\n', " "); + println!( "{}: {} db version: {db_version} -yt-dlp: {yt_dlp_version}", +yt-dlp: {yt_dlp} +python: {python}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"), ); diff --git a/crates/yt/src/videos/display/format_video.rs b/crates/yt/src/videos/display/format_video.rs deleted file mode 100644 index b97acb1..0000000 --- a/crates/yt/src/videos/display/format_video.rs +++ /dev/null @@ -1,94 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use anyhow::Result; - -use crate::{app::App, comments::output::format_text, storage::video_database::Video}; - -impl Video { - pub async fn to_info_display(&self, app: &App) -> Result<String> { - let cache_path = self.cache_path_fmt(app); - let description = self.description_fmt(); - let duration = self.duration_fmt(app); - let extractor_hash = self.extractor_hash_fmt(app).await?; - let in_playlist = self.in_playlist_fmt(app); - let last_status_change = self.last_status_change_fmt(app); - let parent_subscription_name = self.parent_subscription_name_fmt(app); - let priority = self.priority_fmt(); - let publish_date = self.publish_date_fmt(app); - let status = self.status_fmt(app); - let thumbnail_url = self.thumbnail_url_fmt(); - let title = self.title_fmt(app); - let url = self.url_fmt(app); - let watch_progress = self.watch_progress_fmt(app); - let video_options = self.video_options_fmt(app).await?; - - let watched_percentage_fmt = { - if let Some(duration) = self.duration.as_secs() { - format!( - " (watched: {:0.0}%)", - (self.watch_progress.as_secs() / duration) * 100 - ) - } else { - format!(" {watch_progress}") - } - }; - - let string = format!( - "\ -{title} ({extractor_hash}) -| -> {cache_path} -| -> {duration}{watched_percentage_fmt} -| -> {parent_subscription_name} -| -> priority: {priority} -| -> {publish_date} -| -> status: {status} since {last_status_change} ({in_playlist}) -| -> {thumbnail_url} -| -> {url} -| -> options: {} -{}\n", - video_options.to_string().trim(), - format_text(description.to_string().as_str()) - ); - Ok(string) - } - - pub async fn to_line_display(&self, app: &App) -> Result<String> { - let f = format!( - "{} {} {} {} {} {}", - self.status_fmt(app), - self.extractor_hash_fmt(app).await?, - self.title_fmt(app), - self.publish_date_fmt(app), - self.parent_subscription_name_fmt(app), - self.duration_fmt(app) - ); - - Ok(f) - } - - pub async fn to_select_file_display(&self, app: &App) -> Result<String> { - let f = format!( - r#"{}{} {} "{}" "{}" "{}" "{}" "{}"{}"#, - self.status_fmt_no_color(), - self.video_options_fmt_no_color(app).await?, - self.extractor_hash_fmt_no_color(app).await?, - self.title_fmt_no_color(), - self.publish_date_fmt_no_color(), - self.parent_subscription_name_fmt_no_color(), - self.duration_fmt_no_color(), - self.url_fmt_no_color(), - '\n' - ); - - Ok(f) - } -} diff --git a/crates/yt/src/videos/display/mod.rs b/crates/yt/src/videos/display/mod.rs deleted file mode 100644 index 1188569..0000000 --- a/crates/yt/src/videos/display/mod.rs +++ /dev/null @@ -1,229 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use owo_colors::OwoColorize; -use url::Url; - -use crate::{ - app::App, - select::selection_file::duration::MaybeDuration, - storage::video_database::{TimeStamp, Video, VideoStatus, get::get_video_opts}, -}; - -use anyhow::{Context, Result}; - -pub mod format_video; - -macro_rules! get { - ($value:expr, $key:ident, $name:expr, $code:tt) => { - if let Some(value) = &$value.$key { - $code(value) - } else { - concat!("[No ", $name, "]").to_owned() - } - }; -} - -fn maybe_add_color<F>(app: &App, input: String, mut color_fn: F) -> String -where - F: FnMut(String) -> String, -{ - if app.config.global.display_colors { - color_fn(input) - } else { - input - } -} -impl Video { - #[must_use] - pub fn cache_path_fmt(&self, app: &App) -> String { - let cache_path = if let VideoStatus::Cached { - cache_path, - is_focused: _, - } = &self.status - { - cache_path.to_string_lossy().to_string() - } else { - "[No Cache Path]".to_owned() - }; - maybe_add_color(app, cache_path, |v| v.blue().bold().to_string()) - } - - #[must_use] - pub fn description_fmt(&self) -> String { - get!( - self, - description, - "Description", - (|value: &str| value.to_owned()) - ) - } - - #[must_use] - pub fn duration_fmt_no_color(&self) -> String { - self.duration.to_string() - } - #[must_use] - pub fn duration_fmt(&self, app: &App) -> String { - let duration = self.duration_fmt_no_color(); - maybe_add_color(app, duration, |v| v.cyan().bold().to_string()) - } - - #[must_use] - pub fn watch_progress_fmt(&self, app: &App) -> String { - maybe_add_color( - app, - MaybeDuration::from_std(self.watch_progress).to_string(), - |v| v.cyan().bold().to_string(), - ) - } - - pub async fn extractor_hash_fmt_no_color(&self, app: &App) -> Result<String> { - let hash = self - .extractor_hash - .into_short_hash(app) - .await - .with_context(|| { - format!( - "Failed to format extractor hash, whilst formatting video: '{}'", - self.title - ) - })? - .to_string(); - Ok(hash) - } - pub async fn extractor_hash_fmt(&self, app: &App) -> Result<String> { - let hash = self.extractor_hash_fmt_no_color(app).await?; - Ok(maybe_add_color(app, hash, |v| { - v.bright_purple().italic().to_string() - })) - } - - #[must_use] - pub fn in_playlist_fmt(&self, app: &App) -> String { - let output = match &self.status { - VideoStatus::Pick - | VideoStatus::Watch - | VideoStatus::Watched - | VideoStatus::Drop - | VideoStatus::Dropped => "Not in the playlist", - VideoStatus::Cached { is_focused, .. } => { - if *is_focused { - "In the playlist and focused" - } else { - "In the playlist" - } - } - }; - maybe_add_color(app, output.to_owned(), |v| v.yellow().italic().to_string()) - } - #[must_use] - pub fn last_status_change_fmt(&self, app: &App) -> String { - maybe_add_color(app, self.last_status_change.to_string(), |v| { - v.bright_cyan().to_string() - }) - } - - #[must_use] - pub fn parent_subscription_name_fmt_no_color(&self) -> String { - get!( - self, - parent_subscription_name, - "author", - (|sub: &str| sub.replace('"', "'")) - ) - } - #[must_use] - pub fn parent_subscription_name_fmt(&self, app: &App) -> String { - let psn = self.parent_subscription_name_fmt_no_color(); - maybe_add_color(app, psn, |v| v.bright_magenta().to_string()) - } - - #[must_use] - pub fn priority_fmt(&self) -> String { - self.priority.to_string() - } - - #[must_use] - pub fn publish_date_fmt_no_color(&self) -> String { - get!( - self, - publish_date, - "release date", - (|date: &TimeStamp| date.to_string()) - ) - } - #[must_use] - pub fn publish_date_fmt(&self, app: &App) -> String { - let date = self.publish_date_fmt_no_color(); - maybe_add_color(app, date, |v| v.bright_white().bold().to_string()) - } - - #[must_use] - pub fn status_fmt_no_color(&self) -> String { - // TODO: We might support `.trim()`ing that, as the extra whitespace could be bad in the - // selection file. <2024-10-07> - self.status.as_marker().as_command().to_string() - } - #[must_use] - pub fn status_fmt(&self, app: &App) -> String { - let status = self.status_fmt_no_color(); - maybe_add_color(app, status, |v| v.red().bold().to_string()) - } - - #[must_use] - pub fn thumbnail_url_fmt(&self) -> String { - get!( - self, - thumbnail_url, - "thumbnail URL", - (|url: &Url| url.to_string()) - ) - } - - #[must_use] - pub fn title_fmt_no_color(&self) -> String { - self.title.replace(['"', '„', '”', '“'], "'") - } - #[must_use] - pub fn title_fmt(&self, app: &App) -> String { - let title = self.title_fmt_no_color(); - maybe_add_color(app, title, |v| v.green().bold().to_string()) - } - - #[must_use] - pub fn url_fmt_no_color(&self) -> String { - self.url.as_str().replace('"', "\\\"") - } - #[must_use] - pub fn url_fmt(&self, app: &App) -> String { - let url = self.url_fmt_no_color(); - maybe_add_color(app, url, |v| v.italic().to_string()) - } - - pub async fn video_options_fmt_no_color(&self, app: &App) -> Result<String> { - let video_options = { - let opts = get_video_opts(app, &self.extractor_hash) - .await - .with_context(|| { - format!("Failed to get video options for video: '{}'", self.title) - })? - .to_cli_flags(app); - let opts_white = if opts.is_empty() { "" } else { " " }; - format!("{opts_white}{opts}") - }; - Ok(video_options) - } - pub async fn video_options_fmt(&self, app: &App) -> Result<String> { - let opts = self.video_options_fmt_no_color(app).await?; - Ok(maybe_add_color(app, opts, |v| v.bright_green().to_string())) - } -} diff --git a/crates/yt/src/videos/format_video.rs b/crates/yt/src/videos/format_video.rs new file mode 100644 index 0000000..6598780 --- /dev/null +++ b/crates/yt/src/videos/format_video.rs @@ -0,0 +1,133 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use anyhow::Result; +use colors::Colorize; + +use crate::{app::App, output::format_text, storage::db::video::Video, videos::RenderWithApp}; + +impl Video { + pub(crate) async fn to_info_display( + &self, + app: &App, + format: Option<String>, + ) -> Result<String> { + let cache_path = self.cache_path_fmt().to_string(app); + let description = self.description_fmt().to_string(app); + let duration = self.duration_fmt().to_string(app); + let extractor_hash = self.extractor_hash_fmt(app).await?.to_string(app); + let in_playlist = self.in_playlist_fmt().to_string(app); + let last_status_change = self.last_status_change_fmt().to_string(app); + let parent_subscription_name = self.parent_subscription_name_fmt().to_string(app); + let priority = self.priority_fmt().to_string(app); + let publish_date = self.publish_date_fmt().to_string(app); + let status = self.status_fmt().to_string(app); + let thumbnail_url = self.thumbnail_url_fmt().to_string(app); + let title = self.title_fmt().to_string(app); + let url = self.url_fmt().to_string(app); + let video_options = self.video_options_fmt(app).to_string(app); + + let watched_percentage_fmt = { + if let Some(percent) = self.watch_progress_percent_fmt() { + format!(" (watched: {})", percent.to_string(app)) + } else { + format!(" {}", self.watch_progress_fmt().to_string(app)) + } + }; + + let options = video_options.to_string(); + let options = options.trim(); + let description = format_text(description.to_string().as_str(), None); + + let string = if let Some(format) = format { + format + .replace("{title}", &title) + .replace("{extractor_hash}", &extractor_hash) + .replace("{cache_path}", &cache_path) + .replace("{duration}", &duration) + .replace("{watched_percentage_fmt}", &watched_percentage_fmt) + .replace("{parent_subscription_name}", &parent_subscription_name) + .replace("{priority}", &priority) + .replace("{publish_date}", &publish_date) + .replace("{status}", &status) + .replace("{last_status_change}", &last_status_change) + .replace("{in_playlist}", &in_playlist) + .replace("{thumbnail_url}", &thumbnail_url) + .replace("{url}", &url) + .replace("{options}", options) + .replace("{description}", &description) + } else { + format!( + "\ +{title} ({extractor_hash}) +| -> {cache_path} +| -> {duration}{watched_percentage_fmt} +| -> {parent_subscription_name} +| -> priority: {priority} +| -> {publish_date} +| -> status: {status} since {last_status_change} ({in_playlist}) +| -> {thumbnail_url} +| -> {url} +| -> options: {options} +{description}\n", + ) + }; + Ok(string) + } + + pub(crate) async fn to_line_display( + &self, + app: &App, + format: Option<String>, + ) -> Result<String> { + let status = self.status_fmt().to_string(app); + let extractor_hash = self.extractor_hash_fmt(app).await?.to_string(app); + let title = self.title_fmt().to_string(app); + let publish_date = self.publish_date_fmt().to_string(app); + let parent_subscription_name = self.parent_subscription_name_fmt().to_string(app); + let duration = self.duration_fmt().to_string(app); + let url = self.url_fmt().to_string(app); + + let f = if let Some(format) = format { + format + .replace("{status}", &status) + .replace("{extractor_hash}", &extractor_hash) + .replace("{title}", &title) + .replace("{publish_date}", &publish_date) + .replace("{parent_subscription_name}", &parent_subscription_name) + .replace("{duration}", &duration) + .replace("{url}", &url) + } else { + format!( + "{status} {extractor_hash} {title} {publish_date} {parent_subscription_name} {duration}" + ) + }; + + Ok(f) + } + + pub(crate) async fn to_select_file_display(&self, app: &App) -> Result<String> { + let f = format!( + r#"{}{} {} "{}" "{}" "{}" "{}" "{}"{}"#, + self.status_fmt().render(false), + self.video_options_fmt(app).render(false), + self.extractor_hash_fmt(app).await?.render(false), + self.title_fmt().render(false), + self.publish_date_fmt().render(false), + self.parent_subscription_name_fmt().render(false), + self.duration_fmt().render(false), + self.url_fmt().render(false), + '\n' + ); + + Ok(f) + } +} diff --git a/crates/yt/src/videos/mod.rs b/crates/yt/src/videos/mod.rs index e821772..c2f01fa 100644 --- a/crates/yt/src/videos/mod.rs +++ b/crates/yt/src/videos/mod.rs @@ -9,59 +9,205 @@ // You should have received a copy of the License along with this program. // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -use anyhow::Result; -use futures::{TryStreamExt, stream::FuturesUnordered}; -use nucleo_matcher::{ - Matcher, - pattern::{CaseMatching, Normalization, Pattern}, -}; +use std::fmt::Write; -pub mod display; +use anyhow::{Context, Result}; +use colors::{Colorize, IntoCanvas}; +use url::Url; use crate::{ app::App, - storage::video_database::{Video, VideoStatusMarker, get}, + select::duration::MaybeDuration, + storage::db::video::{TimeStamp, Video, VideoStatus}, }; -async fn to_line_display_owned(video: Video, app: &App) -> Result<String> { - video.to_line_display(app).await +pub(crate) mod format_video; + +macro_rules! get { + ($value:expr, $key:ident, $name:expr, $code:tt) => { + if let Some(value) = &$value.$key { + $code(value) + } else { + concat!("[No ", $name, "]").to_owned() + } + }; +} + +pub(crate) trait RenderWithApp: Colorize { + fn to_string(self, app: &App) -> String { + self.render(app.config.global.display_colors) + } } +impl<C: Colorize> RenderWithApp for C {} + +impl Video { + #[must_use] + pub(crate) fn cache_path_fmt(&self) -> impl Colorize { + let cache_path = if let VideoStatus::Cached { + cache_path, + is_focused: _, + } = &self.status + { + cache_path.to_string_lossy().to_string() + } else { + "[No Cache Path]".to_owned() + }; + + cache_path.blue().bold() + } + + #[must_use] + pub(crate) fn description_fmt(&self) -> impl Colorize { + get!( + self, + description, + "Description", + (|value: &str| value.to_owned()) + ) + .into_canvas() + } + + #[must_use] + pub(crate) fn duration_fmt(&self) -> impl Colorize { + self.duration.cyan().bold() + } + + #[must_use] + pub(crate) fn watch_progress_fmt(&self) -> impl Colorize { + MaybeDuration::from_std(self.watch_progress).cyan().bold() + } + #[must_use] + pub(crate) fn watch_progress_percent_fmt(&self) -> Option<impl Colorize> { + self.duration.as_secs_f64().map(|duration| { + let watch_progress = self.watch_progress.as_secs_f64(); -pub async fn query(app: &App, limit: Option<usize>, search_query: Option<String>) -> Result<()> { - let all_videos = get::videos(app, VideoStatusMarker::ALL).await?; + (format!("{:0.0}%", (watch_progress / duration) * 100.0)).into_canvas() + }) + } + + pub(crate) async fn extractor_hash_fmt(&self, app: &App) -> Result<impl Colorize> { + let hash = self + .extractor_hash + .as_short_hash(app) + .await + .with_context(|| { + format!( + "Failed to format extractor hash, whilst formatting video: '{}'", + self.title + ) + })?; - // turn one video to a color display, to pre-warm the hash shrinking cache - if let Some(val) = all_videos.first() { - val.to_line_display(app).await?; + Ok(hash.purple().bold().italic()) } - let limit = limit.unwrap_or(all_videos.len()); + #[must_use] + pub(crate) fn in_playlist_fmt(&self) -> impl Colorize { + let output = match &self.status { + VideoStatus::Pick + | VideoStatus::Watch + | VideoStatus::Watched + | VideoStatus::Drop + | VideoStatus::Dropped => "Not in the playlist", + VideoStatus::Cached { is_focused, .. } => { + if *is_focused { + "In the playlist and focused" + } else { + "In the playlist" + } + } + }; + output.yellow().italic() + } + #[must_use] + pub(crate) fn last_status_change_fmt(&self) -> impl Colorize { + self.last_status_change.bright_cyan() + } - let all_video_strings: Vec<String> = all_videos - .into_iter() - .take(limit) - .map(|vid| to_line_display_owned(vid, app)) - .collect::<FuturesUnordered<_>>() - .try_collect::<Vec<String>>() - .await?; + #[must_use] + pub(crate) fn parent_subscription_name_fmt(&self) -> impl Colorize { + let psn = get!( + self, + parent_subscription_name, + "author", + (|sub: &str| sub.replace('"', "'")) + ); - if let Some(query) = search_query { - let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT.match_paths()); + psn.bright_magenta() + } - let pattern_matches = Pattern::parse( - &query.replace(' ', "\\ "), - CaseMatching::Ignore, - Normalization::Smart, + #[must_use] + pub(crate) fn priority_fmt(&self) -> impl Colorize { + self.priority.into_canvas() + } + + #[must_use] + pub(crate) fn publish_date_fmt(&self) -> impl Colorize { + let date = get!( + self, + publish_date, + "release date", + (|date: &TimeStamp| date.to_string()) + ); + + date.bright_white().bold() + } + + #[must_use] + pub(crate) fn status_fmt(&self) -> impl Colorize { + // TODO: We might support `.trim()`ing that, as the extra whitespace could be bad in the + // selection file. <2024-10-07> + let status = self.status.as_marker().as_command().to_owned(); + + status.red().bold() + } + + #[must_use] + pub(crate) fn thumbnail_url_fmt(&self) -> impl Colorize { + get!( + self, + thumbnail_url, + "thumbnail URL", + (|url: &Url| url.to_string()) ) - .match_list(all_video_strings, &mut matcher); + .into_canvas() + } + + #[must_use] + pub(crate) fn title_fmt(&self) -> impl Colorize { + let title = self.title.replace(['"', '„', '”', '“'], "'"); - pattern_matches - .iter() - .rev() - .for_each(|(val, key)| println!("{val} ({key})")); - } else { - println!("{}", all_video_strings.join("\n")); + title.green().bold() } - Ok(()) + #[must_use] + pub(crate) fn url_fmt(&self) -> impl Colorize { + let url = self.url.as_str().replace('"', "\\\""); + + url.italic() + } + + pub(crate) fn video_options_fmt(&self, app: &App) -> impl Colorize { + let video_options = { + let mut opts = String::new(); + + if let Some(playback_speed) = self.playback_speed { + if (playback_speed - app.config.select.playback_speed).abs() > f64::EPSILON { + write!(opts, " --playback-speed '{}'", playback_speed).expect("In-memory"); + } + } + + if let Some(subtitle_langs) = &self.subtitle_langs { + if subtitle_langs != &app.config.select.subtitle_langs { + write!(opts, " --subtitle-langs '{}'", subtitle_langs).expect("In-memory"); + } + } + + let opts = opts.trim().to_owned(); + + let opts_white = if opts.is_empty() { "" } else { " " }; + format!("{opts_white}{opts}") + }; + + video_options.bright_green() + } } diff --git a/crates/yt/src/watch/mod.rs b/crates/yt/src/watch/mod.rs deleted file mode 100644 index c32a76f..0000000 --- a/crates/yt/src/watch/mod.rs +++ /dev/null @@ -1,178 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{ - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; - -use anyhow::{Context, Result}; -use libmpv2::{Mpv, events::EventContext}; -use log::{debug, info, trace, warn}; -use playlist_handler::{reload_mpv_playlist, save_watch_progress}; -use tokio::{task, time::sleep}; - -use self::playlist_handler::Status; -use crate::{ - app::App, - cache::maintain, - storage::video_database::{get, notify::wait_for_db_write}, -}; - -pub mod playlist; -pub mod playlist_handler; - -fn init_mpv(app: &App) -> Result<(Mpv, EventContext)> { - // set some default values, to make things easier (these can be overridden by the config file, - // which we load later) - let mpv = Mpv::with_initializer(|mpv| { - // Enable default key bindings, so the user can actually interact with - // the player (and e.g. close the window). - mpv.set_property("input-default-bindings", "yes")?; - mpv.set_property("input-vo-keyboard", "yes")?; - - // Show the on screen controller. - mpv.set_property("osc", "yes")?; - - // Don't automatically advance to the next video (or exit the player) - mpv.set_option("keep-open", "always")?; - - // Always display an window, even for non-video playback. - // As mpv does not have cli access, no window means no control and no user feedback. - mpv.set_option("force-window", "yes")?; - Ok(()) - }) - .context("Failed to initialize mpv")?; - - let config_path = &app.config.paths.mpv_config_path; - if config_path.try_exists()? { - info!("Found mpv.conf at '{}'!", config_path.display()); - mpv.command( - "load-config-file", - &[config_path - .to_str() - .context("Failed to parse the config path is utf8-stringt")?], - )?; - } else { - warn!( - "Did not find a mpv.conf file at '{}'", - config_path.display() - ); - } - - let input_path = &app.config.paths.mpv_input_path; - if input_path.try_exists()? { - info!("Found mpv.input.conf at '{}'!", input_path.display()); - mpv.command( - "load-input-conf", - &[input_path - .to_str() - .context("Failed to parse the input path as utf8 string")?], - )?; - } else { - warn!( - "Did not find a mpv.input.conf file at '{}'", - input_path.display() - ); - } - - let ev_ctx = EventContext::new(mpv.ctx); - ev_ctx.disable_deprecated_events()?; - - Ok((mpv, ev_ctx)) -} - -pub async fn watch(app: Arc<App>) -> Result<()> { - maintain(&app, false).await?; - - let (mpv, mut ev_ctx) = init_mpv(&app).context("Failed to initialize mpv instance")?; - let mpv = Arc::new(mpv); - reload_mpv_playlist(&app, &mpv, None, None).await?; - - let should_break = Arc::new(AtomicBool::new(false)); - - let local_app = Arc::clone(&app); - let local_mpv = Arc::clone(&mpv); - let local_should_break = Arc::clone(&should_break); - let progress_handle = task::spawn(async move { - loop { - if local_should_break.load(Ordering::Relaxed) { - break; - } - - if get::currently_focused_video(&local_app).await?.is_some() { - save_watch_progress(&local_app, &local_mpv).await?; - } - - sleep(Duration::from_secs(30)).await; - } - - Ok::<(), anyhow::Error>(()) - }); - - let mut have_warned = (false, 0); - 'watchloop: loop { - 'waitloop: while let Ok(value) = playlist_handler::status(&app).await { - match value { - Status::NoMoreAvailable => { - break 'watchloop; - } - Status::NoCached { marked_watch } => { - // try again next time. - if have_warned.0 { - if have_warned.1 != marked_watch { - warn!("Now {} videos are marked as to be watched.", marked_watch); - have_warned.1 = marked_watch; - } - } else { - warn!( - "There is nothing to watch yet, but still {} videos marked as to be watched. \ - Will idle, until they become available", - marked_watch - ); - have_warned = (true, marked_watch); - } - wait_for_db_write(&app).await?; - } - Status::Available { newly_available } => { - debug!("Check and found {newly_available} videos!"); - have_warned.0 = false; - - // Something just became available! - break 'waitloop; - } - } - } - - if let Some(ev) = ev_ctx.wait_event(30.) { - match ev { - Ok(event) => { - trace!("Mpv event triggered: {:#?}", event); - if playlist_handler::handle_mpv_event(&app, &mpv, &event) - .await - .with_context(|| format!("Failed to handle mpv event: '{event:#?}'"))? - { - break; - } - } - Err(e) => debug!("Mpv Event errored: {}", e), - } - } - } - - should_break.store(true, Ordering::Relaxed); - progress_handle.await??; - - Ok(()) -} diff --git a/crates/yt/src/watch/playlist.rs b/crates/yt/src/watch/playlist.rs deleted file mode 100644 index ff383d0..0000000 --- a/crates/yt/src/watch/playlist.rs +++ /dev/null @@ -1,99 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::path::Path; - -use crate::{ - ansi_escape_codes::{cursor_up, erase_in_display_from_cursor}, - app::App, - storage::video_database::{Video, VideoStatus, get, notify::wait_for_db_write}, -}; - -use anyhow::Result; -use futures::{TryStreamExt, stream::FuturesOrdered}; - -/// Extract the values of the [`VideoStatus::Cached`] value from a Video. -fn cache_values(video: &Video) -> (&Path, bool) { - if let VideoStatus::Cached { - cache_path, - is_focused, - } = &video.status - { - (cache_path, *is_focused) - } else { - unreachable!("All of these videos should be cached"); - } -} - -/// # Panics -/// Only if internal assertions fail. -pub async fn playlist(app: &App, watch: bool) -> Result<()> { - let mut previous_output_length = 0; - loop { - let playlist = get::playlist(app).await?.to_videos(); - - let output = playlist - .into_iter() - .map(|video| async move { - let mut output = String::new(); - - let (_, is_focused) = cache_values(&video); - - if is_focused { - output.push_str("🔻 "); - } else { - output.push_str(" "); - } - - output.push_str(&video.title_fmt(app)); - - output.push_str(" ("); - output.push_str(&video.parent_subscription_name_fmt(app)); - output.push(')'); - - output.push_str(" ["); - output.push_str(&video.duration_fmt(app)); - - if is_focused { - output.push_str(" ("); - output.push_str(&if let Some(duration) = video.duration.as_secs() { - format!("{:0.0}%", (video.watch_progress.as_secs() / duration) * 100) - } else { - video.watch_progress_fmt(app) - }); - output.push(')'); - } - output.push(']'); - - output.push('\n'); - - Ok::<String, anyhow::Error>(output) - }) - .collect::<FuturesOrdered<_>>() - .try_collect::<String>() - .await?; - - // Delete the previous output - cursor_up(previous_output_length); - erase_in_display_from_cursor(); - - previous_output_length = output.chars().filter(|ch| *ch == '\n').count(); - - print!("{output}"); - - if !watch { - break; - } - - wait_for_db_write(app).await?; - } - - Ok(()) -} diff --git a/crates/yt/src/watch/playlist_handler/mod.rs b/crates/yt/src/watch/playlist_handler/mod.rs deleted file mode 100644 index 29b8f39..0000000 --- a/crates/yt/src/watch/playlist_handler/mod.rs +++ /dev/null @@ -1,342 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -use std::{cmp::Ordering, time::Duration}; - -use crate::{ - app::App, - storage::video_database::{ - VideoStatus, VideoStatusMarker, - extractor_hash::ExtractorHash, - get::{self, Playlist, PlaylistIndex}, - set, - }, -}; - -use anyhow::{Context, Result}; -use libmpv2::{EndFileReason, Mpv, events::Event}; -use log::{debug, info}; - -mod client_messages; - -#[derive(Debug, Clone, Copy)] -pub enum Status { - /// There are no videos cached and no more marked to be watched. - /// Waiting is pointless. - NoMoreAvailable, - - /// There are no videos cached, but some (> 0) are marked to be watched. - /// So we should wait for them to become available. - NoCached { marked_watch: usize }, - - /// There are videos cached and ready to be inserted into the playback queue. - Available { newly_available: usize }, -} - -fn mpv_message(mpv: &Mpv, message: &str, time: Duration) -> Result<()> { - mpv.command( - "show-text", - &[message, time.as_millis().to_string().as_str()], - )?; - Ok(()) -} - -async fn apply_video_options(app: &App, mpv: &Mpv, video: &ExtractorHash) -> Result<()> { - let options = get::video_mpv_opts(app, video).await?; - let video = get::video_by_hash(app, video).await?; - - mpv.set_property("speed", options.playback_speed)?; - - // We already start at 0, so setting it twice adds a uncomfortable skip sound. - if video.watch_progress.as_secs() != 0 { - mpv.set_property( - "time-pos", - i64::try_from(video.watch_progress.as_secs()).expect("This should not overflow"), - )?; - } - Ok(()) -} - -async fn mark_video_watched(app: &App, mpv: &Mpv) -> Result<()> { - let current_video = get::currently_focused_video(app) - .await? - .expect("This should be some at this point"); - - debug!( - "playlist handler will mark video '{}' watched.", - current_video.title - ); - - save_watch_progress(app, mpv).await?; - - set::video_watched(app, ¤t_video.extractor_hash).await?; - - Ok(()) -} - -/// Saves the `watch_progress` of the currently focused video. -pub(super) async fn save_watch_progress(app: &App, mpv: &Mpv) -> Result<()> { - let current_video = get::currently_focused_video(app) - .await? - .expect("This should be some at this point"); - let watch_progress = u32::try_from( - mpv.get_property::<i64>("time-pos") - .context("Failed to get the watchprogress of the currently playling video")?, - ) - .expect("This conversion should never fail as the `time-pos` property is positive"); - - debug!( - "Setting the watch progress for the current_video '{}' to {watch_progress}s", - current_video.title_fmt_no_color() - ); - - set::video_watch_progress(app, ¤t_video.extractor_hash, watch_progress).await -} - -/// Sync the mpv playlist with the internal playlist. -/// -/// This takes an `maybe_playlist` argument, if you have already fetched the playlist and want to -/// add that. -pub(super) async fn reload_mpv_playlist( - app: &App, - mpv: &Mpv, - maybe_playlist: Option<Playlist>, - maybe_index: Option<PlaylistIndex>, -) -> Result<()> { - fn get_playlist_count(mpv: &Mpv) -> Result<usize> { - mpv.get_property::<i64>("playlist/count") - .context("Failed to get mpv playlist len") - .map(|count| { - usize::try_from(count).expect("The playlist_count should always be positive") - }) - } - - if get_playlist_count(mpv)? != 0 { - // We could also use `loadlist`, but that would require use to start a unix socket or even - // write all the video paths to a file beforehand - mpv.command("playlist-clear", &[])?; - mpv.command("playlist-remove", &["current"])?; - } - - assert_eq!( - get_playlist_count(mpv)?, - 0, - "The playlist should be empty at this point." - ); - - let playlist = if let Some(p) = maybe_playlist { - p - } else { - get::playlist(app).await? - }; - - debug!("Will add {} videos to playlist.", playlist.len()); - playlist.into_iter().try_for_each(|cache_path| { - mpv.command( - "loadfile", - &[ - cache_path.to_str().with_context(|| { - format!( - "Failed to parse the video cache path ('{}') as valid utf8", - cache_path.display() - ) - })?, - "append-play", - ], - )?; - - Ok::<(), anyhow::Error>(()) - })?; - - let index = if let Some(index) = maybe_index { - let index = usize::from(index); - let playlist_length = get_playlist_count(mpv)?; - - match index.cmp(&playlist_length) { - Ordering::Greater => { - unreachable!( - "The index '{index}' execeeds the playlist length '{playlist_length}'." - ); - } - Ordering::Less => index, - Ordering::Equal => { - // The index is pointing to the end of the playlist. We could either go the second - // to last entry (i.e., one entry back) or wrap around to the start. - // We wrap around: - 0 - } - } - } else { - get::current_playlist_index(app) - .await? - .map_or(0, usize::from) - }; - mpv.set_property("playlist-pos", index.to_string().as_str())?; - - Ok(()) -} - -/// Return the status of the playback queue -pub async fn status(app: &App) -> Result<Status> { - let playlist = get::playlist(app).await?; - - let playlist_len = playlist.len(); - let marked_watch_num = get::videos(app, &[VideoStatusMarker::Watch]).await?.len(); - - if playlist_len == 0 && marked_watch_num == 0 { - Ok(Status::NoMoreAvailable) - } else if playlist_len == 0 && marked_watch_num != 0 { - Ok(Status::NoCached { - marked_watch: marked_watch_num, - }) - } else if playlist_len != 0 { - Ok(Status::Available { - newly_available: playlist_len, - }) - } else { - unreachable!( - "The playlist length is {playlist_len}, but the number of marked watch videos is {marked_watch_num}! This is a bug." - ); - } -} - -/// # Returns -/// This will return [`true`], if the event handling should be stopped -/// -/// # Panics -/// Only if internal assertions fail. -#[allow(clippy::too_many_lines)] -pub async fn handle_mpv_event(app: &App, mpv: &Mpv, event: &Event<'_>) -> Result<bool> { - match event { - Event::EndFile(r) => match r.reason { - EndFileReason::Eof => { - info!("Mpv reached the end of the current video. Marking it watched."); - mark_video_watched(app, mpv).await?; - reload_mpv_playlist(app, mpv, None, None).await?; - } - EndFileReason::Stop => { - // This reason is incredibly ambiguous. It _both_ means actually pausing a - // video and going to the next one in the playlist. - // Oh, and it's also called, when a video is removed from the playlist (at - // least via "playlist-remove current") - info!("Paused video (or went to next playlist entry); Doing nothing"); - } - EndFileReason::Quit => { - info!("Mpv quit. Exiting playback"); - - save_watch_progress(app, mpv).await?; - - return Ok(true); - } - EndFileReason::Error => { - unreachable!("This should have been raised as a separate error") - } - EndFileReason::Redirect => { - // TODO: We probably need to handle this somehow <2025-02-17> - } - }, - Event::StartFile(_) => { - let mpv_pos = usize::try_from(mpv.get_property::<i64>("playlist-pos")?) - .expect("The value is strictly positive"); - - let next_video = { - let yt_pos = get::current_playlist_index(app).await?.map(usize::from); - - if (Some(mpv_pos) != yt_pos) || yt_pos.is_none() { - let playlist = get::playlist(app).await?; - let video = playlist - .get(PlaylistIndex::from(mpv_pos)) - .expect("The mpv pos should not be out of bounds"); - - set::focused( - app, - &video.extractor_hash, - get::currently_focused_video(app) - .await? - .as_ref() - .map(|v| &v.extractor_hash), - ) - .await?; - - video.extractor_hash - } else { - get::currently_focused_video(app) - .await? - .expect("We have a focused video") - .extractor_hash - } - }; - - apply_video_options(app, mpv, &next_video).await?; - } - Event::Seek => { - save_watch_progress(app, mpv).await?; - } - Event::ClientMessage(a) => { - debug!("Got Client Message event: '{}'", a.join(" ")); - - match a.as_slice() { - &["yt-comments-external"] => { - client_messages::handle_yt_comments_external(app).await?; - } - &["yt-comments-local"] => { - client_messages::handle_yt_comments_local(app, mpv).await?; - } - - &["yt-description-external"] => { - client_messages::handle_yt_description_external(app).await?; - } - &["yt-description-local"] => { - client_messages::handle_yt_description_local(app, mpv).await?; - } - - &["yt-mark-picked"] => { - let current_video = get::currently_focused_video(app) - .await? - .expect("This should exist at this point"); - let current_index = get::current_playlist_index(app) - .await? - .expect("This should exist, as we can mark this video picked"); - - save_watch_progress(app, mpv).await?; - - set::video_status( - app, - ¤t_video.extractor_hash, - VideoStatus::Pick, - Some(current_video.priority), - ) - .await?; - - reload_mpv_playlist(app, mpv, None, Some(current_index)).await?; - mpv_message(mpv, "Marked the video as picked", Duration::from_secs(3))?; - } - &["yt-mark-watched"] => { - let current_index = get::current_playlist_index(app) - .await? - .expect("This should exist, as we can mark this video picked"); - mark_video_watched(app, mpv).await?; - - reload_mpv_playlist(app, mpv, None, Some(current_index)).await?; - mpv_message(mpv, "Marked the video watched", Duration::from_secs(3))?; - } - &["yt-check-new-videos"] => { - reload_mpv_playlist(app, mpv, None, None).await?; - } - other => { - debug!("Unknown message: {}", other.join(" ")); - } - } - } - _ => {} - } - - Ok(false) -} diff --git a/crates/yt/src/yt_dlp/mod.rs b/crates/yt/src/yt_dlp/mod.rs new file mode 100644 index 0000000..eaa80a1 --- /dev/null +++ b/crates/yt/src/yt_dlp/mod.rs @@ -0,0 +1,253 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{borrow::ToOwned, str::FromStr, time::Duration}; + +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use futures::{FutureExt, future::BoxFuture}; +use log::{error, warn}; +use serde_json::json; +use tokio::{fs, io}; +use url::Url; +use yt_dlp::{ + YoutubeDL, info_json::InfoJson, json_cast, json_get, json_try_get, options::YoutubeDLOptions, +}; + +use crate::{ + app::App, + select::duration::MaybeDuration, + shared::bytes::Bytes, + storage::db::{ + extractor_hash::ExtractorHash, + subscription::Subscription, + video::{Priority, TimeStamp, Video, VideoStatus}, + }, +}; + +pub(crate) fn yt_dlp_opts_updating(max_backlog: usize) -> Result<YoutubeDL> { + Ok(YoutubeDLOptions::new() + .set("playliststart", 1) + .set("playlistend", max_backlog) + .set("noplaylist", false) + .set( + "extractor_args", + json! {{"youtubetab": {"approximate_date": [""]}}}, + ) + // // TODO: This also removes unlisted and other stuff. Find a good way to remove the + // // members-only videos from the feed. <2025-04-17> + // .set("match-filter", "availability=public") + .build()?) +} + +impl Video { + pub(crate) fn get_approx_size(&self) -> Result<u64> { + let yt_dlp = { + YoutubeDLOptions::new() + .set("prefer_free_formats", true) + .set("format", "bestvideo[height<=?1080]+bestaudio/best") + .set("fragment_retries", 10) + .set("retries", 10) + .set("getcomments", false) + .set("ignoreerrors", false) + .build() + .context("Failed to instanciate get approx size yt_dlp") + }?; + + let result = yt_dlp + .extract_info(&self.url, false, true) + .with_context(|| format!("Failed to extract video information: '{}'", self.title))?; + + let size = if let Some(filesize) = json_try_get!(result, "filesize", as_u64) { + filesize + } else if let Some(num) = json_try_get!(result, "filesize_approx", as_u64) { + num + } else if let Some(duration) = json_try_get!(result, "duration", as_f64) + && let Some(tbr) = json_try_get!(result, "tbr", as_f64) + { + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + let duration = duration.ceil() as u64; + + // TODO: yt_dlp gets this from the format + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + let tbr = tbr.ceil() as u64; + + duration * tbr * (1000 / 8) + } else { + let hardcoded_default = Bytes::from_str("250 MiB").expect("This is hardcoded"); + error!( + "Failed to find a filesize for video: {:?} (Using hardcoded value of {})", + self.title, hardcoded_default + ); + hardcoded_default.as_u64() + }; + + Ok(size) + } +} + +impl Video { + #[allow(clippy::too_many_lines)] + pub(crate) fn from_info_json(entry: &InfoJson, sub: Option<&Subscription>) -> Result<Video> { + fn fmt_context(date: &str, extended: Option<&str>) -> String { + let f = format!( + "Failed to parse the `upload_date` of the entry ('{date}'). \ + Expected `YYYY-MM-DD`, has the format changed?" + ); + if let Some(date_string) = extended { + format!("{f}\nThe parsed '{date_string}' can't be turned to a valid UTC date.'") + } else { + f + } + } + + let publish_date = if let Some(date) = json_try_get!(entry, "upload_date", as_str) { + let year: u32 = date + .chars() + .take(4) + .collect::<String>() + .parse() + .with_context(|| fmt_context(date, None))?; + let month: u32 = date + .chars() + .skip(4) + .take(2) + .collect::<String>() + .parse() + .with_context(|| fmt_context(date, None))?; + let day: u32 = date + .chars() + .skip(4 + 2) + .take(2) + .collect::<String>() + .parse() + .with_context(|| fmt_context(date, None))?; + + let date_string = format!("{year:04}-{month:02}-{day:02}T00:00:00Z"); + Some( + DateTime::<Utc>::from_str(&date_string) + .with_context(|| fmt_context(date, Some(&date_string)))? + .timestamp(), + ) + } else { + warn!( + "The video '{}' lacks it's upload date!", + json_get!(entry, "title", as_str) + ); + None + }; + + let thumbnail_url = match ( + &json_try_get!(entry, "thumbnails", as_array), + &json_try_get!(entry, "thumbnail", as_str), + ) { + (None, None) => None, + (None, Some(thumbnail)) => Some(Url::from_str(thumbnail)?), + + // TODO: The algorithm is not exactly the best <2024-05-28> + (Some(thumbnails), None) => { + if let Some(thumbnail) = thumbnails.first() { + Some(Url::from_str(json_get!( + json_cast!(thumbnail, as_object), + "url", + as_str + ))?) + } else { + None + } + } + (Some(_), Some(thumnail)) => Some(Url::from_str(thumnail)?), + }; + + let url = { + let smug_url: Url = json_get!(entry, "webpage_url", as_str).parse()?; + // TODO(@bpeetz): We should probably add this? <2025-06-14> + // if '#__youtubedl_smuggle' not in smug_url: + // return smug_url, default + // url, _, sdata = smug_url.rpartition('#') + // jsond = urllib.parse.parse_qs(sdata)['__youtubedl_smuggle'][0] + // data = json.loads(jsond) + // return url, data + + smug_url + }; + + let extractor_hash = ExtractorHash::from_info_json(entry); + + let subscription_name = if let Some(sub) = sub { + Some(sub.name.clone()) + } else if let Some(uploader) = json_try_get!(entry, "uploader", as_str) { + if json_try_get!(entry, "webpage_url_domain", as_str) == Some("youtube.com") { + Some(format!("{uploader} - Videos")) + } else { + Some(uploader.to_owned()) + } + } else { + None + }; + + let video = Video { + description: json_try_get!(entry, "description", as_str).map(ToOwned::to_owned), + duration: MaybeDuration::from_maybe_secs_f64(json_try_get!(entry, "duration", as_f64)), + extractor_hash, + last_status_change: TimeStamp::from_now(), + parent_subscription_name: subscription_name, + priority: Priority::default(), + publish_date: publish_date.map(TimeStamp::from_secs), + status: VideoStatus::Pick, + thumbnail_url, + title: json_get!(entry, "title", as_str).to_owned(), + url, + watch_progress: Duration::default(), + playback_speed: None, + subtitle_langs: None, + }; + Ok(video) + } +} + +pub(crate) async fn get_current_cache_allocation(app: &App) -> Result<Bytes> { + fn dir_size(mut dir: fs::ReadDir) -> BoxFuture<'static, Result<Bytes>> { + async move { + let mut acc = 0; + while let Some(entry) = dir.next_entry().await? { + let size = match entry.metadata().await? { + data if data.is_dir() => { + let path = entry.path(); + let read_dir = fs::read_dir(path).await?; + + dir_size(read_dir).await?.as_u64() + } + data => data.len(), + }; + acc += size; + } + Ok(Bytes::new(acc)) + } + .boxed() + } + + let read_dir_result = match fs::read_dir(&app.config.paths.download_dir).await { + Ok(ok) => ok, + Err(err) => match err.kind() { + io::ErrorKind::NotFound => { + unreachable!("The download dir should always be created in the config finalizers."); + } + err => Err(io::Error::from(err)).with_context(|| { + format!( + "Failed to get dir size of download dir at: '{}'", + &app.config.paths.download_dir.display() + ) + })?, + }, + }; + + dir_size(read_dir_result).await +} diff --git a/crates/yt/tests/_testenv/init.rs b/crates/yt/tests/_testenv/init.rs new file mode 100644 index 0000000..5970c7c --- /dev/null +++ b/crates/yt/tests/_testenv/init.rs @@ -0,0 +1,136 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + env, + fs::{self, OpenOptions}, + io::{self, Write}, + path::PathBuf, +}; + +use crate::{_testenv::Paths, testenv::TestEnv}; + +fn target_dir() -> PathBuf { + // Tests exe is in target/debug/deps, the *yt* exe is in target/debug + env::current_exe() + .expect("./target/debug/deps/yt-*") + .parent() + .expect("./target/debug/deps") + .parent() + .expect("./target/debug") + .parent() + .expect("./target") + .to_path_buf() +} + +fn test_dir(name: &'static str) -> PathBuf { + target_dir().join("tests").join(name) +} + +fn prepare_files_and_dirs(name: &'static str) -> io::Result<Paths> { + let test_dir = test_dir(name); + + fs::create_dir_all(&test_dir)?; + + let db_path = test_dir.join("database.sqlite"); + let last_selection_path = test_dir.join("last_selection"); + let config_path = test_dir.join("config.toml"); + let download_dir = test_dir.join("download"); + + { + // Remove all files, that are not the download dir. + for entry in fs::read_dir(test_dir).expect("Works") { + let entry = entry.unwrap(); + let entry_ft = entry.file_type().unwrap(); + + if entry_ft.is_dir() && entry.file_name() == "download" { + continue; + } + + if entry_ft.is_dir() { + fs::remove_dir_all(entry.path()).unwrap(); + } else { + fs::remove_file(entry.path()).unwrap(); + } + } + } + + fs::create_dir_all(&download_dir)?; + + { + let mut config_file = OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(&config_path)?; + + writeln!( + config_file, + r#"[paths] +download_dir = "{}" +mpv_config_path = "/dev/null" +mpv_input_path = "/dev/null" +database_path = "{}" +last_selection_path = "{}" + +[download] +max_cache_size = "100GiB" + +[update] +max_backlog = 1 +"#, + download_dir.display(), + db_path.display(), + last_selection_path.display(), + )?; + config_file.flush()?; + } + + Ok(Paths { + db: db_path, + last_selection: last_selection_path, + config: config_path, + download_dir, + }) +} + +/// Find the *yt* executable. +fn find_yt_exe() -> PathBuf { + let target = target_dir().join("debug"); + + let exe_name = if cfg!(windows) { "yt.exe" } else { "yt" }; + + target.join(exe_name) +} + +impl TestEnv { + pub(crate) fn new(name: &'static str) -> TestEnv { + let yt_exe = find_yt_exe(); + let test_dir = test_dir(name); + + let paths = prepare_files_and_dirs(name).expect("config dir"); + + TestEnv { + name, + yt_exe, + test_dir, + paths, + spawned_childs: vec![], + } + } +} + +impl Drop for TestEnv { + fn drop(&mut self) { + for child in &mut self.spawned_childs { + drop(child.kill()); + } + } +} diff --git a/crates/yt/tests/_testenv/mod.rs b/crates/yt/tests/_testenv/mod.rs new file mode 100644 index 0000000..38d1f0a --- /dev/null +++ b/crates/yt/tests/_testenv/mod.rs @@ -0,0 +1,35 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +//! This code was taken from *fd* at 30-06-2025. + +use std::{path::PathBuf, process}; + +mod init; +mod run; +pub(crate) mod util; + +/// Environment for the integration tests. +pub(crate) struct TestEnv { + pub(crate) name: &'static str, + pub(crate) yt_exe: PathBuf, + pub(crate) test_dir: PathBuf, + + pub(crate) paths: Paths, + + pub(crate) spawned_childs: Vec<process::Child>, +} + +pub(crate) struct Paths { + pub(crate) db: PathBuf, + pub(crate) last_selection: PathBuf, + pub(crate) config: PathBuf, + pub(crate) download_dir: PathBuf, +} diff --git a/crates/yt/tests/_testenv/run.rs b/crates/yt/tests/_testenv/run.rs new file mode 100644 index 0000000..578d823 --- /dev/null +++ b/crates/yt/tests/_testenv/run.rs @@ -0,0 +1,183 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + collections::HashMap, + process::{self, Stdio}, +}; + +use colors::{Colorize, IntoCanvas}; + +use crate::testenv::TestEnv; + +/// Format an error message for when *yt* did not exit successfully. +fn format_exit_error(args: &[&str], output: &process::Output) -> String { + format!( + "`yt {}` did not exit successfully.\nstdout:\n---\n{}---\nstderr:\n---\n{}---", + args.join(" "), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +/// Format an error message for when the output of *yt* did not match the expected output. +fn format_output_error(args: &[&str], expected: &str, actual: &str) -> String { + fn normalize_str(input: &str) -> &str { + if input.is_empty() { "<Empty>" } else { input } + } + + let expected = normalize_str(expected); + let actual = normalize_str(actual); + + format!( + concat!( + "`yt {}` did not produce the expected output.\n", + "expected:\n---\n{}\n---\n and actual:\n---\n{}\n---\n" + ), + args.join(" "), + expected, + actual + ) +} + +/// Normalize the output for comparison. +fn normalize_output(s: &str, trim_start: bool, normalize_line: bool) -> String { + // Split into lines and normalize separators. + let mut lines = s + .replace('\0', "NULL\n") + .lines() + .map(|line| { + let line = if trim_start { line.trim_start() } else { line }; + let line = line.replace('/', std::path::MAIN_SEPARATOR_STR); + if normalize_line { + let mut words: Vec<_> = line.split_whitespace().collect(); + words.sort_unstable(); + return words.join(" "); + } + line + }) + .collect::<Vec<_>>(); + + lines.sort(); + lines.join("\n") +} + +impl TestEnv { + /// Assert that calling *yt* in the specified path under the root working directory, + /// and with the specified arguments produces the expected output. + pub(crate) fn assert_output(&self, args: &[&str], expected: &str) { + let expected = normalize_output(expected, true, false); + let actual = self.run(args); + + assert!( + expected == actual, + "{}", + format_output_error(args, &expected, &actual) + ); + } + + /// Run *yt* once with the first args, pipe the output of this command to *yt* with the second + /// args and return the normalized output. + pub(crate) fn run_piped(&self, first_args: &[&str], second_args: &[&str]) -> String { + let mut first_cmd = self.prepare_yt(first_args); + let mut second_cmd = self.prepare_yt(second_args); + + first_cmd.stdout(Stdio::piped()); + let mut first_child = first_cmd.spawn().expect("yt spawn"); + + second_cmd.stdin(first_child.stdout.take().expect("Was set")); + + let first_output = first_child.wait_with_output().expect("yt run"); + assert!( + first_output.status.success(), + "{}", + format_exit_error(first_args, &first_output) + ); + + Self::finalize_cmd(second_cmd, second_args) + } + + /// Run *yt*, with the given args. + /// Returns the normalized stdout output. + pub(crate) fn run(&self, args: &[&str]) -> String { + let cmd = self.prepare_yt(args); + Self::finalize_cmd(cmd, args) + } + + /// Run *yt*, with the given args and environment variables. + /// Returns the normalized stdout output. + pub(crate) fn run_env(&self, args: &[&str], env: &HashMap<&str, &str>) -> String { + let mut cmd = self.prepare_yt(args); + cmd.envs(env.iter()); + Self::finalize_cmd(cmd, args) + } + + /// Run *yt*, with the given args and fork into the background. + /// Returns a sender for the lines on stdout. + pub(crate) fn run_background(&mut self, args: &[&str]) -> process::ChildStdout { + let mut cmd = self.prepare_yt(args); + + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::inherit()); + + // The whole point of this function, is calling a command and keep it running for the whole + // programs run-time. + // + // And we provide a clean-up mechanism. + #[allow(clippy::zombie_processes)] + let mut child = cmd.spawn().expect("yt spawn"); + + let stdout = child.stdout.take().expect("Was piped"); + + self.spawned_childs.push(child); + + stdout + } + + fn finalize_cmd(mut cmd: process::Command, args: &[&str]) -> String { + let child = cmd.spawn().expect("yt spawn"); + let output = child.wait_with_output().expect("yt output"); + + assert!( + output.status.success(), + "{}", + format_exit_error(args, &output) + ); + + normalize_output(&String::from_utf8_lossy(&output.stdout), false, false) + } + + fn prepare_yt(&self, args: &[&str]) -> process::Command { + let mut cmd = process::Command::new(&self.yt_exe); + + cmd.current_dir(&self.test_dir); + + cmd.args([ + "-v", + "--color", + "false", + "--config-path", + self.paths.config.to_str().unwrap(), + ]); + + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + cmd.args(args); + + eprintln!( + "{} `yt {}`", + self.name.blue().italic().render(true), + args.join(" ") + ); + + cmd + } +} diff --git a/crates/yt/tests/_testenv/util.rs b/crates/yt/tests/_testenv/util.rs new file mode 100644 index 0000000..6633fbf --- /dev/null +++ b/crates/yt/tests/_testenv/util.rs @@ -0,0 +1,371 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::_testenv::TestEnv; + +use std::{ + collections::HashMap, + fs::{OpenOptions, Permissions}, + io::Write, + os::unix::fs::PermissionsExt, + path::PathBuf, +}; + +pub(crate) fn get_first_hash(env: &TestEnv) -> String { + let output = env.run(&["videos", "ls", "--format", "{extractor_hash}"]); + + let first_hash = output.lines().next().unwrap(); + first_hash.to_owned() +} + +#[derive(Clone, Copy)] +pub(crate) enum Subscription { + Tagesschau, +} + +impl Subscription { + const fn as_url(self) -> &'static str { + match self { + Subscription::Tagesschau => { + "https://www.ardmediathek.de/sendung/tagesschau/Y3JpZDovL2Rhc2Vyc3RlLmRlL3RhZ2Vzc2NoYXU" + } + } + } + + const fn as_name(self) -> &'static str { + match self { + Subscription::Tagesschau => "Tagesschau", + } + } +} + +/// Provide the given number of videos. +pub(crate) fn provide_videos(env: &TestEnv, sub: Subscription, num: u8) { + add_sub(env, sub); + update_sub(env, num, sub); +} + +pub(crate) fn add_sub(env: &TestEnv, sub: Subscription) { + env.run(&[ + "subs", + "add", + sub.as_url(), + "--name", + sub.as_name(), + "--no-check", + ]); +} + +pub(crate) fn update_sub(env: &TestEnv, num_of_videos: u8, sub: Subscription) { + let num_of_videos: &str = u8_to_char(num_of_videos); + + env.run(&["update", "--max-backlog", num_of_videos, sub.as_name()]); +} + +pub(crate) fn run_select(env: &TestEnv, sed_regex: &str) { + let mut map = HashMap::new(); + + let command = make_command( + env, + format!(r#"sed --in-place '{sed_regex}' "$1""#).as_str(), + ); + map.insert("EDITOR", command.to_str().unwrap()); + + env.run_env(&["select", "file", "--done"], &map); +} + +fn make_command(env: &TestEnv, shell_command: &str) -> PathBuf { + let command_path = env.test_dir.join("command.sh"); + + { + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&command_path) + .unwrap(); + + writeln!(file, "#!/usr/bin/env sh").unwrap(); + + file.write_all(shell_command.as_bytes()).unwrap(); + file.flush().unwrap(); + + { + let perms = Permissions::from_mode(0o0700); + file.set_permissions(perms).unwrap(); + } + } + + command_path +} + +// Char conversion {{{ +#[allow(clippy::too_many_lines)] +const fn u8_to_char(input: u8) -> &'static str { + match input { + 0 => "0", + 1 => "1", + 2 => "2", + 3 => "3", + 4 => "4", + 5 => "5", + 6 => "6", + 7 => "7", + 8 => "8", + 9 => "9", + 10 => "10", + 11 => "11", + 12 => "12", + 13 => "13", + 14 => "14", + 15 => "15", + 16 => "16", + 17 => "17", + 18 => "18", + 19 => "19", + 20 => "20", + 21 => "21", + 22 => "22", + 23 => "23", + 24 => "24", + 25 => "25", + 26 => "26", + 27 => "27", + 28 => "28", + 29 => "29", + 30 => "30", + 31 => "31", + 32 => "32", + 33 => "33", + 34 => "34", + 35 => "35", + 36 => "36", + 37 => "37", + 38 => "38", + 39 => "39", + 40 => "40", + 41 => "41", + 42 => "42", + 43 => "43", + 44 => "44", + 45 => "45", + 46 => "46", + 47 => "47", + 48 => "48", + 49 => "49", + 50 => "50", + 51 => "51", + 52 => "52", + 53 => "53", + 54 => "54", + 55 => "55", + 56 => "56", + 57 => "57", + 58 => "58", + 59 => "59", + 60 => "60", + 61 => "61", + 62 => "62", + 63 => "63", + 64 => "64", + 65 => "65", + 66 => "66", + 67 => "67", + 68 => "68", + 69 => "69", + 70 => "70", + 71 => "71", + 72 => "72", + 73 => "73", + 74 => "74", + 75 => "75", + 76 => "76", + 77 => "77", + 78 => "78", + 79 => "79", + 80 => "80", + 81 => "81", + 82 => "82", + 83 => "83", + 84 => "84", + 85 => "85", + 86 => "86", + 87 => "87", + 88 => "88", + 89 => "89", + 90 => "90", + 91 => "91", + 92 => "92", + 93 => "93", + 94 => "94", + 95 => "95", + 96 => "96", + 97 => "97", + 98 => "98", + 99 => "99", + 100 => "100", + 101 => "101", + 102 => "102", + 103 => "103", + 104 => "104", + 105 => "105", + 106 => "106", + 107 => "107", + 108 => "108", + 109 => "109", + 110 => "110", + 111 => "111", + 112 => "112", + 113 => "113", + 114 => "114", + 115 => "115", + 116 => "116", + 117 => "117", + 118 => "118", + 119 => "119", + 120 => "120", + 121 => "121", + 122 => "122", + 123 => "123", + 124 => "124", + 125 => "125", + 126 => "126", + 127 => "127", + 128 => "128", + 129 => "129", + 130 => "130", + 131 => "131", + 132 => "132", + 133 => "133", + 134 => "134", + 135 => "135", + 136 => "136", + 137 => "137", + 138 => "138", + 139 => "139", + 140 => "140", + 141 => "141", + 142 => "142", + 143 => "143", + 144 => "144", + 145 => "145", + 146 => "146", + 147 => "147", + 148 => "148", + 149 => "149", + 150 => "150", + 151 => "151", + 152 => "152", + 153 => "153", + 154 => "154", + 155 => "155", + 156 => "156", + 157 => "157", + 158 => "158", + 159 => "159", + 160 => "160", + 161 => "161", + 162 => "162", + 163 => "163", + 164 => "164", + 165 => "165", + 166 => "166", + 167 => "167", + 168 => "168", + 169 => "169", + 170 => "170", + 171 => "171", + 172 => "172", + 173 => "173", + 174 => "174", + 175 => "175", + 176 => "176", + 177 => "177", + 178 => "178", + 179 => "179", + 180 => "180", + 181 => "181", + 182 => "182", + 183 => "183", + 184 => "184", + 185 => "185", + 186 => "186", + 187 => "187", + 188 => "188", + 189 => "189", + 190 => "190", + 191 => "191", + 192 => "192", + 193 => "193", + 194 => "194", + 195 => "195", + 196 => "196", + 197 => "197", + 198 => "198", + 199 => "199", + 200 => "200", + 201 => "201", + 202 => "202", + 203 => "203", + 204 => "204", + 205 => "205", + 206 => "206", + 207 => "207", + 208 => "208", + 209 => "209", + 210 => "210", + 211 => "211", + 212 => "212", + 213 => "213", + 214 => "214", + 215 => "215", + 216 => "216", + 217 => "217", + 218 => "218", + 219 => "219", + 220 => "220", + 221 => "221", + 222 => "222", + 223 => "223", + 224 => "224", + 225 => "225", + 226 => "226", + 227 => "227", + 228 => "228", + 229 => "229", + 230 => "230", + 231 => "231", + 232 => "232", + 233 => "233", + 234 => "234", + 235 => "235", + 236 => "236", + 237 => "237", + 238 => "238", + 239 => "239", + 240 => "240", + 241 => "241", + 242 => "242", + 243 => "243", + 244 => "244", + 245 => "245", + 246 => "246", + 247 => "247", + 248 => "248", + 249 => "249", + 250 => "250", + 251 => "251", + 252 => "252", + 253 => "253", + 254 => "254", + 255 => "255", + } +} +// }}} diff --git a/crates/yt/tests/select/base.rs b/crates/yt/tests/select/base.rs new file mode 100644 index 0000000..24e198b --- /dev/null +++ b/crates/yt/tests/select/base.rs @@ -0,0 +1,50 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{collections::HashMap, fs}; + +use crate::{ + _testenv::{ + TestEnv, + util::{self, Subscription}, + }, + select::get_videos_in_state, +}; + +#[test] +fn test_base() { + let env = TestEnv::new(module_path!()); + + util::provide_videos(&env, Subscription::Tagesschau, 4); + + let first_hash = &util::get_first_hash(&env); + env.run(&["select", "drop", first_hash]); + + let mut map = HashMap::new(); + map.insert("EDITOR", "true"); + env.run_env(&["select", "file", "--done"], &map); + + fs::remove_file(&env.paths.last_selection).unwrap(); + + env.run_env(&["select", "split", "--done"], &map); + assert_states(&env); + + env.run_env(&["select", "file", "--use-last-selection"], &map); + assert_states(&env); +} + +fn assert_states(env: &TestEnv) { + assert_eq!(get_videos_in_state(env, "Picked"), 3); + assert_eq!(get_videos_in_state(env, "Drop"), 1); + + assert_eq!(get_videos_in_state(env, "Watch"), 0); + assert_eq!(get_videos_in_state(env, "Cached"), 0); + assert_eq!(get_videos_in_state(env, "Watched"), 0); +} diff --git a/crates/yt/tests/select/file.rs b/crates/yt/tests/select/file.rs new file mode 100644 index 0000000..b8bd2b5 --- /dev/null +++ b/crates/yt/tests/select/file.rs @@ -0,0 +1,31 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{ + _testenv::util::{self, Subscription}, + select::get_videos_in_state, + testenv::TestEnv, +}; + +#[test] +fn test_file() { + let env = TestEnv::new(module_path!()); + + util::provide_videos(&env, Subscription::Tagesschau, 4); + + util::run_select(&env, "s/pick/watch/"); + + assert_eq!(get_videos_in_state(&env, "Picked"), 0); + assert_eq!(get_videos_in_state(&env, "Drop"), 0); + + assert_eq!(get_videos_in_state(&env, "Watch"), 4); + assert_eq!(get_videos_in_state(&env, "Cached"), 0); + assert_eq!(get_videos_in_state(&env, "Watched"), 0); +} diff --git a/crates/yt/tests/select/mod.rs b/crates/yt/tests/select/mod.rs new file mode 100644 index 0000000..d7033f8 --- /dev/null +++ b/crates/yt/tests/select/mod.rs @@ -0,0 +1,25 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::_testenv::TestEnv; + +mod base; +mod file; +mod options; + +fn get_videos_in_state(env: &TestEnv, state: &str) -> usize { + let status = env.run(&[ + "status", + "--format", + format!("{{{}_videos_len}}", state.to_lowercase()).as_str(), + ]); + + status.parse().unwrap() +} diff --git a/crates/yt/tests/select/options.rs b/crates/yt/tests/select/options.rs new file mode 100644 index 0000000..6a0d155 --- /dev/null +++ b/crates/yt/tests/select/options.rs @@ -0,0 +1,51 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{_testenv::util, select::get_videos_in_state, testenv::TestEnv}; + +#[test] +fn test_options() { + let env = TestEnv::new(module_path!()); + + util::provide_videos(&env, util::Subscription::Tagesschau, 1); + + let video_hash = &util::get_first_hash(&env); + env.run(&["select", "watch", video_hash]); + + env.assert_output(&["videos", "info", video_hash, "--format", "{options}"], ""); + + env.run(&[ + "select", + "watch", + video_hash, + "--playback-speed", + "1", + "--subtitle-langs", + "en,de,sv", + ]); + + env.assert_output( + &["videos", "info", video_hash, "--format", "{options}"], + "--playback-speed '1' --subtitle-langs 'en,de,sv'", + ); + + env.run(&["select", "watch", video_hash, "-s", "1.7", "-l", "de"]); + + env.assert_output( + &["videos", "info", video_hash, "--format", "{options}"], + "--playback-speed '1.7' --subtitle-langs 'de'", + ); + + assert_eq!(get_videos_in_state(&env, "Picked"), 0); + assert_eq!(get_videos_in_state(&env, "Drop"), 0); + assert_eq!(get_videos_in_state(&env, "Watch"), 1); + assert_eq!(get_videos_in_state(&env, "Cached"), 0); + assert_eq!(get_videos_in_state(&env, "Watched"), 0); +} diff --git a/crates/yt/tests/subscriptions/import_export/golden.txt b/crates/yt/tests/subscriptions/import_export/golden.txt new file mode 100644 index 0000000..7ed5419 --- /dev/null +++ b/crates/yt/tests/subscriptions/import_export/golden.txt @@ -0,0 +1,2 @@ +Nyheter på lätt svenska: 'https://www.svtplay.se/nyheter-pa-latt-svenska' +tagesschau: 'https://www.ardmediathek.de/sendung/tagesschau/Y3JpZDovL2Rhc2Vyc3RlLmRlL3RhZ2Vzc2NoYXU' diff --git a/crates/bytes/Cargo.lock.license b/crates/yt/tests/subscriptions/import_export/golden.txt.license index d4d410f..7813eb6 100644 --- a/crates/bytes/Cargo.lock.license +++ b/crates/yt/tests/subscriptions/import_export/golden.txt.license @@ -1,6 +1,6 @@ yt - A fully featured command line YouTube client -Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> SPDX-License-Identifier: GPL-3.0-or-later This file is part of Yt. diff --git a/crates/yt/tests/subscriptions/import_export/mod.rs b/crates/yt/tests/subscriptions/import_export/mod.rs new file mode 100644 index 0000000..1156508 --- /dev/null +++ b/crates/yt/tests/subscriptions/import_export/mod.rs @@ -0,0 +1,35 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::testenv::TestEnv; + +#[test] +fn test_import_export() { + let env = TestEnv::new(module_path!()); + + env.run(&[ + "subs", + "add", + "https://www.svtplay.se/nyheter-pa-latt-svenska", + ]); + env.run(&[ + "subs", + "add", + "https://www.ardmediathek.de/sendung/tagesschau/Y3JpZDovL2Rhc2Vyc3RlLmRlL3RhZ2Vzc2NoYXU", + ]); + + let before = env.run(&["subs", "list"]); + env.assert_output(&["subs", "list"], include_str!("./golden.txt")); + + env.run_piped(&["subs", "export"], &["subs", "import", "--force"]); + + env.assert_output(&["subs", "list"], &before); + env.assert_output(&["subs", "list"], include_str!("./golden.txt")); +} diff --git a/crates/yt/tests/subscriptions/mod.rs b/crates/yt/tests/subscriptions/mod.rs new file mode 100644 index 0000000..0b300c5 --- /dev/null +++ b/crates/yt/tests/subscriptions/mod.rs @@ -0,0 +1,12 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +mod import_export; +mod naming_subscriptions; diff --git a/crates/yt/tests/subscriptions/naming_subscriptions/golden.txt b/crates/yt/tests/subscriptions/naming_subscriptions/golden.txt new file mode 100644 index 0000000..46ede50 --- /dev/null +++ b/crates/yt/tests/subscriptions/naming_subscriptions/golden.txt @@ -0,0 +1,2 @@ +Nyheter: 'https://www.svtplay.se/nyheter-pa-latt-svenska' +Vewn: 'https://www.ardmediathek.de/sendung/tagesschau/Y3JpZDovL2Rhc2Vyc3RlLmRlL3RhZ2Vzc2NoYXU' diff --git a/crates/yt/tests/subscriptions/naming_subscriptions/golden.txt.license b/crates/yt/tests/subscriptions/naming_subscriptions/golden.txt.license new file mode 100644 index 0000000..7813eb6 --- /dev/null +++ b/crates/yt/tests/subscriptions/naming_subscriptions/golden.txt.license @@ -0,0 +1,9 @@ +yt - A fully featured command line YouTube client + +Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +SPDX-License-Identifier: GPL-3.0-or-later + +This file is part of Yt. + +You should have received a copy of the License along with this program. +If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. diff --git a/crates/yt/tests/subscriptions/naming_subscriptions/mod.rs b/crates/yt/tests/subscriptions/naming_subscriptions/mod.rs new file mode 100644 index 0000000..50fe3e4 --- /dev/null +++ b/crates/yt/tests/subscriptions/naming_subscriptions/mod.rs @@ -0,0 +1,33 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::testenv::TestEnv; + +#[test] +fn test_naming_subscriptions() { + let env = TestEnv::new(module_path!()); + + env.run(&[ + "subs", + "add", + "https://www.svtplay.se/nyheter-pa-latt-svenska", + "--name", + "Nyheter", + ]); + env.run(&[ + "subs", + "add", + "https://www.ardmediathek.de/sendung/tagesschau/Y3JpZDovL2Rhc2Vyc3RlLmRlL3RhZ2Vzc2NoYXU", + "--name", + "Vewn", + ]); + + env.assert_output(&["subs", "list"], include_str!("./golden.txt")); +} diff --git a/crates/yt/tests/tests.rs b/crates/yt/tests/tests.rs new file mode 100644 index 0000000..89c3091 --- /dev/null +++ b/crates/yt/tests/tests.rs @@ -0,0 +1,22 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +// Use this, for the background run pids +// #![feature(linux_pidfd)] + +#![allow(unused_crate_dependencies)] + +mod _testenv; +pub(crate) use _testenv as testenv; + +mod select; +mod subscriptions; +mod videos; +mod watch; diff --git a/crates/yt/tests/videos/downloading.rs b/crates/yt/tests/videos/downloading.rs new file mode 100644 index 0000000..f026858 --- /dev/null +++ b/crates/yt/tests/videos/downloading.rs @@ -0,0 +1,52 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use crate::{_testenv::util, testenv::TestEnv}; + +#[test] +fn test_downloading() { + let env = TestEnv::new(module_path!()); + + util::provide_videos(&env, util::Subscription::Tagesschau, 1); + + let first_hash = &util::get_first_hash(&env); + env.run(&["select", "watch", first_hash]); + + env.run(&["download"]); + + let usage = get_cache_usage(&env); + assert!(usage > 0.0); + + env.run(&["cache", "clear"]); + + let usage = get_cache_usage(&env); + + #[allow(clippy::float_cmp)] + { + assert_eq!(usage, 0.0); + } +} + +fn get_cache_usage(env: &TestEnv) -> f64 { + let status = env.run(&["status", "--format", "{cache_usage}"]); + + let split: Vec<_> = status.split(' ').collect(); + let usage: f64 = split[0].parse().unwrap(); + let unit = split[1]; + + #[allow(clippy::cast_precision_loss)] + match unit { + "B" => usage * (1024u64.pow(0)) as f64, + "KiB" => usage * (1024u64.pow(1)) as f64, + "MiB" => usage * (1024u64.pow(2)) as f64, + "GiB" => usage * (1024u64.pow(3)) as f64, + other => unreachable!("Unknown unit: {other}"), + } +} diff --git a/crates/yt/tests/videos/mod.rs b/crates/yt/tests/videos/mod.rs new file mode 100644 index 0000000..6a80761 --- /dev/null +++ b/crates/yt/tests/videos/mod.rs @@ -0,0 +1,11 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +mod downloading; diff --git a/crates/yt/tests/watch/focus_switch.rs b/crates/yt/tests/watch/focus_switch.rs new file mode 100644 index 0000000..81246f3 --- /dev/null +++ b/crates/yt/tests/watch/focus_switch.rs @@ -0,0 +1,53 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use yt_dlp::json_cast; + +use crate::{_testenv::util, testenv::TestEnv, watch::MpvControl}; + +#[test] +#[ignore = "Currently, this test is missing it's goal"] +fn test_focus_switch() { + let mut env = TestEnv::new(module_path!()); + + { + util::provide_videos(&env, util::Subscription::Tagesschau, 32); + + util::run_select(&env, "s/pick/watch/"); + + env.run(&["download"]); + } + + let mut mpv = MpvControl::new(&mut env); + + assert_pos(&mut mpv, 0); + + for i in 1..32 { + mpv.assert(&["playlist-next", "weak"]); + assert_pos(&mut mpv, i); + } + + mpv.assert(&["playlist-next", "weak"]); + assert_pos(&mut mpv, 2); + + mpv.assert(&["playlist-prev", "weak"]); + assert_pos(&mut mpv, 1); + + mpv.assert(&["playlist-prev", "weak"]); + assert_pos(&mut mpv, 0); + + mpv.assert(&["playlist-prev", "weak"]); + assert_pos(&mut mpv, 0); +} + +fn assert_pos(mpv: &mut MpvControl, pos: i64) { + let mpv_pos = mpv.assert(&["get_property", "playlist-pos"]); + assert_eq!(json_cast!(mpv_pos, as_i64), pos); +} diff --git a/crates/yt/tests/watch/mod.rs b/crates/yt/tests/watch/mod.rs new file mode 100644 index 0000000..7af8b39 --- /dev/null +++ b/crates/yt/tests/watch/mod.rs @@ -0,0 +1,135 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + io::{BufRead, BufReader, Write}, + os::unix::net::UnixStream, + path::PathBuf, + sync::atomic::AtomicU64, +}; + +use colors::{Colorize, IntoCanvas}; +use serde_json::json; +use yt_dlp::{json_cast, json_get, json_try_get}; + +use crate::_testenv::TestEnv; + +mod focus_switch; + +struct MpvControl { + stream: UnixStream, + current_request_id: AtomicU64, + name: &'static str, +} + +impl MpvControl { + fn new(env: &mut TestEnv) -> Self { + let socket_path = { + let stdout = env.run_background(&[ + "watch", + // "--headless", + "--provide-ipc-socket", + ]); + + let line = { + let mut buf = String::new(); + let mut reader = BufReader::new(stdout); + reader.read_line(&mut buf).expect("In-memory"); + buf + }; + + PathBuf::from(line.trim()) + }; + + let stream = UnixStream::connect(&socket_path).unwrap_or_else(|e| { + panic!( + "Path to socket ('{}') should exist, but did not: {e}", + socket_path.display() + ) + }); + + let mut me = Self { + stream, + name: env.name, + current_request_id: AtomicU64::new(0), + }; + + // Disable all events. + // We do not use them, and this should reduce the read load on the socket. + me.assert(&["disable_event", "all"]); + + me + } + + /// Run a command and assert that it ran successfully. + fn assert(&mut self, args: &[&str]) -> serde_json::Value { + let out = self.command(args); + + out.unwrap_or_else(|e| panic!("`mpv {}` failed; error {e}.", args.join(" "))) + } + + /// Run a command in mpv. + /// Will return true if the command ran correctly and false if not. + fn command(&mut self, args: &[&str]) -> Result<serde_json::Value, String> { + let tl_rid = self + .current_request_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + + eprint!( + "{} `mpv {}`", + self.name.blue().italic().render(true), + args.join(" ") + ); + + writeln!( + self.stream, + "{}", + json!( { "command": args, "request_id": tl_rid }) + ) + .expect("Should always work"); + + loop { + let response: serde_json::Value = { + let mut reader = BufReader::new(&mut self.stream); + + let mut buf = String::new(); + reader.read_line(&mut buf).expect("Works"); + serde_json::from_str(&buf).expect("Mpv only returns json") + }; + + if let Some(rid) = json_try_get!(response, "request_id", as_u64) { + if rid == tl_rid { + let error = json_get!(response, "error", as_str); + + if error == "success" { + let data: serde_json::Value = { + match response.get("data") { + Some(val) => val.to_owned(), + None => serde_json::Value::Null, + } + }; + + eprintln!(", {}: {data}", "output".bright_blue().render(true),); + return Ok(data); + } + + eprintln!(", {}: {error}", "error".bright_red().render(true)); + return Err(error.to_owned()); + } + } + } + } +} + +impl Drop for MpvControl { + fn drop(&mut self) { + self.assert(&["quit"]); + } +} diff --git a/crates/yt_dlp/Cargo.toml b/crates/yt_dlp/Cargo.toml index 90f2e10..eb2924d 100644 --- a/crates/yt_dlp/Cargo.toml +++ b/crates/yt_dlp/Cargo.toml @@ -22,11 +22,13 @@ rust-version.workspace = true publish = true [dependencies] -indexmap = { version = "2.9.0", default-features = false } +curl = "0.4.49" log.workspace = true -rustpython = { git = "https://github.com/RustPython/RustPython.git", features = ["threading", "stdlib", "stdio", "importlib", "ssl"], default-features = false } +pyo3 = { workspace = true } +pyo3-pylogger = { path = "crates/pyo3-pylogger" } +serde = { workspace = true, features = ["derive"] } serde_json.workspace = true -thiserror = "2.0.12" +thiserror = "2.0.17" url.workspace = true [lints] diff --git a/crates/yt_dlp/README.md b/crates/yt_dlp/README.md index 591ef2e..ece8540 100644 --- a/crates/yt_dlp/README.md +++ b/crates/yt_dlp/README.md @@ -12,7 +12,7 @@ If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. # Yt_py -> \[can be empty\] +> [can be empty] Some text about the project. diff --git a/crates/yt_dlp/crates/pyo3-pylogger/.gitignore b/crates/yt_dlp/crates/pyo3-pylogger/.gitignore new file mode 100644 index 0000000..733c5bc --- /dev/null +++ b/crates/yt_dlp/crates/pyo3-pylogger/.gitignore @@ -0,0 +1,13 @@ +# yt - A fully featured command line YouTube client +# +# Copyright (C) 2025 Dylan Bobby Storey <dylan.storey@gmail.com>, cpu <daniel@binaryparadox.net>, Warren Snipes <contact@warrensnipes.dev> +# SPDX-License-Identifier: Apache-2.0 +# +# This file is part of Yt. +# +# You should have received a copy of the License along with this program. +# If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +target +Cargo.lock +.idea diff --git a/crates/yt_dlp/crates/pyo3-pylogger/Cargo.toml b/crates/yt_dlp/crates/pyo3-pylogger/Cargo.toml new file mode 100644 index 0000000..a2676e7 --- /dev/null +++ b/crates/yt_dlp/crates/pyo3-pylogger/Cargo.toml @@ -0,0 +1,31 @@ +# yt - A fully featured command line YouTube client +# +# Copyright (C) 2025 Dylan Bobby Storey <dylan.storey@gmail.com>, cpu <daniel@binaryparadox.net>, Warren Snipes <contact@warrensnipes.dev> +# SPDX-License-Identifier: Apache-2.0 +# +# This file is part of Yt. +# +# You should have received a copy of the License along with this program. +# If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +[package] +name = "pyo3-pylogger" +version = "1.9.0" +edition = "2021" +authors = [ + "Dylan Bobby Storey <dylan.storey@gmail.com>", + "cpu <daniel@binaryparadox.net>", + "Warren Snipes <contact@warrensnipes.dev>", +] +description = "Enables `log` for pyo3 based Rust applications using the `logging` modules." +publish = ["crates-io"] +license = "Apache-2.0" +readme = "README.md" +homepage = "https://github.com/dylanbstorey/pyo3-pylogger" +repository = "https://github.com/dylanbstorey/pyo3-pylogger" +documentation = "https://github.com/dylanbstorey/pyo3-pylogger" + +[dependencies] +pyo3 = { workspace = true } +log = { workspace = true } +phf = { version = "0.13", features = ["macros"] } diff --git a/crates/yt_dlp/crates/pyo3-pylogger/LICENSE b/crates/yt_dlp/crates/pyo3-pylogger/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/crates/yt_dlp/crates/pyo3-pylogger/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/yt_dlp/crates/pyo3-pylogger/README.md b/crates/yt_dlp/crates/pyo3-pylogger/README.md new file mode 100644 index 0000000..e68903b --- /dev/null +++ b/crates/yt_dlp/crates/pyo3-pylogger/README.md @@ -0,0 +1,160 @@ +<!-- +yt - A fully featured command line YouTube client + +Copyright (C) 2025 Dylan Bobby Storey <dylan.storey@gmail.com>, cpu <daniel@binaryparadox.net>, Warren Snipes <contact@warrensnipes.dev> +SPDX-License-Identifier: Apache-2.0 + +This file is part of Yt. + +You should have received a copy of the License along with this program. +If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. +--> + +# pyo3-pylogger + +Enables log messages for pyo3 embedded Python applications using Python's +`logging` or module. + +# Features + +- Logging integration between Python's `logging` module and Rust's `log` crate +- Structured logging support via the logging + [extra](https://docs.python.org/3/library/logging.html#logging.Logger.debug) + field (requires `kv` or `tracing-kv`feature) +- Integration with Rust's `tracing` library (requires `tracing` feature) + +# Usage + +```rust +use log::{info, warn}; +use pyo3::{ffi::c_str, prelude::*}; +fn main() { + // register the host handler with python logger, providing a logger target + pyo3_pylogger::register("example_application_py_logger"); + + // initialize up a logger + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("trace")).init(); + //just show the logger working from Rust. + info!("Just some normal information!"); + warn!("Something spooky happened!"); + + // Ask pyo3 to set up embedded Python interpreter + pyo3::prepare_freethreaded_python(); + Python::with_gil(|py| { + // Python code can now `import logging` as usual + py.run( + c_str!( + r#" +import logging +logging.getLogger().setLevel(0) +logging.debug('DEBUG') +logging.info('INFO') +logging.warning('WARNING') +logging.error('ERROR') +logging.getLogger('foo.bar.baz').info('INFO')"# + ), + None, + None, + ) + .unwrap(); + }) +} + + +``` + +## Outputs + +```bash +[2025-03-28T01:12:29Z INFO helloworld] Just some normal information! +[2025-03-28T01:12:29Z WARN helloworld] Something spooky happened! +[2025-03-28T01:12:29Z DEBUG example_application_py_logger] DEBUG +[2025-03-28T01:12:29Z INFO example_application_py_logger] INFO +[2025-03-28T01:12:29Z WARN example_application_py_logger] WARNING +[2025-03-28T01:12:29Z ERROR example_application_py_logger] ERROR +[2025-03-28T01:12:29Z INFO example_application_py_logger::foo::bar::baz] INFO +``` + +## Structured Logging + +To enable structured logging support, add the `kv` feature to your `Cargo.toml`: + +```toml +[dependencies] +pyo3-pylogger = { version = "0.4", features = ["kv"] } +``` + +Then you can use Python's `extra` parameter to pass structured data: + +```python +logging.info("Processing order", extra={"order_id": "12345", "amount": 99.99}) +``` + +When using a structured logging subscriber in Rust, these key-value pairs will +be properly captured, for example: + +```bash +[2025-03-28T01:12:29Z INFO example_application_py_logger] Processing order order_id=12345 amount=99.99 +``` + +## Tracing Support + +To enable integration with Rust's `tracing` library, add the `tracing` feature +to your `Cargo.toml`: + +```toml +[dependencies] +pyo3-pylogger = { version = "0.4", default-features = false, features = ["tracing"] } +``` + +When the `tracing` feature is enabled, Python logs will be forwarded to the +active tracing subscriber: + +```rust +use tracing::{info, warn}; +use pyo3::{ffi::c_str, prelude::*}; + +fn main() { + // Register the tracing handler with Python logger + pyo3_pylogger::register_tracing("example_application_py_logger"); + + // Initialize tracing subscriber + tracing_subscriber::fmt::init(); + + // Tracing events from Rust + info!("Tracing information from Rust"); + + // Python logging will be captured by the tracing subscriber + pyo3::prepare_freethreaded_python(); + Python::with_gil(|py| { + py.run( + c_str!( + r#" +import logging +logging.getLogger().setLevel(0) +logging.info('This will be captured by tracing')"# + ), + None, + None, + ) + .unwrap(); + }) +} +``` + +### Structured Data with Tracing + +The `tracing` feature automatically supports Python's `extra` field for +structured data. However, the KV fields are json serialized and not available as +tracing attributes. This is a limitation of the `tracing` library and is not +specific to this crate. See +[this issue](https://github.com/tokio-rs/tracing/issues/372) for more +information. + +# Feature Flags + +- `kv`: Enables structured logging support via Python's `extra` fields. This + adds support for the `log` crate's key-value system. +- `tracing`: Enables integration with Rust's `tracing` library. +- `tracing-kv`: Enables structured logging support via Python's `extra` fields + and integration with Rust's `tracing` library. diff --git a/crates/yt_dlp/crates/pyo3-pylogger/src/kv.rs b/crates/yt_dlp/crates/pyo3-pylogger/src/kv.rs new file mode 100644 index 0000000..67a0c3e --- /dev/null +++ b/crates/yt_dlp/crates/pyo3-pylogger/src/kv.rs @@ -0,0 +1,127 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Dylan Bobby Storey <dylan.storey@gmail.com>, cpu <daniel@binaryparadox.net>, Warren Snipes <contact@warrensnipes.dev> +// SPDX-License-Identifier: Apache-2.0 +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +//! Key-Value handling module for Python LogRecord attributes. +//! +//! This module provides functionality to extract and handle custom key-value pairs +//! from Python LogRecord objects, facilitating integration between Python's logging +//! system and Rust's log crate. + +use pyo3::{ + Bound, PyAny, PyResult, + types::{PyAnyMethods, PyDict, PyDictMethods, PyListMethods}, +}; +use std::collections::HashMap; + +/// A static hashset containing all standard [LogRecord](https://github.com/python/cpython/blob/8a00c9a4d2ce9d373b13f8f0a2265a65f4523293/Lib/logging/__init__.py#L286-L287) attributes defined in the CPython logging module. +/// +/// This set is used to differentiate between standard [LogRecord](https://github.com/python/cpython/blob/8a00c9a4d2ce9d373b13f8f0a2265a65f4523293/Lib/logging/__init__.py#L286-L287) attributes and custom key-value pairs +/// that users might add to their log records. The attributes listed here correspond to the default +/// attributes created by Python's [makeRecord](https://github.com/python/cpython/blob/8a00c9a4d2ce9d373b13f8f0a2265a65f4523293/Lib/logging/__init__.py#L1633-L1634) function. +pub static LOG_RECORD_KV_ATTRIBUTES: phf::Set<&'static str> = phf::phf_set! { + "name", + "msg", + "args", + "levelname", + "levelno", + "pathname", + "filename", + "module", + "exc_info", + "exc_text", + "stack_info", + "lineno", + "funcName", + "created", + "msecs", + "relativeCreated", + "thread", + "threadName", + "processName", + "process", + "taskName", +}; + +/// Extracts custom key-value pairs from a Python LogRecord object. +/// +/// This function examines the `__dict__` of a LogRecord(https://github.com/python/cpython/blob/8a00c9a4d2ce9d373b13f8f0a2265a65f4523293/Lib/logging/__init__.py#L286-L287) object and identifies any attributes +/// that are not part of the standard [LogRecord](https://github.com/python/cpython/blob/8a00c9a4d2ce9d373b13f8f0a2265a65f4523293/Lib/logging/__init__.py#L286-L287) attributes. These custom attributes are +/// treated as key-value pairs for structured logging. +/// +/// # Arguments +/// * `record` - A reference to a Python LogRecord object +/// +/// # Returns +/// * `PyResult<Option<HashMap<String, pyo3::Bound<'a, pyo3::PyAny>>>>` - If custom attributes +/// are found, returns a HashMap containing the key-value pairs. Returns None if no custom +/// attributes are present. +/// +/// # Note +/// This function relies on the fact that Python will not implement new attributes on the LogRecord object. +/// If new attributes are added, this function will not be able to filter them out and will return them as key-value pairs. +/// In that future, [LOG_RECORD_KV_ATTRIBUTES] will need to be updated to include the new attributes. +/// This is an unfortunate side effect of using the `__dict__` attribute to extract key-value pairs. However, there are no other ways to handle this given that CPython does not distinguish between user-provided attributes and attributes created by the logging module. +pub fn find_kv_args<'a>( + record: &Bound<'a, PyAny>, +) -> PyResult<Option<std::collections::HashMap<String, pyo3::Bound<'a, pyo3::PyAny>>>> { + let dict: Bound<'_, PyDict> = record.getattr("__dict__")?.extract()?; + + // We can abuse the fact that Python dictionaries are ordered by insertion order to reverse iterate over the keys + // and stop at the first key that is not a predefined key-value pair attribute. + let mut kv_args: Option<HashMap<String, pyo3::Bound<'_, pyo3::PyAny>>> = None; + + for item in dict.items().iter().rev() { + let (key, value) = + item.extract::<(pyo3::Bound<'_, pyo3::PyAny>, pyo3::Bound<'_, pyo3::PyAny>)>()?; + + let key_str = key.to_string(); + if LOG_RECORD_KV_ATTRIBUTES.contains(&key_str) { + break; + } + if kv_args.is_none() { + kv_args = Some(HashMap::new()); + } + + kv_args.as_mut().unwrap().insert(key_str, value); + } + + Ok(kv_args) +} + +/// A wrapper struct that implements the `log::kv::Source` trait for Python key-value pairs. +/// +/// This struct allows Python LogRecord custom attributes to be used with Rust's +/// structured logging system by implementing the necessary trait for key-value handling. +/// +/// # Type Parameters +/// * `'a` - The lifetime of the contained Python values +pub struct KVSource<'a>(pub HashMap<String, pyo3::Bound<'a, pyo3::PyAny>>); + +impl log::kv::Source for KVSource<'_> { + /// Visits each key-value pair in the source, converting Python values to debug representations. + /// + /// # Arguments + /// * `visitor` - The visitor that will process each key-value pair + /// + /// # Returns + /// * `Result<(), log::kv::Error>` - Success if all pairs are visited successfully, + /// or an error if visitation fails + fn visit<'kvs>( + &'kvs self, + visitor: &mut dyn log::kv::VisitSource<'kvs>, + ) -> Result<(), log::kv::Error> { + for (key, value) in &self.0 { + let v: log::kv::Value<'_> = log::kv::Value::from_debug(value); + + visitor.visit_pair(log::kv::Key::from_str(key), v)?; + } + Ok(()) + } +} diff --git a/crates/yt_dlp/crates/pyo3-pylogger/src/level.rs b/crates/yt_dlp/crates/pyo3-pylogger/src/level.rs new file mode 100644 index 0000000..d244ef4 --- /dev/null +++ b/crates/yt_dlp/crates/pyo3-pylogger/src/level.rs @@ -0,0 +1,43 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Dylan Bobby Storey <dylan.storey@gmail.com>, cpu <daniel@binaryparadox.net>, Warren Snipes <contact@warrensnipes.dev> +// SPDX-License-Identifier: Apache-2.0 +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +/// A wrapper type for logging levels that supports both `tracing` and `log` features. +pub(crate) struct Level(pub log::Level); + +/// Converts a numeric level value to the appropriate logging Level. +/// +/// # Arguments +/// +/// * `level` - A u8 value representing the logging level: +/// * 40+ = Error +/// * 30-39 = Warn +/// * 20-29 = Info +/// * 10-19 = Debug +/// * 0-9 = Trace +/// +/// # Returns +/// +/// Returns a `Level` wrapper containing either a `tracing::Level` or `log::Level` +/// depending on which feature is enabled. +pub(crate) fn get_level(level: u8) -> Level { + { + if level.ge(&40u8) { + Level(log::Level::Error) + } else if level.ge(&30u8) { + Level(log::Level::Warn) + } else if level.ge(&20u8) { + Level(log::Level::Info) + } else if level.ge(&10u8) { + Level(log::Level::Debug) + } else { + Level(log::Level::Trace) + } + } +} diff --git a/crates/yt_dlp/crates/pyo3-pylogger/src/lib.rs b/crates/yt_dlp/crates/pyo3-pylogger/src/lib.rs new file mode 100644 index 0000000..3ecb123 --- /dev/null +++ b/crates/yt_dlp/crates/pyo3-pylogger/src/lib.rs @@ -0,0 +1,211 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Dylan Bobby Storey <dylan.storey@gmail.com>, cpu <daniel@binaryparadox.net>, Warren Snipes <contact@warrensnipes.dev> +// SPDX-License-Identifier: Apache-2.0 +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::{ + ffi::CString, + sync::{self, OnceLock}, +}; + +use log::{debug, log_enabled}; +use pyo3::{ + Bound, Py, PyAny, PyResult, Python, pyfunction, + sync::OnceLockExt, + types::{PyAnyMethods, PyDict, PyListMethods, PyModuleMethods}, + wrap_pyfunction, +}; + +mod kv; +mod level; + +static LOGGER: sync::OnceLock<Py<PyAny>> = OnceLock::new(); + +/// Is the specified record to be logged? Returns false for no, +/// true for yes. Filters can either modify log records in-place or +/// return a completely different record instance which will replace +/// the original log record in any future processing of the event. +#[pyfunction] +fn filter_error_log<'py>(record: Bound<'py, PyAny>) -> bool { + // Filter out all error logs (they are propagated as rust errors) + let levelname: String = record + .getattr("levelname") + .expect("This should exist") + .extract() + .expect("This should be a String"); + + let return_value = levelname.as_str() != "ERROR"; + + if log_enabled!(log::Level::Debug) && !return_value { + let message: String = { + let get_message = record.getattr("getMessage").expect("Is set"); + let message: String = get_message + .call((), None) + .expect("Can be called") + .extract() + .expect("Downcasting works"); + + message.as_str().to_owned() + }; + + debug!("Swollowed error message: '{message}'"); + } + return_value +} + +/// Consume a Python `logging.LogRecord` and emit a Rust `Log` instead. +#[pyfunction] +fn host_log(record: Bound<'_, PyAny>, rust_target: &str) -> PyResult<()> { + let level = record.getattr("levelno")?.extract()?; + let message = record.getattr("getMessage")?.call0()?.to_string(); + let pathname = record.getattr("pathname")?.extract::<String>()?; + let lineno = record.getattr("lineno")?.extract::<u32>()?; + + let logger_name = record.getattr("name")?.extract::<String>()?; + + let full_target: Option<String> = if logger_name.trim().is_empty() || logger_name == "root" { + None + } else { + // Libraries (ex: tracing_subscriber::filter::Directive) expect rust-style targets like foo::bar, + // and may not deal well with "." as a module separator: + let logger_name = logger_name.replace('.', "::"); + Some(format!("{rust_target}::{logger_name}")) + }; + let target = full_target.as_deref().unwrap_or(rust_target); + + handle_record(record, target, &message, lineno, &pathname, level)?; + + Ok(()) +} + +fn handle_record( + #[allow(unused_variables)] record: Bound<'_, PyAny>, + target: &str, + message: &str, + lineno: u32, + pathname: &str, + level: u8, +) -> PyResult<()> { + // If log feature is enabled, use log::logger + let level = crate::level::get_level(level).0; + + { + let mut metadata_builder = log::MetadataBuilder::new(); + metadata_builder.target(target); + metadata_builder.level(level); + + let mut record_builder = log::Record::builder(); + + { + let kv_args = kv::find_kv_args(&record)?; + + let kv_source = kv_args.map(kv::KVSource); + if let Some(kv_source) = kv_source { + log::logger().log( + &record_builder + .metadata(metadata_builder.build()) + .args(format_args!("{}", &message)) + .line(Some(lineno)) + .file(Some(pathname)) + .module_path(Some(pathname)) + .key_values(&kv_source) + .build(), + ); + return Ok(()); + } + } + + log::logger().log( + &record_builder + .metadata(metadata_builder.build()) + .args(format_args!("{}", &message)) + .line(Some(lineno)) + .file(Some(pathname)) + .module_path(Some(pathname)) + .build(), + ); + } + + Ok(()) +} + +/// Registers the host_log function in rust as the event handler for Python's logging logger +/// This function needs to be called from within a pyo3 context as early as possible to ensure logging messages +/// arrive to the rust consumer. +pub fn setup_logging<'py>(py: Python<'py>, target: &str) -> PyResult<Bound<'py, PyAny>> { + let logger = LOGGER + .get_or_init_py_attached(py, || match setup_logging_inner(py, target) { + Ok(ok) => ok.unbind(), + Err(err) => { + panic!("Failed to initialize logger: {}", err); + } + }) + .clone_ref(py); + + Ok(logger.into_bound(py)) +} + +fn setup_logging_inner<'py>(py: Python<'py>, target: &str) -> PyResult<Bound<'py, PyAny>> { + let logging = py.import("logging")?; + + logging.setattr("host_log", wrap_pyfunction!(host_log, &logging)?)?; + + #[allow(clippy::uninlined_format_args)] + let code = CString::new(format!( + r#" +class HostHandler(Handler): + def __init__(self, level=0): + super().__init__(level=level) + + def emit(self, record: LogRecord): + host_log(record, "{}") + +oldBasicConfig = basicConfig +def basicConfig(*pargs, **kwargs): + if "handlers" not in kwargs: + kwargs["handlers"] = [HostHandler()] + return oldBasicConfig(*pargs, **kwargs) +"#, + target + ))?; + + let logging_scope = logging.dict(); + py.run(&code, Some(&logging_scope), None)?; + + let all = logging.index()?; + all.append("HostHandler")?; + + let logger = { + let get_logger = logging_scope.get_item("getLogger")?; + get_logger.call((target,), None)? + }; + + { + let basic_config = logging_scope.get_item("basicConfig")?; + basic_config.call( + (), + { + let dict = PyDict::new(py); + + // Ensure that all events are logged by setting + // the log level to NOTSET (we filter on rust's side) + dict.set_item("level", 0)?; + + Some(dict) + } + .as_ref(), + )?; + } + + { + let add_filter = logger.getattr("addFilter")?; + add_filter.call((wrap_pyfunction!(filter_error_log, &logging)?,), None)?; + } + + Ok(logger) +} diff --git a/crates/bytes/update.sh b/crates/yt_dlp/crates/pyo3-pylogger/update.sh index c1a0215..dd3e57e 100755 --- a/crates/bytes/update.sh +++ b/crates/yt_dlp/crates/pyo3-pylogger/update.sh @@ -1,9 +1,9 @@ -#!/usr/bin/env sh +#! /usr/bin/env sh # yt - A fully featured command line YouTube client # -# Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright (C) 2025 Dylan Bobby Storey <dylan.storey@gmail.com>, cpu <daniel@binaryparadox.net>, Warren Snipes <contact@warrensnipes.dev> +# SPDX-License-Identifier: Apache-2.0 # # This file is part of Yt. # @@ -13,3 +13,5 @@ cd "$(dirname "$0")" || exit 1 [ "$1" = "upgrade" ] && cargo upgrade --incompatible cargo update + +# vim: ft=sh diff --git a/crates/yt_dlp/examples/main.rs b/crates/yt_dlp/examples/main.rs new file mode 100644 index 0000000..e924407 --- /dev/null +++ b/crates/yt_dlp/examples/main.rs @@ -0,0 +1,15 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +fn main() { + let yt_dlp = yt_dlp::options::YoutubeDLOptions::new().build().unwrap(); + + dbg!(yt_dlp.version().unwrap()); +} diff --git a/crates/yt_dlp/src/info_json.rs b/crates/yt_dlp/src/info_json.rs new file mode 100644 index 0000000..402acb4 --- /dev/null +++ b/crates/yt_dlp/src/info_json.rs @@ -0,0 +1,56 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use pyo3::{ + Bound, Python, intern, + types::{PyAnyMethods, PyDict}, +}; + +pub type InfoJson = serde_json::Map<String, serde_json::Value>; + +/// # Panics +/// If expectation about python operations fail. +#[must_use] +pub fn json_loads( + input: serde_json::Map<String, serde_json::Value>, + py: Python<'_>, +) -> Bound<'_, PyDict> { + let json = py.import(intern!(py, "json")).expect("Module exists"); + let loads = json.getattr(intern!(py, "loads")).expect("Method exists"); + let self_str = serde_json::to_string(&serde_json::Value::Object(input)).expect("Vaild json"); + let dict = loads + .call((self_str,), None) + .expect("Vaild json is always a valid dict"); + + dict.cast_into().expect("Should always be a dict") +} + +/// # Panics +/// If expectation about python operations fail. +#[must_use] +pub fn json_dumps(input: &Bound<'_, PyDict>) -> serde_json::Map<String, serde_json::Value> { + let py = input.py(); + + let json = py.import(intern!(py, "json")).expect("Module exists"); + let dumps = json.getattr(intern!(py, "dumps")).expect("Method exists"); + let dict = dumps + .call((input,), None) + .map_err(|err| err.print(py)) + .expect("Might not always work, but for our dicts it works"); + + let string: String = dict.extract().expect("Should always be a string"); + + let value: serde_json::Value = serde_json::from_str(&string).expect("Should be valid json"); + + match value { + serde_json::Value::Object(map) => map, + _ => unreachable!("These should not be json.dumps output"), + } +} diff --git a/crates/yt_dlp/src/lib.rs b/crates/yt_dlp/src/lib.rs index dd42fc6..4b252de 100644 --- a/crates/yt_dlp/src/lib.rs +++ b/crates/yt_dlp/src/lib.rs @@ -1,216 +1,137 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + //! The `yt_dlp` interface is completely contained in the [`YoutubeDL`] structure. -use std::{self, env, mem, path::PathBuf}; - -use indexmap::IndexMap; -use log::{Level, debug, error, info, log_enabled}; -use logging::setup_logging; -use rustpython::{ - InterpreterConfig, - vm::{ - self, AsObject, Interpreter, PyObjectRef, PyPayload, PyRef, VirtualMachine, - builtins::{PyBaseException, PyBaseExceptionRef, PyDict, PyList, PyStr}, - function::{FuncArgs, KwArgs, PosArgs}, - py_io::Write, - suggestion::offer_suggestions, - }, +use std::path::PathBuf; + +use log::{debug, info}; +use pyo3::{ + Bound, Py, PyAny, Python, intern, + types::{PyAnyMethods, PyDict, PyIterator, PyList}, }; use url::Url; -mod logging; +use crate::{ + info_json::{InfoJson, json_dumps, json_loads}, + python_error::{IntoPythonError, PythonError}, +}; + +pub mod info_json; +pub mod options; +pub mod post_processors; pub mod progress_hook; +pub mod python_error; #[macro_export] macro_rules! json_get { - ($value:expr, $name:literal, $into:ident) => { - $crate::json_cast!($value.get($name).expect("Should exist"), $into) - }; + ($value:expr, $name:literal, $into:ident) => {{ + match $value.get($name) { + Some(val) => $crate::json_cast!(@log_key $name, val, $into), + None => panic!( + concat!( + "Expected '", + $name, + "' to be a key for the '", + stringify!($value), + "' object: {:#?}" + ), + $value + ), + } + }}; +} + +#[macro_export] +macro_rules! json_try_get { + ($value:expr, $name:literal, $into:ident) => {{ + if let Some(val) = $value.get($name) { + if val.is_null() { + None + } else { + Some(json_cast!(@log_key $name, val, $into)) + } + } else { + None + } + }}; } #[macro_export] macro_rules! json_cast { - ($value:expr, $into:ident) => { - $value.$into().expect(concat!( - "Should be able to cast value into ", - stringify!($into) - )) - }; + ($value:expr, $into:ident) => {{ + let value_name = stringify!($value); + json_cast!(@log_key value_name, $value, $into) + }}; + + (@log_key $name:expr, $value:expr, $into:ident) => {{ + match $value.$into() { + Some(result) => result, + None => panic!( + concat!( + "Expected to be able to cast '{}' value (which is '{:?}') ", + stringify!($into) + ), + $name, + $value + ), + } + }}; } +macro_rules! py_kw_args { + ($py:expr => $($kw_arg_name:ident = $kw_arg_val:expr),*) => {{ + use $crate::python_error::IntoPythonError; + + let dict = PyDict::new($py); + + $( + dict.set_item(stringify!($kw_arg_name), $kw_arg_val).wrap_exc($py)?; + )* + + Some(dict) + } + .as_ref()}; +} +pub(crate) use py_kw_args; + /// The core of the `yt_dlp` interface. +#[derive(Debug)] pub struct YoutubeDL { - interpreter: Interpreter, - youtube_dl_class: PyObjectRef, - yt_dlp_module: PyObjectRef, + inner: Py<PyAny>, options: serde_json::Map<String, serde_json::Value>, } -impl std::fmt::Debug for YoutubeDL { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // TODO(@bpeetz): Use something useful here. <2025-06-13> - f.write_str("YoutubeDL") - } -} - impl YoutubeDL { - /// Construct this instance from options. - /// - /// # Panics - /// If `yt_dlp` changed their interface. + /// Fetch the underlying `yt_dlp` and `python` version. /// /// # Errors - /// If a python call fails. - pub fn from_options(mut options: YoutubeDLOptions) -> Result<Self, build::Error> { - let mut settings = vm::Settings::default(); - if let Ok(python_path) = env::var("PYTHONPATH") { - for path in python_path.split(':') { - settings.path_list.push(path.to_owned()); - } - } else { - error!( - "No PYTHONPATH found or invalid utf8. \ - This means, that you probably did not \ - supply the yt_dlp!" - ); - } - - settings.install_signal_handlers = false; - - // NOTE(@bpeetz): Another value leads to an internal codegen error. <2025-06-13> - settings.optimize = 0; - - settings.isolated = true; - - let interpreter = InterpreterConfig::new() - .init_stdlib() - .settings(settings) - .interpreter(); - - let output_options = options.options.clone(); - - let (yt_dlp_module, youtube_dl_class) = match interpreter.enter(|vm| { - let yt_dlp_module = vm.import("yt_dlp", 0)?; - let class = yt_dlp_module.get_attr("YoutubeDL", vm)?; - - let maybe_hook = mem::take(&mut options.progress_hook); - let opts = options.into_py_dict(vm); - if let Some(function) = maybe_hook { - opts.get_or_insert(vm, vm.new_pyobj("progress_hooks"), || { - let hook: PyObjectRef = vm.new_function("progress_hook", function).into(); - vm.new_pyobj(vec![hook]) - }) - .expect("Should work?"); - } - - { - // Unconditionally set a logger. - // Otherwise, yt_dlp will log to stderr. - - /// Is the specified record to be logged? Returns false for no, - /// true for yes. Filters can either modify log records in-place or - /// return a completely different record instance which will replace - /// the original log record in any future processing of the event. - fn filter_error_log(mut input: FuncArgs, vm: &VirtualMachine) -> bool { - let record = input.args.remove(0); - - // Filter out all error logs (they are propagated as rust errors) - let levelname: PyRef<PyStr> = record - .get_attr("levelname", vm) - .expect("This should exist") - .downcast() - .expect("This should be a String"); - - let return_value = levelname.as_str() != "ERROR"; - - if log_enabled!(Level::Debug) && !return_value { - let message: String = { - let get_message = record.get_attr("getMessage", vm).expect("Is set"); - let message: PyRef<PyStr> = get_message - .call((), vm) - .expect("Can be called") - .downcast() - .expect("Downcasting works"); - - message.as_str().to_owned() - }; - - debug!("Swollowed error message: '{message}'"); - } - return_value - } - - let logging = setup_logging(vm, "yt_dlp")?; - let ytdl_logger = { - let get_logger = logging.get_item("getLogger", vm)?; - get_logger.call(("yt_dlp",), vm)? - }; - - { - let args = FuncArgs::new( - PosArgs::new(vec![]), - KwArgs::new({ - let mut map = IndexMap::new(); - // Ensure that all events are logged by setting - // the log level to NOTSET (we filter on rust's side) - map.insert("level".to_owned(), vm.new_pyobj(0)); - map - }), - ); - - let basic_config = logging.get_item("basicConfig", vm)?; - basic_config.call(args, vm)?; - } - - { - let add_filter = ytdl_logger.get_attr("addFilter", vm)?; - add_filter.call( - (vm.new_function("yt_dlp_error_filter", filter_error_log),), - vm, - )?; - } - - opts.set_item("logger", ytdl_logger, vm)?; - } - - let youtube_dl_class = class.call((opts,), vm)?; - - Ok::<_, PyRef<PyBaseException>>((yt_dlp_module, youtube_dl_class)) - }) { - Ok(ok) => Ok(ok), - Err(err) => { - // TODO(@bpeetz): Do we want to run `interpreter.finalize` here? <2025-06-14> - // interpreter.finalize(Some(err)); - interpreter.enter(|vm| { - let buffer = process_exception(vm, &err); - Err(build::Error::Python(buffer)) - }) - } - }?; - - Ok(Self { - interpreter, - youtube_dl_class, - yt_dlp_module, - options: output_options, + /// If python attribute access fails. + pub fn version(&self) -> Result<(String, String), PythonError> { + Python::attach(|py| { + let yt_dlp = py + .import(intern!(py, "yt_dlp")) + .wrap_exc(py)? + .getattr(intern!(py, "version")) + .wrap_exc(py)? + .getattr(intern!(py, "__version__")) + .wrap_exc(py)? + .extract() + .wrap_exc(py)?; + + let python = py.version(); + + Ok((yt_dlp, python.to_owned())) }) } - /// # Panics - /// - /// If `yt_dlp` changed their location or type of `__version__`. - pub fn version(&self) -> String { - let str_ref: PyRef<PyStr> = self.interpreter.enter_and_expect( - |vm| { - let version_module = self.yt_dlp_module.get_attr("version", vm)?; - let version = version_module.get_attr("__version__", vm)?; - let version = version.downcast().expect("This should always be a string"); - Ok(version) - }, - "yt_dlp version location has changed", - ); - str_ref.to_string() - } - /// Download a given list of URLs. /// Returns the paths they were downloaded to. /// @@ -224,8 +145,9 @@ impl YoutubeDL { let info_json = self.extract_info(url, true, true)?; // Try to work around yt-dlp type weirdness - let result_string = if let Some(filename) = info_json.get("filename") { - PathBuf::from(json_cast!(filename, as_str)) + let result_string = if let Some(filename) = json_try_get!(info_json, "filename", as_str) + { + PathBuf::from(filename) } else { PathBuf::from(json_get!( json_cast!( @@ -267,63 +189,66 @@ impl YoutubeDL { download: bool, process: bool, ) -> Result<InfoJson, extract_info::Error> { - match self.interpreter.enter(|vm| { - let pos_args = PosArgs::new(vec![vm.new_pyobj(url.to_string())]); - - let kw_args = KwArgs::new({ - let mut map = IndexMap::new(); - map.insert("download".to_owned(), vm.new_pyobj(download)); - map.insert("process".to_owned(), vm.new_pyobj(process)); - map - }); + Python::attach(|py| { + let inner = self + .inner + .bind(py) + .getattr(intern!(py, "extract_info")) + .wrap_exc(py)?; - let fun_args = FuncArgs::new(pos_args, kw_args); - - let inner = self.youtube_dl_class.get_attr("extract_info", vm)?; let result = inner - .call_with_args(fun_args, vm)? - .downcast::<PyDict>() + .call( + (url.to_string(),), + py_kw_args!(py => download = download, process = process), + ) + .wrap_exc(py)? + .cast_into::<PyDict>() .expect("This is a dict"); // Resolve the generator object - if let Ok(generator) = result.get_item("entries", vm) { - if generator.payload_is::<PyList>() { + if let Ok(generator) = result.get_item(intern!(py, "entries")) { + if generator.is_instance_of::<PyList>() { // already resolved. Do nothing - } else { - let max_backlog = self.options.get("playlistend").map_or(10000, |value| { - usize::try_from(value.as_u64().expect("Works")).expect("Should work") - }); + } else if let Ok(generator) = generator.cast::<PyIterator>() { + // A python generator object. + let max_backlog = json_try_get!(self.options, "playlistend", as_u64) + .map_or(10000, |playlistend| { + usize::try_from(playlistend).expect("Should work") + }); let mut out = vec![]; - let next = generator.get_attr("__next__", vm)?; - while let Ok(output) = next.call((), vm) { - out.push(output); + for output in generator { + out.push(output.wrap_exc(py)?); if out.len() == max_backlog { break; } } - result.set_item("entries", vm.new_pyobj(out), vm)?; - } - } - let result = { - let sanitize = self.youtube_dl_class.get_attr("sanitize_info", vm)?; - let value = sanitize.call((result,), vm)?; + result.set_item(intern!(py, "entries"), out).wrap_exc(py)?; + } else { + // Probably some sort of paged list (`OnDemand` or otherwise) + let max_backlog = json_try_get!(self.options, "playlistend", as_u64) + .map_or(10000, |playlistend| { + usize::try_from(playlistend).expect("Should work") + }); - value.downcast::<PyDict>().expect("This should stay a dict") - }; + let next = generator.getattr(intern!(py, "getslice")).wrap_exc(py)?; - let result_json = json_dumps(result, vm); + let output = next + .call((), py_kw_args!(py => start = 0, end = max_backlog)) + .wrap_exc(py)?; - Ok::<_, PyRef<PyBaseException>>(result_json) - }) { - Ok(ok) => Ok(ok), - Err(err) => self.interpreter.enter(|vm| { - let buffer = process_exception(vm, &err); - Err(extract_info::Error::Python(buffer)) - }), - } + result + .set_item(intern!(py, "entries"), output) + .wrap_exc(py)?; + } + } + + let result = self.prepare_info_json(&result, py)?; + + Ok(result) + }) } /// Take the (potentially modified) result of the information extractor (i.e., @@ -344,263 +269,110 @@ impl YoutubeDL { ie_result: InfoJson, download: bool, ) -> Result<InfoJson, process_ie_result::Error> { - match self.interpreter.enter(|vm| { - let pos_args = PosArgs::new(vec![vm.new_pyobj(json_loads(ie_result, vm))]); + Python::attach(|py| { + let inner = self + .inner + .bind(py) + .getattr(intern!(py, "process_ie_result")) + .wrap_exc(py)?; - let kw_args = KwArgs::new({ - let mut map = IndexMap::new(); - map.insert("download".to_owned(), vm.new_pyobj(download)); - map - }); - - let fun_args = FuncArgs::new(pos_args, kw_args); - - let inner = self.youtube_dl_class.get_attr("process_ie_result", vm)?; let result = inner - .call_with_args(fun_args, vm)? - .downcast::<PyDict>() + .call( + (json_loads(ie_result, py),), + py_kw_args!(py => download = download), + ) + .wrap_exc(py)? + .cast_into::<PyDict>() .expect("This is a dict"); - let result = { - let sanitize = self.youtube_dl_class.get_attr("sanitize_info", vm)?; - let value = sanitize.call((result,), vm)?; - - value.downcast::<PyDict>().expect("This should stay a dict") - }; - - let result_json = json_dumps(result, vm); - - Ok::<_, PyRef<PyBaseException>>(result_json) - }) { - Ok(ok) => Ok(ok), - Err(err) => self.interpreter.enter(|vm| { - let buffer = process_exception(vm, &err); - Err(process_ie_result::Error::Python(buffer)) - }), - } - } -} + let result = self.prepare_info_json(&result, py)?; -#[allow(missing_docs)] -pub mod process_ie_result { - #[derive(Debug, thiserror::Error)] - pub enum Error { - #[error("Python threw an exception: {0}")] - Python(String), - } -} -#[allow(missing_docs)] -pub mod extract_info { - #[derive(Debug, thiserror::Error)] - pub enum Error { - #[error("Python threw an exception: {0}")] - Python(String), + Ok(result) + }) } -} - -pub type InfoJson = serde_json::Map<String, serde_json::Value>; -pub type ProgressHookFunction = fn(input: FuncArgs, vm: &VirtualMachine); - -/// Options, that are used to customize the download behaviour. -/// -/// In the future, this might get a Builder api. -/// -/// See `help(yt_dlp.YoutubeDL())` from python for a full list of available options. -#[derive(Default, Debug)] -pub struct YoutubeDLOptions { - options: serde_json::Map<String, serde_json::Value>, - progress_hook: Option<ProgressHookFunction>, -} -impl YoutubeDLOptions { - #[must_use] - pub fn new() -> Self { - Self { - options: serde_json::Map::new(), - progress_hook: None, - } - } + /// Close this [`YoutubeDL`] instance, and stop all currently running downloads. + /// + /// # Errors + /// If python operations fail. + pub fn close(&self) -> Result<(), close::Error> { + Python::attach(|py| { + debug!("Closing YoutubeDL."); - #[must_use] - pub fn set(self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self { - let mut options = self.options; - options.insert(key.into(), value.into()); + let inner = self + .inner + .bind(py) + .getattr(intern!(py, "close")) + .wrap_exc(py)?; - Self { - options, - progress_hook: self.progress_hook, - } - } + inner.call0().wrap_exc(py)?; - #[must_use] - pub fn with_progress_hook(self, progress_hook: ProgressHookFunction) -> Self { - if let Some(_previous_hook) = self.progress_hook { - todo!() - } else { - Self { - options: self.options, - progress_hook: Some(progress_hook), - } - } + Ok(()) + }) } - /// # Errors - /// If the underlying [`YoutubeDL::from_options`] errors. - pub fn build(self) -> Result<YoutubeDL, build::Error> { - YoutubeDL::from_options(self) - } + fn prepare_info_json<'py>( + &self, + info: &Bound<'py, PyDict>, + py: Python<'py>, + ) -> Result<InfoJson, prepare::Error> { + let sanitize = self + .inner + .bind(py) + .getattr(intern!(py, "sanitize_info")) + .wrap_exc(py)?; - #[must_use] - pub fn from_json_options(options: serde_json::Map<String, serde_json::Value>) -> Self { - Self { - options, - progress_hook: None, - } - } + let value = sanitize.call((info,), None).wrap_exc(py)?; - #[must_use] - pub fn get(&self, key: &str) -> Option<&serde_json::Value> { - self.options.get(key) - } + let result = value.cast::<PyDict>().expect("This should stay a dict"); - fn into_py_dict(self, vm: &VirtualMachine) -> PyRef<PyDict> { - json_loads(self.options, vm) + Ok(json_dumps(result)) } } #[allow(missing_docs)] -pub mod build { +pub mod close { + use crate::python_error::PythonError; + #[derive(Debug, thiserror::Error)] pub enum Error { - #[error("Python threw an exception: {0}")] - Python(String), - - #[error("Io error: {0}")] - Io(#[from] std::io::Error), + #[error(transparent)] + Python(#[from] PythonError), } } +#[allow(missing_docs)] +pub mod process_ie_result { + use crate::{prepare, python_error::PythonError}; -fn json_loads( - input: serde_json::Map<String, serde_json::Value>, - vm: &VirtualMachine, -) -> PyRef<PyDict> { - let json = vm.import("json", 0).expect("Module exists"); - let loads = json.get_attr("loads", vm).expect("Method exists"); - let self_str = serde_json::to_string(&serde_json::Value::Object(input)).expect("Vaild json"); - let dict = loads - .call((self_str,), vm) - .expect("Vaild json is always a valid dict"); - - dict.downcast().expect("Should always be a dict") -} + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error(transparent)] + Python(#[from] PythonError), -/// # Panics -/// If expectation about python operations fail. -pub fn json_dumps( - input: PyRef<PyDict>, - vm: &VirtualMachine, -) -> serde_json::Map<String, serde_json::Value> { - let json = vm.import("json", 0).expect("Module exists"); - let dumps = json.get_attr("dumps", vm).expect("Method exists"); - let dict = dumps - .call((input,), vm) - .map_err(|err| vm.print_exception(err)) - .expect("Might not always work, but for our dicts it works"); - - let string: PyRef<PyStr> = dict.downcast().expect("Should always be a string"); - - let real_string = string.to_str().expect("Should be valid utf8"); - - // { - // let mut file = File::create("debug.dump.json").unwrap(); - // write!(file, "{}", real_string).unwrap(); - // } - - let value: serde_json::Value = serde_json::from_str(real_string).expect("Should be valid json"); - - match value { - serde_json::Value::Object(map) => map, - _ => unreachable!("These should not be json.dumps output"), + #[error("Failed to prepare the info json")] + InfoJsonPrepare(#[from] prepare::Error), } } +#[allow(missing_docs)] +pub mod extract_info { + use crate::{prepare, python_error::PythonError}; -// Inlined and changed from `vm.write_exception_inner` -fn write_exception<W: Write>( - vm: &VirtualMachine, - output: &mut W, - exc: &PyBaseExceptionRef, -) -> Result<(), W::Error> { - let varargs = exc.args(); - let args_repr = { - match varargs.len() { - 0 => vec![], - 1 => { - let args0_repr = if true { - varargs[0] - .str(vm) - .unwrap_or_else(|_| PyStr::from("<element str() failed>").into_ref(&vm.ctx)) - } else { - varargs[0].repr(vm).unwrap_or_else(|_| { - PyStr::from("<element repr() failed>").into_ref(&vm.ctx) - }) - }; - vec![args0_repr] - } - _ => varargs - .iter() - .map(|vararg| { - vararg.repr(vm).unwrap_or_else(|_| { - PyStr::from("<element repr() failed>").into_ref(&vm.ctx) - }) - }) - .collect(), - } - }; - - let exc_class = exc.class(); - - if exc_class.fast_issubclass(vm.ctx.exceptions.syntax_error) { - unreachable!( - "A syntax error should never be raised, \ - as yt_dlp should not have them and neither our embedded code" - ); - } + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error(transparent)] + Python(#[from] PythonError), - let exc_name = exc_class.name(); - match args_repr.len() { - 0 => write!(output, "{exc_name}"), - 1 => write!(output, "{}: {}", exc_name, args_repr[0]), - _ => write!( - output, - "{}: ({})", - exc_name, - args_repr - .iter() - .map(|val| val.as_str()) - .collect::<Vec<_>>() - .join(", "), - ), - }?; - - match offer_suggestions(exc, vm) { - Some(suggestions) => { - write!(output, ". Did you mean: '{suggestions}'?") - } - None => Ok(()), + #[error("Failed to prepare the info json")] + InfoJsonPrepare(#[from] prepare::Error), } } +#[allow(missing_docs)] +pub mod prepare { + use crate::python_error::PythonError; -fn process_exception(vm: &VirtualMachine, err: &PyBaseExceptionRef) -> String { - let mut buffer = String::new(); - write_exception(vm, &mut buffer, err) - .expect("We are writing into an *in-memory* string, it will always work"); - - if log_enabled!(Level::Debug) { - let mut output = String::new(); - vm.write_exception(&mut output, err) - .expect("We are writing into an *in-memory* string, it will always work"); - debug!("Python threw an exception: {output}"); + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error(transparent)] + Python(#[from] PythonError), } - - buffer } diff --git a/crates/yt_dlp/src/logging.rs b/crates/yt_dlp/src/logging.rs deleted file mode 100644 index 5cb4c1d..0000000 --- a/crates/yt_dlp/src/logging.rs +++ /dev/null @@ -1,197 +0,0 @@ -// yt - A fully featured command line YouTube client -// -// Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -// SPDX-License-Identifier: GPL-3.0-or-later -// -// This file is part of Yt. -// -// You should have received a copy of the License along with this program. -// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. - -// This file is taken from: https://github.com/dylanbstorey/pyo3-pylogger/blob/d89e0d6820ebc4f067647e3b74af59dbc4941dd5/src/lib.rs -// It is licensed under the Apache 2.0 License, copyright up to 2024 by Dylan Storey -// It was modified by Benedikt Peetz 2024, 2025 - -use log::{Level, MetadataBuilder, Record, logger}; -use rustpython::vm::{ - PyObjectRef, PyRef, PyResult, VirtualMachine, - builtins::{PyInt, PyList, PyStr}, - convert::ToPyObject, - function::FuncArgs, -}; - -/// Consume a Python `logging.LogRecord` and emit a Rust `Log` instead. -fn host_log(mut input: FuncArgs, vm: &VirtualMachine) -> PyResult<()> { - let record = input.args.remove(0); - let rust_target = { - let base: PyRef<PyStr> = input.args.remove(0).downcast().expect("Should be a string"); - base.as_str().to_owned() - }; - - let level = { - let level: PyRef<PyInt> = record - .get_attr("levelno", vm)? - .downcast() - .expect("Should always be an int"); - level.as_u32_mask() - }; - let message = { - let get_message = record.get_attr("getMessage", vm)?; - let message: PyRef<PyStr> = get_message - .call((), vm)? - .downcast() - .expect("Downcasting works"); - - message.as_str().to_owned() - }; - - let pathname = { - let pathname: PyRef<PyStr> = record - .get_attr("pathname", vm)? - .downcast() - .expect("Is a string"); - - pathname.as_str().to_owned() - }; - - let lineno = { - let lineno: PyRef<PyInt> = record - .get_attr("lineno", vm)? - .downcast() - .expect("Is a number"); - - lineno.as_u32_mask() - }; - - let logger_name = { - let name: PyRef<PyStr> = record - .get_attr("name", vm)? - .downcast() - .expect("Should be a string"); - name.as_str().to_owned() - }; - - let full_target: Option<String> = if logger_name.trim().is_empty() || logger_name == "root" { - None - } else { - // Libraries (ex: tracing_subscriber::filter::Directive) expect rust-style targets like foo::bar, - // and may not deal well with "." as a module separator: - let logger_name = logger_name.replace('.', "::"); - Some(format!("{rust_target}::{logger_name}")) - }; - - let target = full_target.as_deref().unwrap_or(&rust_target); - - // error - let error_metadata = if level >= 40 { - MetadataBuilder::new() - .target(target) - .level(Level::Error) - .build() - } else if level >= 30 { - MetadataBuilder::new() - .target(target) - .level(Level::Warn) - .build() - } else if level >= 20 { - MetadataBuilder::new() - .target(target) - .level(Level::Info) - .build() - } else if level >= 10 { - MetadataBuilder::new() - .target(target) - .level(Level::Debug) - .build() - } else { - MetadataBuilder::new() - .target(target) - .level(Level::Trace) - .build() - }; - - logger().log( - &Record::builder() - .metadata(error_metadata) - .args(format_args!("{}", &message)) - .line(Some(lineno)) - .file(None) - .module_path(Some(&pathname)) - .build(), - ); - - Ok(()) -} - -/// Registers the `host_log` function in rust as the event handler for Python's logging logger -/// This function needs to be called from within a pyo3 context as early as possible to ensure logging messages -/// arrive to the rust consumer. -/// -/// # Panics -/// Only if internal assertions fail. -#[allow(clippy::module_name_repetitions)] -pub(super) fn setup_logging(vm: &VirtualMachine, target: &str) -> PyResult<PyObjectRef> { - let logging = vm.import("logging", 0)?; - - let scope = vm.new_scope_with_builtins(); - - for (key, value) in logging.dict().expect("Should be a dict") { - let key: PyRef<PyStr> = key.downcast().expect("Is a string"); - - scope.globals.set_item(key.as_str(), value, vm)?; - } - scope - .globals - .set_item("host_log", vm.new_function("host_log", host_log).into(), vm)?; - - let local_scope = scope.clone(); - vm.run_code_string( - local_scope, - format!( - r#" -class HostHandler(Handler): - def __init__(self, level=0): - super().__init__(level=level) - - def emit(self, record): - host_log(record,"{target}") - -oldBasicConfig = basicConfig -def basicConfig(*pargs, **kwargs): - if "handlers" not in kwargs: - kwargs["handlers"] = [HostHandler()] - return oldBasicConfig(*pargs, **kwargs) -"# - ) - .as_str(), - "<embedded logging inintializing code>".to_owned(), - )?; - - let all: PyRef<PyList> = logging - .get_attr("__all__", vm)? - .downcast() - .expect("Is a list"); - all.borrow_vec_mut().push(vm.new_pyobj("HostHandler")); - - // { - // let logging_dict = logging.dict().expect("Exists"); - // - // for (key, val) in scope.globals { - // let key: PyRef<PyStr> = key.downcast().expect("Is a string"); - // - // if !logging_dict.contains_key(key.as_str(), vm) { - // logging_dict.set_item(key.as_str(), val, vm)?; - // } - // } - // - // for (key, val) in scope.locals { - // let key: PyRef<PyStr> = key.downcast().expect("Is a string"); - // - // if !logging_dict.contains_key(key.as_str(), vm) { - // logging_dict.set_item(key.as_str(), val, vm)?; - // } - // } - // } - - Ok(scope.globals.to_pyobject(vm)) -} diff --git a/crates/yt_dlp/src/options.rs b/crates/yt_dlp/src/options.rs new file mode 100644 index 0000000..4b8906e --- /dev/null +++ b/crates/yt_dlp/src/options.rs @@ -0,0 +1,207 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::sync; + +use pyo3::{ + Bound, IntoPyObjectExt, PyAny, PyResult, Python, intern, + types::{PyAnyMethods, PyCFunction, PyDict, PyTuple}, +}; +use pyo3_pylogger::setup_logging; + +use crate::{ + YoutubeDL, json_loads, post_processors, py_kw_args, + python_error::{IntoPythonError, PythonError}, +}; + +pub type ProgressHookFunction = fn(py: Python<'_>) -> PyResult<Bound<'_, PyCFunction>>; +pub type PostProcessorFunction = fn(py: Python<'_>) -> PyResult<Bound<'_, PyAny>>; + +/// Options, that are used to customize the download behaviour. +/// +/// In the future, this might get a Builder api. +/// +/// See `help(yt_dlp.YoutubeDL())` from python for a full list of available options. +#[derive(Default, Debug)] +pub struct YoutubeDLOptions { + options: serde_json::Map<String, serde_json::Value>, + progress_hook: Option<ProgressHookFunction>, + post_processors: Vec<PostProcessorFunction>, +} + +impl YoutubeDLOptions { + #[must_use] + pub fn new() -> Self { + let me = Self { + options: serde_json::Map::new(), + progress_hook: None, + post_processors: vec![], + }; + + me.with_post_processor(post_processors::dearrow::process) + } + + #[must_use] + pub fn set(self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self { + let mut options = self.options; + options.insert(key.into(), value.into()); + + Self { options, ..self } + } + + #[must_use] + pub fn with_progress_hook(self, progress_hook: ProgressHookFunction) -> Self { + if let Some(_previous_hook) = self.progress_hook { + todo!() + } else { + Self { + progress_hook: Some(progress_hook), + ..self + } + } + } + + #[must_use] + pub fn with_post_processor(mut self, pp: PostProcessorFunction) -> Self { + self.post_processors.push(pp); + self + } + + /// # Errors + /// If the underlying [`YoutubeDL::from_options`] errors. + pub fn build(self) -> Result<YoutubeDL, build::Error> { + YoutubeDL::from_options(self) + } + + #[must_use] + pub fn from_json_options(options: serde_json::Map<String, serde_json::Value>) -> Self { + Self { + options, + ..Self::new() + } + } + + #[must_use] + pub fn get(&self, key: &str) -> Option<&serde_json::Value> { + self.options.get(key) + } +} + +impl YoutubeDL { + /// Construct this instance from options. + /// + /// # Panics + /// If `yt_dlp` changed their interface. + /// + /// # Errors + /// If a python call fails. + #[allow(clippy::too_many_lines)] + pub fn from_options(options: YoutubeDLOptions) -> Result<Self, build::Error> { + Python::initialize(); + + let output_options = options.options.clone(); + + let yt_dlp_module = Python::attach(|py| { + let opts = json_loads(options.options, py); + + { + static CALL_ONCE: sync::Once = sync::Once::new(); + + CALL_ONCE.call_once(|| { + py.run( + c" +import signal +signal.signal(signal.SIGINT, signal.SIG_DFL) + ", + None, + None, + ) + .unwrap_or_else(|err| { + panic!("Failed to disable python signal handling: {err}") + }); + }); + } + + { + // Setup the progress hook + if let Some(ph) = options.progress_hook { + opts.set_item(intern!(py, "progress_hooks"), vec![ph(py).wrap_exc(py)?]) + .wrap_exc(py)?; + } + } + + { + // Unconditionally set a logger. + // Otherwise, yt_dlp will log to stderr. + + let ytdl_logger = setup_logging(py, "yt_dlp").wrap_exc(py)?; + + opts.set_item(intern!(py, "logger"), ytdl_logger) + .wrap_exc(py)?; + } + + let inner = { + let p_params = opts.into_bound_py_any(py).wrap_exc(py)?; + let p_auto_init = true.into_bound_py_any(py).wrap_exc(py)?; + + py.import(intern!(py, "yt_dlp.YoutubeDL")) + .wrap_exc(py)? + .getattr(intern!(py, "YoutubeDL")) + .wrap_exc(py)? + .call1( + PyTuple::new( + py, + [ + p_params.into_bound_py_any(py).wrap_exc(py)?, + p_auto_init.into_bound_py_any(py).wrap_exc(py)?, + ], + ) + .wrap_exc(py)?, + ) + .wrap_exc(py)? + }; + + { + // Setup the post processors + let add_post_processor_fun = inner + .getattr(intern!(py, "add_post_processor")) + .wrap_exc(py)?; + + for pp in options.post_processors { + add_post_processor_fun + .call( + (pp(py).wrap_exc(py)?.into_bound_py_any(py).wrap_exc(py)?,), + // "when" can take any value in yt_dlp.utils.POSTPROCESS_WHEN + py_kw_args!(py => when = "pre_process"), + ) + .wrap_exc(py)?; + } + } + + Ok::<_, PythonError>(inner.unbind()) + })?; + + Ok(Self { + inner: yt_dlp_module, + options: output_options, + }) + } +} + +#[allow(missing_docs)] +pub mod build { + use crate::python_error::PythonError; + + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error(transparent)] + Python(#[from] PythonError), + } +} diff --git a/crates/yt_dlp/src/post_processors/dearrow.rs b/crates/yt_dlp/src/post_processors/dearrow.rs new file mode 100644 index 0000000..7787d68 --- /dev/null +++ b/crates/yt_dlp/src/post_processors/dearrow.rs @@ -0,0 +1,247 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use curl::easy::Easy; +use log::{error, info, trace, warn}; +use pyo3::{ + Bound, PyAny, PyErr, PyResult, Python, exceptions, intern, pyfunction, + types::{PyAnyMethods, PyDict, PyModule}, + wrap_pyfunction, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + pydict_cast, pydict_get, + python_error::{IntoPythonError, PythonError}, +}; + +/// # Errors +/// - If the underlying function returns an error. +/// - If python operations fail. +pub fn process(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> { + #[pyfunction] + fn actual_processor(info_json: Bound<'_, PyDict>) -> PyResult<Bound<'_, PyDict>> { + let output = match unwrapped_process(info_json) { + Ok(ok) => ok, + Err(err) => { + return Err(PyErr::new::<exceptions::PyRuntimeError, _>(err.to_string())); + } + }; + Ok(output) + } + + let module = PyModule::new(py, "rust_post_processors")?; + let scope = PyDict::new(py); + scope.set_item( + intern!(py, "actual_processor"), + wrap_pyfunction!(actual_processor, module)?, + )?; + py.run( + c" +import yt_dlp + +class DeArrow(yt_dlp.postprocessor.PostProcessor): + def run(self, info): + info = actual_processor(info) + return [], info + +inst = DeArrow() +", + Some(&scope), + None, + )?; + + Ok(scope.get_item(intern!(py, "inst"))?.cast_into()?) +} + +/// # Errors +/// If the API access fails. +pub fn unwrapped_process(info: Bound<'_, PyDict>) -> Result<Bound<'_, PyDict>, Error> { + if pydict_get!(info, "extractor_key", String).as_str() != "Youtube" { + return Ok(info); + } + + let mut retry_num = 3; + let mut output: DeArrowApi = { + loop { + let output_bytes = { + let mut dst = Vec::new(); + + let mut easy = Easy::new(); + easy.url( + format!( + "https://sponsor.ajay.app/api/branding?videoID={}", + pydict_get!(info, "id", String) + ) + .as_str(), + )?; + + let mut transfer = easy.transfer(); + transfer.write_function(|data| { + dst.extend_from_slice(data); + Ok(data.len()) + })?; + transfer.perform()?; + drop(transfer); + + dst + }; + + match serde_json::from_slice(&output_bytes) { + Ok(ok) => break ok, + Err(err) => { + if retry_num > 0 { + trace!( + "DeArrow: Api access failed, trying again ({retry_num} retries left)" + ); + retry_num -= 1; + } else { + let err: serde_json::Error = err; + return Err(err.into()); + } + } + } + } + }; + + // We pop the titles, so we need this vector reversed. + output.titles.reverse(); + + let title_len = output.titles.len(); + let mut iterator = output.titles.clone(); + let selected = loop { + let Some(title) = iterator.pop() else { + break false; + }; + + if (title.locked || title.votes < 1) && title_len > 1 { + info!( + "DeArrow: Skipping title {:#?}, as it is not good enough", + title.value + ); + // Skip titles that are not “good” enough. + continue; + } + + update_title(&info, &title.value).wrap_exc(info.py())?; + + break true; + }; + + if !selected && title_len != 0 { + // No title was selected, even though we had some titles. + // Just pick the first one in this case. + update_title(&info, &output.titles[0].value).wrap_exc(info.py())?; + } + + Ok(info) +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error(transparent)] + Python(#[from] PythonError), + + #[error("Failed to access the DeArrow api: {0}")] + Get(#[from] curl::Error), + + #[error("Failed to deserialize a api json return object: {0}")] + Deserialize(#[from] serde_json::Error), +} + +fn update_title(info: &Bound<'_, PyDict>, new_title: &str) -> PyResult<()> { + let py = info.py(); + + assert!(!info.contains(intern!(py, "original_title"))?); + + if let Ok(old_title) = info.get_item(intern!(py, "title")) { + warn!( + "DeArrow: Updating title from {:#?} to {:#?}", + pydict_cast!(old_title, &str), + new_title + ); + + info.set_item(intern!(py, "original_title"), old_title) + .expect("We checked, it is a new key"); + } else { + warn!("DeArrow: Setting title to {new_title:#?}"); + } + + let cleaned_title = { + // NOTE(@bpeetz): DeArrow uses `>` as a “Don't format the next word” mark. + // They should be removed, if one does not use a auto-formatter. <2025-06-16> + new_title.replace('>', "") + }; + + info.set_item(intern!(py, "title"), cleaned_title) + .expect("This should work?"); + + Ok(()) +} + +#[derive(Serialize, Deserialize)] +/// See: <https://wiki.sponsor.ajay.app/w/API_Docs/DeArrow> +struct DeArrowApi { + titles: Vec<Title>, + thumbnails: Vec<Thumbnail>, + + #[serde(alias = "randomTime")] + random_time: Option<f64>, + + #[serde(alias = "videoDuration")] + video_duration: Option<f64>, + + #[serde(alias = "casualVotes")] + casual_votes: Vec<CasualVote>, +} + +#[derive(Serialize, Deserialize)] +struct CasualVote { + id: String, + count: u32, + title: String, +} + +#[derive(Serialize, Deserialize, Clone)] +struct Title { + /// Note: Titles will sometimes contain > before a word. + /// This tells the auto-formatter to not format a word. + /// If you have no auto-formatter, you can ignore this and replace it with an empty string + #[serde(alias = "title")] + value: String, + + original: bool, + votes: u64, + locked: bool, + + #[serde(alias = "UUID")] + uuid: String, + + /// only present if requested + #[serde(alias = "userID")] + user_id: Option<String>, +} + +#[derive(Serialize, Deserialize)] +struct Thumbnail { + // null if original is true + timestamp: Option<f64>, + + original: bool, + votes: u64, + locked: bool, + + #[serde(alias = "UUID")] + uuid: String, + + /// only present if requested + #[serde(alias = "userID")] + user_id: Option<String>, +} diff --git a/crates/yt_dlp/src/post_processors/mod.rs b/crates/yt_dlp/src/post_processors/mod.rs new file mode 100644 index 0000000..d9be3f5 --- /dev/null +++ b/crates/yt_dlp/src/post_processors/mod.rs @@ -0,0 +1,48 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +pub mod dearrow; + +#[macro_export] +macro_rules! pydict_get { + ($value:expr, $name:literal, $into:ty) => {{ + let item = $value.get_item(pyo3::intern!($value.py(), $name)); + match &item { + Ok(val) => $crate::pydict_cast!(val, $into), + Err(_) => panic!( + concat!( + "Expected '", + $name, + "' to be a key for the'", + stringify!($value), + "' py dictionary: {:#?}" + ), + $value + ), + } + }}; +} + +#[macro_export] +macro_rules! pydict_cast { + ($value:expr, $into:ty) => {{ + match $value.extract::<$into>() { + Ok(result) => result, + Err(val) => panic!( + concat!( + "Expected to be able to extract ", + stringify!($into), + " from value ({:#?})." + ), + val + ), + } + }}; +} diff --git a/crates/yt_dlp/src/progress_hook.rs b/crates/yt_dlp/src/progress_hook.rs index 7a7628a..7e5f8a5 100644 --- a/crates/yt_dlp/src/progress_hook.rs +++ b/crates/yt_dlp/src/progress_hook.rs @@ -1,41 +1,67 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + #[macro_export] -macro_rules! mk_python_function { +macro_rules! wrap_progress_hook { ($name:ident, $new_name:ident) => { - pub fn $new_name( - mut args: $crate::progress_hook::rustpython::vm::function::FuncArgs, - vm: &$crate::progress_hook::rustpython::vm::VirtualMachine, - ) { - use $crate::progress_hook::rustpython; - - let input = { - let dict: rustpython::vm::PyRef<rustpython::vm::builtins::PyDict> = args - .args - .remove(0) - .downcast() - .expect("The progress hook is always called with these args"); - let new_dict = rustpython::vm::builtins::PyDict::new_ref(&vm.ctx); - dict.into_iter() - .filter_map(|(name, value)| { - let real_name: rustpython::vm::PyRefExact<rustpython::vm::builtins::PyStr> = - name.downcast_exact(vm).expect("Is a string"); - let name_str = real_name.to_str().expect("Is a string"); - if name_str.starts_with('_') { - None - } else { - Some((name_str.to_owned(), value)) - } - }) - .for_each(|(key, value)| { - new_dict - .set_item(&key, value, vm) - .expect("This is a transpositions, should always be valid"); - }); - - $crate::json_dumps(new_dict, vm) - }; - $name(input).expect("Shall not fail!"); + pub(crate) fn $new_name( + py: yt_dlp::progress_hook::__priv::pyo3::Python<'_>, + ) -> yt_dlp::progress_hook::__priv::pyo3::PyResult< + yt_dlp::progress_hook::__priv::pyo3::Bound< + '_, + yt_dlp::progress_hook::__priv::pyo3::types::PyCFunction, + >, + > { + #[yt_dlp::progress_hook::__priv::pyo3::pyfunction] + #[pyo3(crate = "yt_dlp::progress_hook::__priv::pyo3")] + fn inner( + input: yt_dlp::progress_hook::__priv::pyo3::Bound< + '_, + yt_dlp::progress_hook::__priv::pyo3::types::PyDict, + >, + ) -> yt_dlp::progress_hook::__priv::pyo3::PyResult<()> { + let processed_input = { + let new_dict = yt_dlp::progress_hook::__priv::pyo3::types::PyDict::new(input.py()); + + input + .into_iter() + .filter_map(|(name, value)| { + let real_name = yt_dlp::progress_hook::__priv::pyo3::types::PyAnyMethods::extract::<String>(&name).expect("Should always be a string"); + + if real_name.starts_with('_') { + None + } else { + Some((real_name, value)) + } + }) + .for_each(|(key, value)| { + yt_dlp::progress_hook::__priv::pyo3::types::PyDictMethods::set_item(&new_dict, &key, value) + .expect("This is a transpositions, should always be valid"); + }); + yt_dlp::progress_hook::__priv::json_dumps(&new_dict) + }; + + $name(processed_input)?; + + Ok(()) + } + + let module = yt_dlp::progress_hook::__priv::pyo3::types::PyModule::new(py, "progress_hook")?; + let fun = yt_dlp::progress_hook::__priv::pyo3::wrap_pyfunction!(inner, module)?; + + Ok(fun) } }; } -pub use rustpython; +pub mod __priv { + pub use crate::info_json::{json_dumps, json_loads}; + pub use pyo3; +} diff --git a/crates/yt_dlp/src/python_error.rs b/crates/yt_dlp/src/python_error.rs new file mode 100644 index 0000000..0c442b3 --- /dev/null +++ b/crates/yt_dlp/src/python_error.rs @@ -0,0 +1,55 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 Benedikt Peetz <benedikt.peetz@b-peetz.de> +// SPDX-License-Identifier: GPL-3.0-or-later +// +// This file is part of Yt. +// +// You should have received a copy of the License along with this program. +// If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. + +use std::fmt::{self, Display}; + +use log::{Level, debug, log_enabled}; +use pyo3::{PyErr, Python, types::PyTracebackMethods}; + +#[derive(thiserror::Error, Debug)] +pub struct PythonError(pub String); + +pub(crate) trait IntoPythonError<T>: Sized { + fn wrap_exc(self, py: Python<'_>) -> Result<T, PythonError>; +} + +impl<T> IntoPythonError<T> for Result<T, PyErr> { + fn wrap_exc(self, py: Python<'_>) -> Result<T, PythonError> { + self.map_err(|exc| PythonError::from_exception(py, &exc)) + } +} + +impl Display for PythonError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Python threw an exception: {}", self.0) + } +} + +impl PythonError { + pub(super) fn from_exception(py: Python<'_>, exc: &PyErr) -> Self { + let buffer = process_exception(py, exc); + Self(buffer) + } +} + +pub(super) fn process_exception(py: Python<'_>, err: &PyErr) -> String { + if log_enabled!(Level::Debug) { + let mut output = err.to_string(); + + if let Some(tb) = err.traceback(py) { + output.push('\n'); + output.push_str(&tb.format().unwrap()); + } + + debug!("Python threw an exception: {output}"); + } + + err.to_string() +} diff --git a/crates/yt_dlp/update.sh b/crates/yt_dlp/update.sh index c1a0215..52c96b5 100755 --- a/crates/yt_dlp/update.sh +++ b/crates/yt_dlp/update.sh @@ -10,6 +10,4 @@ # You should have received a copy of the License along with this program. # If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -cd "$(dirname "$0")" || exit 1 -[ "$1" = "upgrade" ] && cargo upgrade --incompatible -cargo update +"$(dirname "$0")/crates/pyo3-pylogger/update.sh" "$@" diff --git a/flake.lock b/flake.lock index ba25c93..385fca0 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1749809936, - "narHash": "sha256-WPGRaj7CKfZukjcpxiacp29uYfMl3S9zFiEsVFv/HWM=", + "lastModified": 1764245472, + "narHash": "sha256-udCSZoKawLiNJKFQgikkN96tvVJggs61ihRq8sRt8tM=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ec4c48ddcd5718cc1312f432b800fbbfe63ee2fe", + "rev": "6c16b7ce10e5ef1fee3aecfb327be61888818a7a", "type": "github" }, "original": { @@ -29,11 +29,11 @@ ] }, "locked": { - "lastModified": 1749194973, - "narHash": "sha256-eEy8cuS0mZ2j/r/FE0/LYBSBcIs/MKOIVakwHVuqTfk=", + "lastModified": 1762938485, + "narHash": "sha256-AlEObg0syDl+Spi4LsZIBrjw+snSVU4T8MOeuZJUJjM=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "a05be418a1af1198ca0f63facb13c985db4cb3c5", + "rev": "5b4ee75aeefd1e2d5a1cc43cf6ba65eba75e83e4", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 1a6b43b..1a2a6ce 100644 --- a/flake.nix +++ b/flake.nix @@ -33,6 +33,8 @@ mpv-unwrapped.dev libffi openssl + zlib + curl.dev ]; nativeBuildInputs = with pkgs; [ @@ -43,7 +45,7 @@ SDL2 ]; - yt = pkgs.callPackage ./package/package.nix {inherit tree-sitter-yts;}; + yt = pkgs.callPackage ./nix/package.nix {inherit tree-sitter-yts;}; tree-sitter-yts = pkgs.callPackage ./tree-sitter-yts/package.nix {}; treefmtEval = import ./treefmt.nix {inherit treefmt-nix pkgs;}; @@ -101,11 +103,11 @@ pkgs.sqlite-interactive # yt_dlp - pkgs.yt-dlp pkgs.python3Packages.yt-dlp - pkgs.python3 + pkgs.python3Packages.chardet pkgs.jq pkgs.ffmpeg + pkgs.deno # Tree-sitter pkgs.nodejs diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..dc386dc --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,140 @@ +# yt - A fully featured command line YouTube client +# +# Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> +# SPDX-License-Identifier: GPL-3.0-or-later +# +# This file is part of Yt. +# +# You should have received a copy of the License along with this program. +# If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. +{ + lib, + rustPlatform, + installShellFiles, + # buildInputs + mpv-unwrapped, + ffmpeg-headless, + openssl, + libffi, + zlib, + curl, + deno, + # NativeBuildInputs + makeWrapper, + llvmPackages_latest, + glibc, + sqlite, + fd, + pkg-config, + SDL2, + python3, + # Passthru + tree-sitter-yts, +}: let + python = python3.withPackages (ps: [ps.yt-dlp]); +in + rustPlatform.buildRustPackage (finalAttrs: { + pname = "yt"; + inherit + ((builtins.fromTOML (builtins.readFile + ../Cargo.toml)).workspace.package) + version + ; + + src = lib.cleanSourceWith { + src = lib.cleanSource ./..; + filter = name: type: + (type == "directory") + || (builtins.elem (builtins.baseNameOf name) [ + "Cargo.toml" + "Cargo.lock" + "mkdb.sh" + "help.str" + "raw_error_warning.txt" + "golden.txt" + ]) + || (lib.strings.hasSuffix ".rs" (builtins.baseNameOf name)) + || (lib.strings.hasSuffix ".h" (builtins.baseNameOf name)) + || (lib.strings.hasSuffix ".sql" (builtins.baseNameOf name)); + }; + + nativeBuildInputs = [ + installShellFiles + makeWrapper + sqlite + fd + pkg-config + ]; + + buildInputs = [ + mpv-unwrapped.dev + ffmpeg-headless + openssl + libffi + zlib + curl.dev + python + deno + ]; + + checkInputs = [ + # Needed for the tests in `libmpv2` + SDL2 + ]; + + env = let + clang_version = + lib.versions.major + llvmPackages_latest.clang-unwrapped.version; + in { + # Needed for the compile time sqlite checks. + DATABASE_URL = "sqlite://database.sqlx"; + + # Required by yt_dlp + FFMPEG_LOCATION = "${lib.getExe ffmpeg-headless}"; + + # Tell pyo3 which python to use. + PYO3_PYTHON = lib.getExe python; + + # Needed for the libmpv2. + C_INCLUDE_PATH = "${glibc.dev}/include"; + LIBCLANG_INCLUDE_PATH = "${llvmPackages_latest.clang-unwrapped.lib}/lib/clang/${clang_version}/include"; + LIBCLANG_PATH = "${llvmPackages_latest.clang-unwrapped.lib}/lib/libclang.so"; + }; + + doCheck = true; + checkFlags = [ + # All of these tests try to connect to the internet to download test data. + "--skip=select::base::test_base" + "--skip=select::file::test_file" + "--skip=select::options::test_options" + "--skip=subscriptions::import_export::test_import_export" + "--skip=subscriptions::naming_subscriptions::test_naming_subscriptions" + "--skip=videos::downloading::test_downloading" + ]; + + prePatch = '' + # Generate the sqlite db, so that we can run the comp-time sqlite checks. + bash ./scripts/mkdb.sh + ''; + + passthru = { + inherit tree-sitter-yts; + }; + + cargoLock = { + lockFile = ../Cargo.lock; + }; + + postInstall = '' + installShellCompletion --cmd yt \ + --bash <(COMPLETE=bash $out/bin/yt) \ + --fish <(COMPLETE=fish $out/bin/yt) \ + --zsh <(COMPLETE=zsh $out/bin/yt) + + # NOTE: We cannot clear the path, because we need access to the $EDITOR. <2025-04-04> + wrapProgram $out/bin/yt \ + --prefix PATH : ${lib.makeBinPath finalAttrs.buildInputs} \ + --set YTDLP_NO_PLUGINS 1 + ''; + }) diff --git a/package/package.nix b/package/package.nix deleted file mode 100644 index c3bc338..0000000 --- a/package/package.nix +++ /dev/null @@ -1,75 +0,0 @@ -# yt - A fully featured command line YouTube client -# -# Copyright (C) 2024 Benedikt Peetz <benedikt.peetz@b-peetz.de> -# SPDX-License-Identifier: GPL-3.0-or-later -# -# This file is part of Yt. -# -# You should have received a copy of the License along with this program. -# If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. -{ - ffmpeg, - glibc, - lib, - llvmPackages_latest, - makeWrapper, - mpv-unwrapped, - python3, - rustPlatform, - sqlite, - tree-sitter-yts, - fd, -}: let - version = "0.1.0"; - - src = ./..; - - buildInputs = [ - (python3.withPackages (ps: [ps.yt-dlp])) - mpv-unwrapped.dev - ffmpeg - ]; -in - rustPlatform.buildRustPackage { - inherit version src buildInputs; - pname = "yt"; - - nativeBuildInputs = [ - makeWrapper - sqlite - fd - ]; - - env = let - clang_version = - lib.versions.major - llvmPackages_latest.clang-unwrapped.version; - in { - FFMPEG_LOCATION = "${lib.getExe ffmpeg}"; - PYO3_PYTHON = lib.getExe (python3.withPackages (ps: [ps.yt-dlp])); - - C_INCLUDE_PATH = "${glibc.dev}/include"; - DATABASE_URL = "sqlite://target/database.sqlx"; - LIBCLANG_INCLUDE_PATH = "${llvmPackages_latest.clang-unwrapped.lib}/lib/clang/${clang_version}/include"; - LIBCLANG_PATH = "${llvmPackages_latest.clang-unwrapped.lib}/lib/libclang.so"; - }; - - doCheck = false; - - prePatch = '' - bash ./scripts/mkdb.sh - ''; - - passthru = { - inherit tree-sitter-yts; - }; - - cargoLock = { - lockFile = ../Cargo.lock; - }; - - postInstall = '' - wrapProgram $out/bin/yt \ - --prefix PATH : ${lib.makeBinPath buildInputs} - ''; - } diff --git a/scripts/mkdb.sh b/scripts/mkdb.sh index f0c7740..6674841 100755 --- a/scripts/mkdb.sh +++ b/scripts/mkdb.sh @@ -11,7 +11,7 @@ # If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. root="$(dirname "$0")/.." -db="$root/target/database.sqlx" +db="${DATABASE_URL#sqlite://}" [ -f "$db" ] && rm "$db" [ -d "$root/target" ] || mkdir "$root/target" diff --git a/update.sh b/update.sh index 6672566..e90b290 100755 --- a/update.sh +++ b/update.sh @@ -15,8 +15,6 @@ nix flake update [ "$1" = "upgrade" ] && cargo upgrade --incompatible allow --pinned allow --recursive true cargo update --recursive -# TODO: Not all of these update are currently needed. <2024-11-04> ./crates/yt_dlp/update.sh "$@" ./crates/libmpv2/update.sh "$@" -./crates/bytes/update.sh "$@" # vim: ft=sh |
