diff options
139 files changed, 11409 insertions, 6666 deletions
diff --git a/.envrc b/.envrc index 0d242f2..bac1203 100644 --- a/.envrc +++ b/.envrc @@ -14,10 +14,12 @@ use flake root="$(git rev-parse --show-toplevel)" +PATH_add ./scripts PATH_add ./target/debug PATH_add ./target/release PATH_add ./target/profiling -PATH_add ./python_update -export PYO3_PYTHON=python3 -export DATABASE_URL="sqlite://$root/target/database.sqlite" +export DATABASE_URL="sqlite://$root/target/database.sqlx" + +# Plugins are not supported. +export YTDLP_NO_PLUGINS=1 diff --git a/Cargo.lock b/Cargo.lock index dde2b2d..51c6c6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -13,17 +13,24 @@ dependencies = [ [[package]] name = "adler2" -version = "2.0.0" +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 = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.3", "once_cell", "version_check", "zerocopy", @@ -61,9 +68,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.18" +version = "0.6.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933" dependencies = [ "anstyle", "anstyle-parse", @@ -76,43 +83,44 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" +checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" [[package]] name = "anstyle-parse" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882" dependencies = [ "anstyle", + "once_cell_polyfill", "windows-sys 0.59.0", ] [[package]] name = "anyhow" -version = "1.0.94" +version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" [[package]] name = "arrayref" @@ -127,6 +135,12 @@ 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" @@ -136,6 +150,15 @@ dependencies = [ ] [[package]] +name = "atomic" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +dependencies = [ + "bytemuck", +] + +[[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -143,9 +166,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", "cfg-if", @@ -164,20 +187,20 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bindgen" -version = "0.71.1" +version = "0.72.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +checksum = "4f72209734318d0b619a5e0f5129918b848c416e122a3c4ce054e03cb87b726f" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "cexpr", "clang-sys", - "itertools", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", @@ -196,24 +219,33 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" dependencies = [ "serde", ] [[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] name = "blake3" -version = "1.5.5" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.3.1", ] [[package]] @@ -226,10 +258,27 @@ 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.16.0" +version = "3.18.1" +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 = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" [[package]] name = "byteorder" @@ -239,22 +288,60 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.4.0" +version = "1.7.1" dependencies = [ "serde", ] [[package]] name = "bytes" -version = "1.9.0" +version = "1.10.1" +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 = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" +checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" +dependencies = [ + "rustversion", +] [[package]] name = "cc" -version = "1.2.4" +version = "1.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9157bbaa6b165880c27a4293a474c91cdcf265cc68cc829bf10be0964a391caf" +checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" dependencies = [ "shlex", ] @@ -270,22 +357,28 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.39" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -310,9 +403,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.23" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3135e7ec2ef7b10c6ed8950f0f792ed96ee093fa088608f1c76e569722700c84" +checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f" dependencies = [ "clap_builder", "clap_derive", @@ -320,9 +413,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.23" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30582fc632330df2bd26877bde0c1f4470d57c582bbc070376afcd04d8cb4838" +checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e" dependencies = [ "anstream", "anstyle", @@ -331,10 +424,22 @@ dependencies = [ ] [[package]] +name = "clap_complete" +version = "4.5.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aad5b1b4de04fead402672b48897030eec1f3bfe1550776322f59f6d6e6a5677" +dependencies = [ + "clap", + "clap_lex", + "is_executable", + "shlex", +] + +[[package]] name = "clap_derive" -version = "4.5.18" +version = "4.5.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" +checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce" dependencies = [ "heck", "proc-macro2", @@ -344,15 +449,38 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.4" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" +checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" + +[[package]] +name = "clipboard-win" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +dependencies = [ + "error-code", +] [[package]] name = "colorchoice" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +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", +] [[package]] name = "concurrent-queue" @@ -376,6 +504,22 @@ 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" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -383,18 +527,18 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crc" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" dependencies = [ "crc-catalog", ] @@ -406,6 +550,15 @@ 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" @@ -420,18 +573,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -448,18 +601,24 @@ dependencies = [ [[package]] name = "crossbeam-queue" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df0346b5d5e76ac2fe4e327c5fd1118d6be7c51dfb18f9b7922923f287471e35" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.20" +version = "0.8.21" +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 = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" [[package]] name = "crypto-common" @@ -472,10 +631,49 @@ dependencies = [ ] [[package]] +name = "csv-core" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d02f3b0da4c6504f86e9cd789d8dbafab48c2321be74e9987593de5a894d93d" +dependencies = [ + "memchr", +] + +[[package]] +name = "curl" +version = "0.4.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2d5c8f48d9c0c23250e52b55e82a6ab4fdba6650c931f5a0a57a43abda812b" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "schannel", + "socket2", + "windows-sys 0.59.0", +] + +[[package]] +name = "curl-sys" +version = "0.4.82+curl-8.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4d63638b5ec65f1a4ae945287b3fd035be4554bbaf211901159c9a2a74fb5be" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", + "windows-sys 0.59.0", +] + +[[package]] name = "der" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", "pem-rfc7468", @@ -495,6 +693,27 @@ 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" @@ -506,37 +725,96 @@ 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.13.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ "serde", ] [[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0" +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", +] + +[[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.10" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" dependencies = [ "libc", "windows-sys 0.59.0", ] [[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" @@ -549,9 +827,9 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.3.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" dependencies = [ "concurrent-queue", "parking", @@ -559,12 +837,52 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +dependencies = [ + "crc32fast", + "libz-rs-sys", + "miniz_oxide", +] + +[[package]] name = "flume" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -576,6 +894,27 @@ dependencies = [ ] [[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] name = "form_urlencoded" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -695,14 +1034,45 @@ 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.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", ] [[package]] @@ -713,33 +1083,38 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" [[package]] -name = "hashbrown" -version = "0.14.5" +name = "half" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" dependencies = [ - "ahash", - "allocator-api2", + "cfg-if", + "crunchy", ] [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hashlink" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.14.5", + "hashbrown", ] [[package]] @@ -750,9 +1125,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.4.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -761,6 +1136,12 @@ 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" @@ -780,25 +1161,26 @@ dependencies = [ [[package]] name = "home" -version = "0.5.9" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3d1354bf6b7235cb4a0576c2619fd4ed18183f689b12b006a0ee7329eeff9a5" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] name = "iana-time-zone" -version = "0.1.61" +version = "0.1.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", - "windows-core", + "windows-core 0.61.2", ] [[package]] @@ -812,21 +1194,22 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", + "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ "displaydoc", "litemap", @@ -836,30 +1219,10 @@ dependencies = [ ] [[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" - -[[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ "displaydoc", "icu_collections", @@ -867,68 +1230,55 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" dependencies = [ "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "potential_utf", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", + "icu_locale_core", "stable_deref_trait", "tinystr", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] [[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] name = "idna" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -941,9 +1291,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -951,29 +1301,64 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.7.0" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" +checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown", +] + +[[package]] +name = "inotify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" +dependencies = [ + "bitflags 2.9.1", + "inotify-sys", + "libc", ] [[package]] -name = "indoc" -version = "2.0.5" +name = "inotify-sys" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] [[package]] name = "is-terminal" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", +] + +[[package]] +name = "is_executable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a1b5bad6f9072935961dfbf1cced2f3d129963d091b6f69f007fe04e758ae2" +dependencies = [ + "winapi", ] [[package]] @@ -992,22 +1377,94 @@ dependencies = [ ] [[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] name = "itoa" -version = "1.0.14" +version = "1.0.15" +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 = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" +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.76" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 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" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + +[[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1017,46 +1474,117 @@ 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.168" +version = "0.2.173" +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 = "5aaeb2981e0606ca11d79718f8bb01164f1d6ed75080182d3abf017e6d244b6d" +checksum = "f003aa318c9f0ee69eb0ada7c78f5c9d2fedd2ceb274173b5c7ff475eee584a3" +dependencies = [ + "cc", +] [[package]] name = "libloading" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +checksum = "07033963ba89ebaf1584d767badaa2e8fcec21aedea6b8c0346d487d49c28667" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.53.2", ] [[package]] name = "libm" -version = "0.2.11" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libmpv2" -version = "1.4.0" +version = "1.7.1" dependencies = [ "crossbeam", "libmpv2-sys", "log", "sdl2", - "thiserror 2.0.7", ] [[package]] name = "libmpv2-sys" -version = "1.4.0" +version = "1.7.1" dependencies = [ "bindgen", ] [[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.9.1", + "libc", + "redox_syscall", +] + +[[package]] name = "libsqlite3-sys" version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1068,22 +1596,43 @@ dependencies = [ ] [[package]] +name = "libz-rs-sys" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "libz-sys" +version = "1.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] name = "linux-raw-sys" -version = "0.4.14" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "litemap" -version = "0.7.4" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "lock_api" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" dependencies = [ "autocfg", "scopeguard", @@ -1091,9 +1640,99 @@ dependencies = [ [[package]] name = "log" -version = "0.4.22" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "lz4_flex" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c592ad9fbc1b7838633b3ae55ce69b17d01150c72fcef229fbb819d39ee51ee" +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 = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" [[package]] name = "md-5" @@ -1107,9 +1746,18 @@ dependencies = [ [[package]] name = "memchr" -version = "2.7.4" +version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "memmap2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" +dependencies = [ + "libc", +] [[package]] name = "memoffset" @@ -1128,22 +1776,54 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.0" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", ] [[package]] name = "mio" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" dependencies = [ "libc", - "wasi", - "windows-sys 0.52.0", + "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", +] + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +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", ] [[package]] @@ -1157,16 +1837,30 @@ dependencies = [ ] [[package]] -name = "nucleo-matcher" -version = "0.3.1" +name = "notify" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +checksum = "2fee8403b3d66ac7b26aee6e40a897d85dc5ce26f44da36b8b73e987cc52e943" dependencies = [ - "memchr", - "unicode-segmentation", + "bitflags 2.9.1", + "filetime", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.59.0", ] [[package]] +name = "notify-types" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" + +[[package]] name = "num-bigint-dig" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1184,6 +1878,15 @@ 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" @@ -1214,25 +1917,121 @@ 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.5" +version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.20.2" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[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.1.0" +version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb37767f6569cd834a413442455e0f066d0d522de8630436e2a1761d9726ba56" +checksum = "48dd4f4a2c8405440fd0462561f0e5806bd0f77e86f51c761481bdd4018b545e" + +[[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" @@ -1242,9 +2041,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", "parking_lot_core", @@ -1252,9 +2051,9 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" dependencies = [ "cfg-if", "libc", @@ -1285,55 +2084,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] -name = "pest" -version = "2.7.15" +name = "phf" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b7cafe60d6cf8e62e1b9b2ea516a089c008945bb5a275416789e7db0bc199dc" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "memchr", - "thiserror 2.0.7", - "ucd-trie", + "phf_shared", ] [[package]] -name = "pest_derive" -version = "2.7.15" +name = "phf_codegen" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "816518421cfc6887a0d62bf441b6ffb4536fcc926395a69e1a85852d4363f57e" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "pest", - "pest_generator", + "phf_generator", + "phf_shared", ] [[package]] -name = "pest_generator" -version = "2.7.15" +name = "phf_generator" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d1396fd3a870fc7838768d171b4616d5c91f6cc25e377b673d714567d99377b" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn", + "phf_shared", + "rand", ] [[package]] -name = "pest_meta" -version = "2.7.15" +name = "phf_shared" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e58089ea25d717bfd31fb534e4f3afcc2cc569c70de3e239778991ea3b7dea" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "once_cell", - "pest", - "sha2", + "siphasher", ] [[package]] name = "pin-project-lite" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -1364,114 +2156,114 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +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.10.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] -name = "ppv-lite86" -version = "0.2.20" +name = "portable-atomic-util" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" dependencies = [ - "zerocopy", + "portable-atomic", ] [[package]] -name = "prettyplease" -version = "0.2.25" +name = "potential_utf" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d1ec885c64d0457d564db4ec299b2dae3f9c02808b8ad9c3a089c591b18033" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" dependencies = [ - "proc-macro2", - "syn", + "zerovec", ] [[package]] -name = "proc-macro2" -version = "1.0.92" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "unicode-ident", + "zerocopy", ] [[package]] -name = "pyo3" -version = "0.23.3" +name = "prettyplease" +version = "0.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e484fd2c8b4cb67ab05a318f1fd6fa8f199fcc30819f08f07d200809dba26c15" +checksum = "6837b9e10d61f45f987d50808f83d1ee3d206c66acf650c3e4ae2e1f6ddedf55" dependencies = [ - "cfg-if", - "indoc", - "libc", - "memoffset", - "once_cell", - "portable-atomic", - "pyo3-build-config", - "pyo3-ffi", - "pyo3-macros", - "unindent", + "proc-macro2", + "syn", ] [[package]] -name = "pyo3-build-config" -version = "0.23.3" +name = "proc-macro2" +version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0e0469a84f208e20044b98965e1561028180219e35352a2afaf2b942beff3b" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" dependencies = [ - "once_cell", - "target-lexicon", + "unicode-ident", ] [[package]] -name = "pyo3-ffi" -version = "0.23.3" +name = "pymath" +version = "0.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1547a7f9966f6f1a0f0227564a9945fe36b90da5a93b3933fc3dc03fae372d" +checksum = "5b66ab66a8610ce209d8b36cd0fecc3a15c494f715e0cb26f0586057f293abc9" dependencies = [ "libc", - "pyo3-build-config", ] [[package]] -name = "pyo3-macros" -version = "0.23.3" +name = "quote" +version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb6da8ec6fa5cedd1626c886fc8749bdcbb09424a86461eb8cdf096b7c33257" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", - "pyo3-macros-backend", - "quote", - "syn", ] [[package]] -name = "pyo3-macros-backend" -version = "0.23.3" +name = "r-efi" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" + +[[package]] +name = "radium" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38a385202ff5a92791168b1136afae5059d3ac118457bb7bc304c197c2d33e7d" +checksum = "db0b76288902db304c864a12046b73d2d895cc34a4bb8137baaeebe9978a072c" dependencies = [ - "heck", - "proc-macro2", - "pyo3-build-config", - "quote", - "syn", + "cfg-if", ] [[package]] -name = "quote" -version = "1.0.37" +name = "radix_trie" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" dependencies = [ - "proc-macro2", + "endian-type", + "nibble_vec", ] [[package]] @@ -1482,7 +2274,7 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1492,7 +2284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1501,16 +2293,36 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.16", +] + +[[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.8" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" dependencies = [ - "bitflags 2.6.0", + "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", ] [[package]] @@ -1543,10 +2355,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] +name = "result-like" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abf7172fef6a7d056b5c26bf6c826570267562d51697f4982ff3ba4aec68a9df" +dependencies = [ + "result-like-derive", +] + +[[package]] +name = "result-like-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d6574c02e894d66370cfc681e5d68fedbc9a548fb55b30a96b3f0ae22d0fe5" +dependencies = [ + "pmutil", + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "rsa" -version = "0.9.7" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" dependencies = [ "const-oid", "digest", @@ -1555,7 +2388,7 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", @@ -1563,24 +2396,85 @@ 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.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" [[package]] name = "rustc-hash" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fb8039b3032c191086b10f11f319a6e99e1e82889c5cc6046f515c9db1d497" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustix" -version = "0.38.42" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93dc38ecbab2eb790ff964bb77fa94faf256fd3e73285fd7ba0903b76bedb85" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ - "bitflags 2.6.0", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys", @@ -1588,10 +2482,407 @@ dependencies = [ ] [[package]] +name = "rustpython" +version = "0.4.0" +source = "git+https://github.com/RustPython/RustPython.git?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +dependencies = [ + "cfg-if", + "dirs-next", + "env_logger", + "lexopt", + "libc", + "log", + "ruff_python_parser", + "rustpython-compiler", + "rustpython-pylib", + "rustpython-stdlib", + "rustpython-vm", + "rustyline", +] + +[[package]] +name = "rustpython-codegen" +version = "0.4.0" +source = "git+https://github.com/RustPython/RustPython.git?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +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?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +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?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +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?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +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?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +dependencies = [ + "ruff_source_file", + "ruff_text_size", +] + +[[package]] +name = "rustpython-derive" +version = "0.4.0" +source = "git+https://github.com/RustPython/RustPython.git?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +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?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +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" +dependencies = [ + "once_cell", +] + +[[package]] +name = "rustpython-literal" +version = "0.4.0" +source = "git+https://github.com/RustPython/RustPython.git?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +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?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +dependencies = [ + "glob", + "rustpython-compiler-core", + "rustpython-derive", +] + +[[package]] +name = "rustpython-sre_engine" +version = "0.4.0" +source = "git+https://github.com/RustPython/RustPython.git?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +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?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +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-bidi-mirroring", + "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?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +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", +] + +[[package]] +name = "rustpython-wtf8" +version = "0.4.0" +source = "git+https://github.com/RustPython/RustPython.git?rev=6a992d4f#6a992d4fa2ef4f15f362b54c43aa225c3b552374" +dependencies = [ + "ascii", + "bstr", + "itertools 0.14.0", + "memchr", +] + +[[package]] +name = "rustversion" +version = "1.0.21" +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", +] + +[[package]] name = "ryu" -version = "1.0.18" +version = "1.0.20" +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 = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] [[package]] name = "scopeguard" @@ -1624,18 +2915,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.216" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.216" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -1644,9 +2935,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.133" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", @@ -1656,9 +2947,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] @@ -1676,6 +2967,17 @@ 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" @@ -1688,9 +2990,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -1698,6 +3000,16 @@ 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" @@ -1705,9 +3017,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" dependencies = [ "libc", ] @@ -1719,32 +3031,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] name = "slab" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] +checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" [[package]] name = "smallvec" -version = "1.13.2" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" dependencies = [ "serde", ] [[package]] name = "socket2" -version = "0.5.8" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", "windows-sys 0.52.0", @@ -1770,20 +3085,10 @@ dependencies = [ ] [[package]] -name = "sqlformat" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" -dependencies = [ - "nom", - "unicode_categories", -] - -[[package]] name = "sqlx" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93334716a037193fac19df402f8571269c84a00852f6a7066b5d2616dcd64d3e" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -1794,37 +3099,32 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d8060b456358185f7d50c55d9b5066ad956956fddec42ee2e8567134a8936e" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "atoi", - "byteorder", - "bytes 1.9.0", + "base64", + "bytes 1.10.1", "crc", "crossbeam-queue", "either", "event-listener", - "futures-channel", "futures-core", "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.14.5", + "hashbrown", "hashlink", - "hex", "indexmap", "log", "memchr", "once_cell", - "paste", "percent-encoding", "serde", "serde_json", "sha2", "smallvec", - "sqlformat", - "thiserror 1.0.69", + "thiserror 2.0.12", "tokio", "tokio-stream", "tracing", @@ -1833,9 +3133,9 @@ dependencies = [ [[package]] name = "sqlx-macros" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cac0692bcc9de3b073e8d747391827297e075c7710ff6276d9f7a1f3d58c6657" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", @@ -1846,9 +3146,9 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1804e8a7c7865599c9c79be146dc8a9fd8cc86935fa641d3ea58e5f0688abaa5" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", @@ -1865,22 +3165,21 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn", - "tempfile", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64bb4714269afa44aef2755150a0fc19d756fb580a67db8885608cf02f47d06a" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags 2.6.0", + "bitflags 2.9.1", "byteorder", - "bytes 1.9.0", + "bytes 1.10.1", "crc", "digest", "dotenvy", @@ -1907,27 +3206,26 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 1.0.69", + "thiserror 2.0.12", "tracing", "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fa91a732d854c5d7726349bb4bb879bb9478993ceb764247660aee25f67c2f8" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags 2.6.0", + "bitflags 2.9.1", "byteorder", "crc", "dotenvy", "etcetera", "futures-channel", "futures-core", - "futures-io", "futures-util", "hex", "hkdf", @@ -1945,16 +3243,16 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 1.0.69", + "thiserror 2.0.12", "tracing", "whoami", ] [[package]] name = "sqlx-sqlite" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "flume", @@ -1969,6 +3267,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", + "thiserror 2.0.12", "tracing", "url", ] @@ -1980,6 +3279,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] name = "stderrlog" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2010,6 +3315,25 @@ 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" @@ -2017,9 +3341,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.90" +version = "2.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "919d3b74a5dd0ccd15aeb8f93e7006bd9e14c295087c9896a110f490752bcf31" +checksum = "e4307e30089d6fd6aff212f2da3a1f9e32f3223b1f010fb09b7c95f90f3ca1e8" dependencies = [ "proc-macro2", "quote", @@ -2027,10 +3351,21 @@ dependencies = [ ] [[package]] +name = "syn-ext" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b126de4ef6c2a628a68609dd00733766c3b015894698a438ebdf374933fc31d1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", @@ -2038,19 +3373,34 @@ dependencies = [ ] [[package]] -name = "target-lexicon" -version = "0.12.16" +name = "system-configuration" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.9.1", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] name = "tempfile" -version = "3.14.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ - "cfg-if", "fastrand", + "getrandom 0.3.3", "once_cell", "rustix", "windows-sys 0.59.0", @@ -2066,6 +3416,29 @@ 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.7.1" +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" @@ -2076,11 +3449,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.7" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93605438cbd668185516ab499d589afb7ee1859ea3d5fc8f6b0755e1c7443767" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" dependencies = [ - "thiserror-impl 2.0.7", + "thiserror-impl 2.0.12", ] [[package]] @@ -2096,9 +3469,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.7" +version = "2.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d8749b4531af2117677a5fcd12b1348a3fe2b81e36e61ffeac5c4aa3273e36" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", @@ -2107,19 +3480,24 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ "cfg-if", - "once_cell", ] [[package]] +name = "timsort" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639ce8ef6d2ba56be0383a94dd13b92138d58de44c62618303bb798fa92bdc00" + +[[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ "displaydoc", "zerovec", @@ -2127,9 +3505,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.8.0" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" dependencies = [ "tinyvec_macros", ] @@ -2142,12 +3520,12 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.42.0" +version = "1.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cec9b21b0450273377fc97bd4c33a8acffc8c996c987a7c5b319a0083707551" +checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" dependencies = [ "backtrace", - "bytes 1.9.0", + "bytes 1.10.1", "libc", "mio", "pin-project-lite", @@ -2159,9 +3537,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", @@ -2180,10 +3558,25 @@ dependencies = [ ] [[package]] +name = "tokio-util" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +dependencies = [ + "bytes 1.10.1", + "futures-core", + "futures-sink", + "futures-util", + "hashbrown", + "pin-project-lite", + "tokio", +] + +[[package]] name = "toml" -version = "0.8.19" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", @@ -2193,27 +3586,34 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.22" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", "serde_spanned", "toml_datetime", + "toml_write", "winnow", ] [[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] name = "tracing" version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2227,9 +3627,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" +checksum = "1b1ffbcf9c6f6b99d386e7444eb608ba646ae452a36b39737deb9663b610f662" dependencies = [ "proc-macro2", "quote", @@ -2238,46 +3638,168 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.33" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", ] [[package]] -name = "trinitry" -version = "0.2.2" +name = "twox-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b907da542cbced5261bd3256de1b3a1bf340a3d37f93425a07362a1d687de56" + +[[package]] +name = "typenum" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f814008587cd653ef1f92f9caf321e86a6f53899ec118fd50eaed55974863a40" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "ucd" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4fa6e588762366f1eb4991ce59ad1b93651d0b769dfb4e4d1c5c4b943d1159" + +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" dependencies = [ - "pest", - "pest_derive", + "libc", ] [[package]] -name = "typenum" -version = "1.17.0" +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" [[package]] -name = "ucd-trie" -version = "0.1.7" +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", +] + +[[package]] +name = "unic-ucd-age" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8cfdfe71af46b871dc6af2c24fcd360e2f3392ee4c5111877f2947f311671c" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-bidi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1d568b51222484e1f8209ce48caa6b430bf352962b877d592c29ab31fb53d8c" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-category" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8d4591f5fcfe1bd4453baaf803c40e1b1e69ff8455c47620440b46efef91c0" +dependencies = [ + "matches", + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-hangul" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb1dc690e19010e1523edb9713224cba5ef55b54894fe33424439ec9a40c0054" +dependencies = [ + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-normal" +version = "0.9.0" +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", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] [[package]] name = "unicode-bidi" -version = "0.3.17" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" +checksum = "23cb788ffebc92c5948d0e997106233eeb1d8b9512f93f41651f52b6c5f5af86" + +[[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.14" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "unicode-normalization" @@ -2301,16 +3823,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] -name = "unicode_categories" -version = "0.1.1" +name = "unicode-width" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" [[package]] -name = "unindent" -version = "0.2.3" +name = "unicode_names2" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" +checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +dependencies = [ + "phf", + "unicode_names2_generator", +] + +[[package]] +name = "unicode_names2_generator" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" +dependencies = [ + "getopts", + "log", + "phf_codegen", + "rand", +] [[package]] name = "url" @@ -2325,12 +3863,6 @@ dependencies = [ ] [[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - -[[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2343,6 +3875,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] +name = "uu_fmt" +version = "1.7.1" +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" @@ -2361,10 +3911,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.14.2+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] [[package]] name = "wasite" @@ -2374,20 +3943,21 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", @@ -2399,9 +3969,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2409,9 +3979,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", @@ -2422,21 +3992,68 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.99" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] [[package]] name = "whoami" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" +checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" dependencies = [ "redox_syscall", "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" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] name = "winapi-util" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2446,6 +4063,22 @@ dependencies = [ ] [[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +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" @@ -2455,6 +4088,65 @@ dependencies = [ ] [[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + +[[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2505,7 +4197,7 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", @@ -2513,6 +4205,22 @@ dependencies = [ ] [[package]] +name = "windows-targets" +version = "0.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2525,6 +4233,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2537,6 +4251,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2549,12 +4269,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2567,6 +4299,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2579,6 +4317,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2591,6 +4335,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2603,37 +4353,77 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] name = "winnow" -version = "0.6.20" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" +checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" dependencies = [ "memchr", ] [[package]] -name = "write16" -version = "1.0.0" +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" +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", +] [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "xdg" -version = "2.5.2" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "213b7324336b53d2414b2db8537e56544d981803139155afa84f76eeebb7a546" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" dependencies = [ "serde", "stable_deref_trait", @@ -2643,9 +4433,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", @@ -2655,61 +4445,65 @@ dependencies = [ [[package]] name = "yt" -version = "1.4.0" +version = "1.7.1" dependencies = [ "anyhow", "blake3", - "bytes 1.4.0", + "bytes 1.7.1", "chrono", "chrono-humanize", "clap", + "clap_complete", "futures", "libmpv2", "log", - "nucleo-matcher", + "notify", "owo-colors", "regex", "serde", "serde_json", + "shlex", "sqlx", "stderrlog", "tempfile", + "termsize", "tokio", + "tokio-util", "toml", - "trinitry", "url", + "uu_fmt", "xdg", "yt_dlp", ] [[package]] name = "yt_dlp" -version = "1.4.0" +version = "1.7.1" dependencies = [ - "bytes 1.4.0", + "curl", + "indexmap", "log", - "pyo3", + "rustpython", "serde", "serde_json", - "tokio", + "thiserror 2.0.12", "url", ] [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" dependencies = [ - "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" dependencies = [ "proc-macro2", "quote", @@ -2718,18 +4512,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", @@ -2744,10 +4538,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" [[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" dependencies = [ "yoke", "zerofrom", @@ -2756,11 +4561,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" 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 27d9c05..963a877 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,13 +15,14 @@ members = [ "crates/yt_dlp", "crates/libmpv2", "crates/libmpv2/libmpv2-sys", - "yt", + "crates/termsize", + "crates/yt", ] [workspace.package] -edition = "2021" -version = "1.4.0" -rust-version = "1.80.0" +edition = "2024" +version = "1.7.1" +rust-version = "1.85.0" authors = ["Benedikt Peetz <benedikt.peetz@b-peetz.de>"] repository = "https://git.vhack.eu/soispha/clients/yt" license = "GPL-3.0-or-later" @@ -32,13 +33,15 @@ description = "A fully featured command line YouTube client" yt_dlp = { path = "./crates/yt_dlp" } bytes = { path = "./crates/bytes" } libmpv2 = { path = "./crates/libmpv2" } +termsize = { path = "./crates/termsize" } +uu_fmt = { path = "./crates/fmt" } # Shared -log = "0.4.22" -serde = { version = "1.0.216", features = ["derive"] } -serde_json = "1.0.133" +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.42.0", features = [ +tokio = { version = "1.45.1", features = [ "rt-multi-thread", "macros", "process", @@ -56,6 +59,10 @@ 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/MIT.txt b/LICENSES/MIT.txt new file mode 100644 index 0000000..fc2cf8e --- /dev/null +++ b/LICENSES/MIT.txt @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/NEWS.md b/NEWS.md index 2c09ec9..a0715aa 100644 --- a/NEWS.md +++ b/NEWS.md @@ -14,6 +14,229 @@ 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.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) +- **(crates/libmpv2/Mpv::command)** Correctly escape arguments - ([66c7392](https://git.vhack.eu/soispha/clients/yt/commit/66c739237cc352fedf6276a3163097c1f1f32bd4)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/libmpv2/mpv)** Log the setting of properties - ([d2081fb](https://git.vhack.eu/soispha/clients/yt/commit/d2081fbfed6b2bde727aaf766bf0435ec05a3574)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/termsize)** Remove all of `clippy`'s warnings - ([4b63c7b](https://git.vhack.eu/soispha/clients/yt/commit/4b63c7be4207bf2ff7884189a388e83633b20b26)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/yt_dlp)** Actually return errors instead of panicing - ([4d1b813](https://git.vhack.eu/soispha/clients/yt/commit/4d1b8136bb23d009ee04d863780225ad9d9f9eed)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/yt_dlp)** Avoid printing the file extension in the progress display - ([325b230](https://git.vhack.eu/soispha/clients/yt/commit/325b23039850953705a57c0a331045b169548751)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/yt_dlp/error::PythonError)** Add the python type as `kind` - ([c83dfe5](https://git.vhack.eu/soispha/clients/yt/commit/c83dfe5268e2db39fe731b2d38387b76d9586057)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/yt_dlp/lib)** Actually resolve the `entries` generator object - ([c7601c2](https://git.vhack.eu/soispha/clients/yt/commit/c7601c2e6cc86a3123d8c9dc13afce9430520583)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/yt_dlp/lib)** Swallow all error logs from yt_dlp - ([d1022c5](https://git.vhack.eu/soispha/clients/yt/commit/d1022c5c5c82557d8c2c45fad88b67cc3e6582e3)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/yt_dlp/progress_hook)** Print the progress to stderr - ([1786ba0](https://git.vhack.eu/soispha/clients/yt/commit/1786ba0b87d9883e4c75126e5d72af02134cc8b8)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/yt_dlp/wrappers/info_json)** Serialize the `InfoType`s with their correct name - ([dc19623](https://git.vhack.eu/soispha/clients/yt/commit/dc19623a18ac47d9c660d98db768c64d99decff9)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/yt_dlp/wrappers/info_json)** Don't serialize `None` values - ([fc5771e](https://git.vhack.eu/soispha/clients/yt/commit/fc5771e35b459af6210cbd9a2e7c33b6c462d337)) - [@soispha](https://git.vhack.eu/soispha) +- **(package)** Update to account for modifications in `mkdb.sh` - ([a1dbd45](https://git.vhack.eu/soispha/clients/yt/commit/a1dbd45cbe2b21aa50eefcb6c9f016a7aaa4863c)) - [@soispha](https://git.vhack.eu/soispha) +- **(package/blake3)** Migrate to the new `fetchCargoVendor` fetcher - ([c8ce2b7](https://git.vhack.eu/soispha/clients/yt/commit/c8ce2b7862eb273ed81cd399490e0536b2965a20)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt)** Remove most of the references to the zero version `Video` struct - ([74346a5](https://git.vhack.eu/soispha/clients/yt/commit/74346a5e43235be37bffca6dd0cb0ead66a529b5)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/)** Box large futures - ([335bfe9](https://git.vhack.eu/soispha/clients/yt/commit/335bfe91d7efdfd5a89eaf7728511f96dab3fef3)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/cli)** Make most of the arguments to `yt select <cmd> <hash>` optional - ([ceb0ff2](https://git.vhack.eu/soispha/clients/yt/commit/ceb0ff290707905af56106401b3f2a326971c505)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/download/download_options)** Stop trying to write annotations - ([2bcd30e](https://git.vhack.eu/soispha/clients/yt/commit/2bcd30e3f44fb7dbf859d72caf2a33a96ee5618b)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/main)** Call `watch` with the required `Arc<App>` - ([761a780](https://git.vhack.eu/soispha/clients/yt/commit/761a780499eeb6afe93b55b06d9df5c75aa9d7cc)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/main)** Actually remove the `yt check output-info-json` - ([e07db3a](https://git.vhack.eu/soispha/clients/yt/commit/e07db3a810c2e0f43b20d73ea4258f3ea8f240d4)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/select/cmds/add)** Don't try to add a video that is already added - ([69c94a3](https://git.vhack.eu/soispha/clients/yt/commit/69c94a3068689a575a21a0db6170bde9acad768d)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/select/selection_file/help.str)** Disable vim line wrapping - ([5cf5936](https://git.vhack.eu/soispha/clients/yt/commit/5cf5936c3f2fa0750a2e67d8ff4b6624c3141402)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/status)** Don't show the database version in `yt status` - ([cf8662c](https://git.vhack.eu/soispha/clients/yt/commit/cf8662ce03e5677ddb7de880ceb59d5d84a63259)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/status)** Show the current database version - ([8d7df29](https://git.vhack.eu/soispha/clients/yt/commit/8d7df29bbce0ceb258fa6c591003d379fcdb704f)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/storage/migrate)** Improve error reporting - ([e21c289](https://git.vhack.eu/soispha/clients/yt/commit/e21c289f8d21b802d2dc609233bc28cde65da224)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/storage/migrate/sql/01_zero_to_one.sql)** Account for duration being NULL - ([62cdd76](https://git.vhack.eu/soispha/clients/yt/commit/62cdd76443bbecfbdb70a82a7936a2e602338692)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/storage/notify)** Switch from a polling based system to inotify - ([dc8539e](https://git.vhack.eu/soispha/clients/yt/commit/dc8539e3707c1a281b3aef9c7a6e8f929845d965)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/watch)** Always open a `mpv` window - ([c662429](https://git.vhack.eu/soispha/clients/yt/commit/c6624299c45225ec3971e8119979d7421c56f70d)) - [@soispha](https://git.vhack.eu/soispha) +#### Build system +- **(.envrc)** Align with current state of the repository - ([6e35bf4](https://git.vhack.eu/soispha/clients/yt/commit/6e35bf41687f92a109ee36fa3169b9fb3f72b7f2)) - [@soispha](https://git.vhack.eu/soispha) +- **(.envrc)** Always save the `output.info.json` if in devshell - ([2146109](https://git.vhack.eu/soispha/clients/yt/commit/2146109725115a9d01cc08ebbe3ef9c533ef1a89)) - [@soispha](https://git.vhack.eu/soispha) +- **(flake)** Add `ffmpeg` to the devshell - ([7cc3e3c](https://git.vhack.eu/soispha/clients/yt/commit/7cc3e3cca0de6638625e2997002f78cfd8e03294)) - [@soispha](https://git.vhack.eu/soispha) +- **(rustfmt.toml)** Add - ([ae13afa](https://git.vhack.eu/soispha/clients/yt/commit/ae13afa1aeb8aaed94b1d72aa6207bbfe373dd52)) - [@soispha](https://git.vhack.eu/soispha) +- **(scripts/cprh)** Remove - ([3c91e60](https://git.vhack.eu/soispha/clients/yt/commit/3c91e6046b791664a6e7a0fdc41d369df0ee204a)) - [@soispha](https://git.vhack.eu/soispha) +- **(treewide)** Update - ([7387b68](https://git.vhack.eu/soispha/clients/yt/commit/7387b6853893b3b6a04edb95a830b810e3311ba0)) - [@soispha](https://git.vhack.eu/soispha) +- **(treewide)** Update - ([938b4f1](https://git.vhack.eu/soispha/clients/yt/commit/938b4f1b11dce643942d5e6cd505b206319709a2)) - [@soispha](https://git.vhack.eu/soispha) +- **({.envrc,scripts/mkdb})** Mark the `sqlx` database - ([a1b3f95](https://git.vhack.eu/soispha/clients/yt/commit/a1b3f95bd3f7b447e918eec5bd67d7b5e8333eb0)) - [@soispha](https://git.vhack.eu/soispha) +#### Documentation +- **(yt/cli)** Remove last references to the external update and status_change bits - ([6e9a03f](https://git.vhack.eu/soispha/clients/yt/commit/6e9a03f4e2c6b57ed569bf12ca5e2954149006fb)) - [@soispha](https://git.vhack.eu/soispha) +#### Features +- **(crates/yt_dlp/lib)** Wrap `process_ie_result` function - ([8a53fbd](https://git.vhack.eu/soispha/clients/yt/commit/8a53fbd8af842e2ca1bca0b352113ff7e965f51f)) - [@soispha](https://git.vhack.eu/soispha) +- **(version)** Include `yt-dlp` and `python` version in `--version` - ([431538e](https://git.vhack.eu/soispha/clients/yt/commit/431538e9c90090c8802460f3a625a0cd38a5e72a)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt)** Make colorization of the output configurable - ([e30b69d](https://git.vhack.eu/soispha/clients/yt/commit/e30b69dd4c2ebfb4ae77b38037b66f3e6fcb17bc)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/)** Use concrete types in the `Video` structure - ([9e8657c](https://git.vhack.eu/soispha/clients/yt/commit/9e8657c9762dbb66f3322976606a1b4334d45a6b)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/cli)** Make running the migrations of the database optional - ([31f15dc](https://git.vhack.eu/soispha/clients/yt/commit/31f15dc02bdbb815ce2d53f10d710a65b0b7bf0b)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/select/cmds/add)** Support `start` `stop` args - ([b3785ad](https://git.vhack.eu/soispha/clients/yt/commit/b3785ad44cb48143ed44cee48190b8646d668946)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/select/selection_file/duration)** Support durations up to days - ([14db4ea](https://git.vhack.eu/soispha/clients/yt/commit/14db4eadd57d0a3837227d9d74b84a133bacd434)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/status)** Include the approximate total watch time - ([e902437](https://git.vhack.eu/soispha/clients/yt/commit/e9024377cf5b16f9a81b0f750891307cd6acebe4)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/storage/migrate)** Add version two - ([8dbcf33](https://git.vhack.eu/soispha/clients/yt/commit/8dbcf33c8c6114e5699472d47c39f114103dc02e)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/storage/migrate)** Add db version One - ([93b7432](https://git.vhack.eu/soispha/clients/yt/commit/93b74321bf30ef33e82b0e9337d8cc3b6ca6e663)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/storage/migrate)** Init database migration system - ([9d6721b](https://git.vhack.eu/soispha/clients/yt/commit/9d6721bce1ed93e66c589d34e20393c78c7a423b)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/update)** Port the Python updater to rust - ([1d7bc17](https://git.vhack.eu/soispha/clients/yt/commit/1d7bc17e62a64ec213e43530b050bc41f978c610)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/version)** Show _current_ database version - ([e6bdcb4](https://git.vhack.eu/soispha/clients/yt/commit/e6bdcb4816cd54b7477f50cdebf06b07e7b9c58e)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/watch/playlist)** Init - ([686ea29](https://git.vhack.eu/soispha/clients/yt/commit/686ea29b06162b1a70dc473cea70b00e379c4f29)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/watch/playlist_handler)** Rewrite to use new db layout - ([4a008ef](https://git.vhack.eu/soispha/clients/yt/commit/4a008ef549f595af18f7cf2d0e9940d2627ae8c4)) - [@soispha](https://git.vhack.eu/soispha) +#### Miscellaneous Chores +- **(crates/libmpv2)** Make `cargo clippy` happy - ([b1474f9](https://git.vhack.eu/soispha/clients/yt/commit/b1474f9dc8dc1ed22c2a78680e40bd315cb82b0f)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/termsize)** Vendor - ([9da970f](https://git.vhack.eu/soispha/clients/yt/commit/9da970f1f44f19432680e255f91f73fbb8fbe3c8)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/yt_dlp/wrappers/info_json)** Add further fields - ([832ad82](https://git.vhack.eu/soispha/clients/yt/commit/832ad8265015284f1d95c3426f074aaeacd05864)) - [@soispha](https://git.vhack.eu/soispha) +- **(crates/yt_dlp/wrappers/info_json)** Add further fields - ([674e499](https://git.vhack.eu/soispha/clients/yt/commit/674e4992d320ca0057121eb4474c370abccee8ab)) - [@soispha](https://git.vhack.eu/soispha) +- **(old)** Remove - ([e2b90b4](https://git.vhack.eu/soispha/clients/yt/commit/e2b90b40333e35214f0b1c1e1f575bb688a99e74)) - [@soispha](https://git.vhack.eu/soispha) +- **(treewide)** Add/Update the license headers - ([4cbc424](https://git.vhack.eu/soispha/clients/yt/commit/4cbc424e0d1b51c03dc7c3e49dae361cbf4c4b77)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt)** Change the type of `max_backlog` to `usize` - ([b5145c5](https://git.vhack.eu/soispha/clients/yt/commit/b5145c5d4ef674016f4e4217f67c2969a8dee962)) - [@soispha](https://git.vhack.eu/soispha) +#### Refactoring +- **(crates/fmt)** Init forked `uu_fmt` library - ([7cfa693](https://git.vhack.eu/soispha/clients/yt/commit/7cfa6939deb5496a07313a2a34632da1a3fb1b89)) - [@soispha](https://git.vhack.eu/soispha) +- **(treewide)** Remove all references of the now obsolete update_raw.py - ([6a137c6](https://git.vhack.eu/soispha/clients/yt/commit/6a137c6ca968654810ccfddd90908a227287387f)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/)** Use the new `termsize` and `uu_fmt` crates - ([6da5602](https://git.vhack.eu/soispha/clients/yt/commit/6da5602d083bf26312071e68bfb1eb130da98934)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/description)** Move to the `comments` subdirectory - ([f98665d](https://git.vhack.eu/soispha/clients/yt/commit/f98665d992e3af91e52318e0c6e9334c891343bd)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/storage/video_database)** Move `getters,setters` to `get,set` - ([9496583](https://git.vhack.eu/soispha/clients/yt/commit/9496583cc76fbd7347384716f2898f870743f16d)) - [@soispha](https://git.vhack.eu/soispha) +- **(yt/videos/display)** Streamline video formatting - ([27fae0b](https://git.vhack.eu/soispha/clients/yt/commit/27fae0bcd380fdf7396c33678f4aa3fa2df192cf)) - [@soispha](https://git.vhack.eu/soispha) +#### Style +- **(treewide)** Re-format - ([55a9411](https://git.vhack.eu/soispha/clients/yt/commit/55a94110287ad2b1a55953febac48422a9d3ba89)) - [@soispha](https://git.vhack.eu/soispha) +#### Tests +- **(crates/yt_dlp)** Ignore tests that hang forever - ([405858e](https://git.vhack.eu/soispha/clients/yt/commit/405858e3e7d2e5c06e49f1c195c46d64916afb65)) - [@soispha](https://git.vhack.eu/soispha) + +- - - + +## [v1.4.1](https://git.vhack.eu/soispha/clients/yt/compare/72434a90d6a3dbba48d40a23b840befe7649b558..v1.4.1) - 2024-12-14 +#### Bug Fixes +- **(yt_dlp/wrappers/info_json)** Add further fields to `RequestedDownloads` - ([72434a9](https://git.vhack.eu/soispha/clients/yt/commit/72434a90d6a3dbba48d40a23b840befe7649b558)) - [@soispha](https://git.vhack.eu/soispha) + +- - - + ## [v1.4.0](https://git.vhack.eu/soispha/clients/yt/compare/fcb297027bfb5f1bb97094b23b18522c761106f4..v1.4.0) - 2024-12-14 #### Bug Fixes - **(yt/cli)** Ensure that all `[No <xyz>]` value can be parsed - ([beb5640](https://git.vhack.eu/soispha/clients/yt/commit/beb56409e033fb6d89749788cbf0c91046e3fc16)) - [@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/contrib/config.toml b/contrib/config.toml index ffd91a1..ac06959 100644 --- a/contrib/config.toml +++ b/contrib/config.toml @@ -8,6 +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>. +[global] +display_colors = true + [select] playback_speed = 2.7 subtitle_langs = "" diff --git a/crates/fmt/Cargo.toml b/crates/fmt/Cargo.toml new file mode 100644 index 0000000..f3cf4ad --- /dev/null +++ b/crates/fmt/Cargo.toml @@ -0,0 +1,30 @@ +# yt - A fully featured command line YouTube client +# +# 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. +# +# 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 = "uu_fmt" +authors = ["uutils developers", "Benedikt Peetz <benedikt.peetz@b-peetz.de>"] +license = "MIT" +description = "A fork of the uutils fmt tool. This fork is a library instead of a binary." +version.workspace = true +edition.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[lib] +path = "src/fmt.rs" + +[dependencies] +unicode-width = "0.2.1" + +[lints] +workspace = true diff --git a/crates/fmt/LICENSE b/crates/fmt/LICENSE new file mode 100644 index 0000000..21bd444 --- /dev/null +++ b/crates/fmt/LICENSE @@ -0,0 +1,18 @@ +Copyright (c) uutils developers + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/crates/fmt/LICENSE.license b/crates/fmt/LICENSE.license new file mode 100644 index 0000000..6cee99d --- /dev/null +++ b/crates/fmt/LICENSE.license @@ -0,0 +1,10 @@ +yt - A fully featured command line YouTube client + +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. + +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/fmt/src/fmt.rs b/crates/fmt/src/fmt.rs new file mode 100644 index 0000000..3067bea --- /dev/null +++ b/crates/fmt/src/fmt.rs @@ -0,0 +1,137 @@ +// yt - A fully featured command line YouTube client +// +// 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. +// +// 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 part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use std::fmt::Write; + +use linebreak::break_lines; +use parasplit::ParagraphStream; + +mod linebreak; +mod parasplit; + +#[derive(Debug)] +#[allow(clippy::struct_excessive_bools)] +pub struct FmtOptions { + /// First and second line of paragraph + /// may have different indentations, in which + /// case the first line's indentation is preserved, + /// and each subsequent line's indentation matches the second line. + pub crown_margin: bool, + + /// Like the [`crown_margin`], except that the first and second line of a paragraph *must* + /// have different indentation or they are treated as separate paragraphs. + pub tagged_paragraph: bool, + + /// Attempt to detect and preserve mail headers in the input. + /// Be careful when combining this with [`prefix`]. + pub mail: bool, + + /// Split lines only, do not reflow. + pub split_only: bool, + + /// Insert exactly one space between words, and two between sentences. + /// Sentence breaks in the input are detected as [?!.] followed by two spaces or a newline; + /// other punctuation is not interpreted as a sentence break. + pub uniform: bool, + + /// Reformat only lines beginning with PREFIX, reattaching PREFIX to reformatted lines. + /// Unless [`exact_prefix`] is specified, leading whitespace will be ignored when matching PREFIX. + pub prefix: Option<String>, + + /// Do not reformat lines beginning with ``ANTI_PREFIX``. + /// Unless [`exact_anti_prefix`] is specified, leading whitespace will be ignored when matching ``ANTI_PREFIX``. + pub anti_prefix: Option<String>, + + /// [`prefix`] must match at the beginning of the line with no preceding whitespace. + pub exact_prefix: bool, + + /// [`anti_prefix`] must match at the beginning of the line with no preceding whitespace. + pub exact_anti_prefix: bool, + + /// Fill output lines up to a maximum of WIDTH columns, default 75. + pub width: usize, + + /// Goal width, default of 93% of WIDTH. + /// Must be less than or equal to WIDTH. + pub goal: usize, + + /// Break lines more quickly at the expense of a potentially more ragged appearance. + pub quick: bool, + + /// Treat tabs as TABWIDTH spaces for determining line length, default 8. + /// Note that this is used only for calculating line lengths; tabs are preserved in the output. + pub tabwidth: usize, +} + +impl FmtOptions { + #[must_use] + #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_possible_truncation)] + #[allow(clippy::cast_precision_loss)] + pub fn new(width: Option<usize>, goal: Option<usize>, tabwidth: Option<usize>) -> Self { + // by default, goal is 93% of width + const DEFAULT_GOAL_TO_WIDTH_RATIO: f64 = 0.93; + const DEFAULT_WIDTH: usize = 75; + + FmtOptions { + crown_margin: false, + tagged_paragraph: false, + mail: false, + split_only: false, + uniform: false, + prefix: None, + anti_prefix: None, + exact_prefix: false, + exact_anti_prefix: false, + width: width.unwrap_or(DEFAULT_WIDTH), + goal: goal.unwrap_or( + ((width.unwrap_or(DEFAULT_WIDTH) as f64) * DEFAULT_GOAL_TO_WIDTH_RATIO).floor() + as usize, + ), + quick: false, + tabwidth: tabwidth.unwrap_or(8), + } + } +} + +/// Process text and format it according to the provided options. +/// +/// # Arguments +/// +/// * `text` - The text to process. +/// * `fmt_opts` - A reference to a [`FmtOptions`] structure containing the formatting options. +/// +/// # Returns +/// +/// The formatted [`String`]. +#[must_use] +pub fn process_text(text: &str, fmt_opts: &FmtOptions) -> String { + let mut output = String::new(); + + let p_stream = ParagraphStream::new(fmt_opts, text); + for para_result in p_stream { + match para_result { + Err(s) => { + output.push_str(&s); + output.push('\n'); + } + Ok(para) => write!(output, "{}", break_lines(¶, fmt_opts)) + .expect("This is in-memory. It should not fail"), + } + } + + output +} diff --git a/crates/fmt/src/linebreak.rs b/crates/fmt/src/linebreak.rs new file mode 100644 index 0000000..b1dc6fa --- /dev/null +++ b/crates/fmt/src/linebreak.rs @@ -0,0 +1,520 @@ +// yt - A fully featured command line YouTube client +// +// 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. +// +// 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 part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use std::fmt::Write; +use std::{cmp, mem}; + +use crate::FmtOptions; +use crate::parasplit::{ParaWords, Paragraph, WordInfo}; + +struct BreakArgs<'a> { + opts: &'a FmtOptions, + init_len: usize, + indent_str: &'a str, + indent_len: usize, + uniform: bool, + output: String, +} + +impl BreakArgs<'_> { + fn compute_width(&self, winfo: &WordInfo<'_>, position_n: usize, fresh: bool) -> usize { + if fresh { + 0 + } else { + let post = winfo.after_tab; + match winfo.before_tab { + None => post, + Some(pre) => { + post + ((pre + position_n) / self.opts.tabwidth + 1) * self.opts.tabwidth + - position_n + } + } + } + } +} + +pub(super) fn break_lines(para: &Paragraph, opts: &FmtOptions) -> String { + let mut output = String::new(); + + // indent + let p_indent = ¶.indent_str; + let p_indent_len = para.indent_len; + + // words + let p_words = ParaWords::new(opts, para); + let mut p_words_words = p_words.words(); + + // the first word will *always* appear on the first line + // make sure of this here + let Some(winfo) = p_words_words.next() else { + return "\n".to_owned(); + }; + + // print the init, if it exists, and get its length + let p_init_len = winfo.word_nchars + + if opts.crown_margin || opts.tagged_paragraph { + // handle "init" portion + output.push_str(¶.init_str); + para.init_len + } else if !para.mail_header { + // for non-(crown, tagged) that's the same as a normal indent + output.push_str(p_indent); + p_indent_len + } else { + // except that mail headers get no indent at all + 0 + }; + + // write first word after writing init + write!(output, "{}", winfo.word).expect("Works"); + + // does this paragraph require uniform spacing? + let uniform = para.mail_header || opts.uniform; + + let mut break_args = BreakArgs { + opts, + init_len: p_init_len, + indent_str: p_indent, + indent_len: p_indent_len, + uniform, + output, + }; + + if opts.quick || para.mail_header { + break_simple(p_words_words, &mut break_args); + } else { + break_knuth_plass(p_words_words, &mut break_args); + }; + + break_args.output +} + +// break_simple implements a "greedy" breaking algorithm: print words until +// maxlength would be exceeded, then print a linebreak and indent and continue. +fn break_simple<'a, T: Iterator<Item = &'a WordInfo<'a>>>(iter: T, args: &mut BreakArgs<'a>) { + iter.fold((args.init_len, false), |(l, prev_punct), winfo| { + accum_words_simple(args, l, prev_punct, winfo) + }); + args.output.push('\n'); +} + +fn accum_words_simple<'a>( + args: &mut BreakArgs<'a>, + l: usize, + prev_punct: bool, + winfo: &'a WordInfo<'a>, +) -> (usize, bool) { + // compute the length of this word, considering how tabs will expand at this position on the line + let wlen = winfo.word_nchars + args.compute_width(winfo, l, false); + + let slen = compute_slen( + args.uniform, + winfo.new_line, + winfo.sentence_start, + prev_punct, + ); + + if l + wlen + slen > args.opts.width { + write_newline(args.indent_str, &mut args.output); + write_with_spaces(&winfo.word[winfo.word_start..], 0, &mut args.output); + (args.indent_len + winfo.word_nchars, winfo.ends_punct) + } else { + write_with_spaces(winfo.word, slen, &mut args.output); + (l + wlen + slen, winfo.ends_punct) + } +} + +// break_knuth_plass implements an "optimal" breaking algorithm in the style of +// Knuth, D.E., and Plass, M.F. "Breaking Paragraphs into Lines." in Software, +// Practice and Experience. Vol. 11, No. 11, November 1981. +// http://onlinelibrary.wiley.com/doi/10.1002/spe.4380111102/pdf +#[allow(trivial_casts)] +fn break_knuth_plass<'a, T: Clone + Iterator<Item = &'a WordInfo<'a>>>( + mut iter: T, + args: &mut BreakArgs<'a>, +) { + // run the algorithm to get the breakpoints + let breakpoints = find_kp_breakpoints(iter.clone(), args); + + // iterate through the breakpoints (note that breakpoints is in reverse break order, so we .rev() it + let result: (bool, bool) = breakpoints.iter().rev().fold( + (false, false), + |(mut prev_punct, mut fresh), &(next_break, break_before)| { + if fresh { + write_newline(args.indent_str, &mut args.output); + } + // at each breakpoint, keep emitting words until we find the word matching this breakpoint + for winfo in &mut iter { + let (slen, word) = slice_if_fresh( + fresh, + winfo.word, + winfo.word_start, + args.uniform, + winfo.new_line, + winfo.sentence_start, + prev_punct, + ); + fresh = false; + prev_punct = winfo.ends_punct; + + // We find identical breakpoints here by comparing addresses of the references. + // This is OK because the backing vector is not mutating once we are linebreaking. + let winfo_ptr = winfo as *const _; + let next_break_ptr = next_break as *const _; + if winfo_ptr == next_break_ptr { + // OK, we found the matching word + if break_before { + write_newline(args.indent_str, &mut args.output); + write_with_spaces(&winfo.word[winfo.word_start..], 0, &mut args.output); + } else { + // breaking after this word, so that means "fresh" is true for the next iteration + write_with_spaces(word, slen, &mut args.output); + fresh = true; + } + break; + } + write_with_spaces(word, slen, &mut args.output); + } + (prev_punct, fresh) + }, + ); + let (mut prev_punct, mut fresh) = result; + + // after the last linebreak, write out the rest of the final line. + for winfo in iter { + if fresh { + write_newline(args.indent_str, &mut args.output); + } + let (slen, word) = slice_if_fresh( + fresh, + winfo.word, + winfo.word_start, + args.uniform, + winfo.new_line, + winfo.sentence_start, + prev_punct, + ); + prev_punct = winfo.ends_punct; + fresh = false; + write_with_spaces(word, slen, &mut args.output); + } + + args.output.push('\n'); +} + +struct LineBreak<'a> { + prev: usize, + linebreak: Option<&'a WordInfo<'a>>, + break_before: bool, + demerits: i64, + prev_rat: f32, + length: usize, + fresh: bool, +} + +#[allow(clippy::cognitive_complexity)] +#[allow(clippy::cast_possible_wrap)] +fn find_kp_breakpoints<'a, T: Iterator<Item = &'a WordInfo<'a>>>( + iter: T, + args: &BreakArgs<'a>, +) -> Vec<(&'a WordInfo<'a>, bool)> { + let mut iter = iter.peekable(); + // set up the initial null linebreak + let mut linebreaks = vec![LineBreak { + prev: 0, + linebreak: None, + break_before: false, + demerits: 0, + prev_rat: 0.0, + length: args.init_len, + fresh: false, + }]; + // this vec holds the current active linebreaks; next_ holds the breaks that will be active for + // the next word + let mut active_breaks = vec![0]; + let mut next_active_breaks = vec![]; + + let stretch = args.opts.width - args.opts.goal; + let minlength = args.opts.goal.max(stretch + 1) - stretch; + let mut new_linebreaks = vec![]; + let mut is_sentence_start = false; + let mut least_demerits = 0; + loop { + let Some(w) = iter.next() else { break }; + + // if this is the last word, we don't add additional demerits for this break + let (is_last_word, is_sentence_end) = match iter.peek() { + None => (true, true), + Some(&&WordInfo { + sentence_start: st, + new_line: nl, + .. + }) => (false, st || (nl && w.ends_punct)), + }; + + // should we be adding extra space at the beginning of the next sentence? + let slen = compute_slen(args.uniform, w.new_line, is_sentence_start, false); + + let mut ld_new = i64::MAX; + let mut ld_next = i64::MAX; + let mut ld_idx = 0; + new_linebreaks.clear(); + next_active_breaks.clear(); + // go through each active break, extending it and possibly adding a new active + // break if we are above the minimum required length + #[allow(clippy::explicit_iter_loop)] + for &i in active_breaks.iter() { + let active = &mut linebreaks[i]; + // normalize demerits to avoid overflow, and record if this is the least + active.demerits -= least_demerits; + if active.demerits < ld_next { + ld_next = active.demerits; + ld_idx = i; + } + + // get the new length + let tlen = w.word_nchars + + args.compute_width(w, active.length, active.fresh) + + slen + + active.length; + + // if tlen is longer than args.opts.width, we drop this break from the active list + // otherwise, we extend the break, and possibly add a new break at this point + if tlen <= args.opts.width { + // this break will still be active next time + next_active_breaks.push(i); + // we can put this word on this line + active.fresh = false; + active.length = tlen; + + // if we're above the minlength, we can also consider breaking here + if tlen >= minlength { + let (new_demerits, new_ratio) = if is_last_word { + // there is no penalty for the final line's length + (0, 0.0) + } else { + compute_demerits( + args.opts.goal as isize - tlen as isize, + stretch, + w.word_nchars, + active.prev_rat, + ) + }; + + // do not even consider adding a line that has too many demerits + // also, try to detect overflow by checking signum + let total_demerits = new_demerits + active.demerits; + if new_demerits < BAD_INFTY_SQ + && total_demerits < ld_new + && active.demerits.signum() <= new_demerits.signum() + { + ld_new = total_demerits; + new_linebreaks.push(LineBreak { + prev: i, + linebreak: Some(w), + break_before: false, + demerits: total_demerits, + prev_rat: new_ratio, + length: args.indent_len, + fresh: true, + }); + } + } + } + } + + // if we generated any new linebreaks, add the last one to the list + // the last one is always the best because we don't add to new_linebreaks unless + // it's better than the best one so far + match new_linebreaks.pop() { + None => (), + Some(lb) => { + next_active_breaks.push(linebreaks.len()); + linebreaks.push(lb); + } + } + + if next_active_breaks.is_empty() { + // every potential linebreak is too long! choose the linebreak with the least demerits, ld_idx + let new_break = + restart_active_breaks(args, &linebreaks[ld_idx], ld_idx, w, slen, minlength); + next_active_breaks.push(linebreaks.len()); + linebreaks.push(new_break); + least_demerits = 0; + } else { + // next time around, normalize out the demerits fields + // on active linebreaks to make overflow less likely + least_demerits = cmp::max(ld_next, 0); + } + // swap in new list of active breaks + mem::swap(&mut active_breaks, &mut next_active_breaks); + // If this was the last word in a sentence, the next one must be the first in the next. + is_sentence_start = is_sentence_end; + } + + // return the best path + build_best_path(&linebreaks, &active_breaks) +} + +fn build_best_path<'a>(paths: &[LineBreak<'a>], active: &[usize]) -> Vec<(&'a WordInfo<'a>, bool)> { + // of the active paths, we select the one with the fewest demerits + active + .iter() + .min_by_key(|&&a| paths[a].demerits) + .map(|&(mut best_idx)| { + let mut breakwords = vec![]; + // now, chase the pointers back through the break list, recording + // the words at which we should break + loop { + let next_best = &paths[best_idx]; + match next_best.linebreak { + None => return breakwords, + Some(prev) => { + breakwords.push((prev, next_best.break_before)); + best_idx = next_best.prev; + } + } + } + }) + .unwrap_or_default() +} + +// "infinite" badness is more like (1+BAD_INFTY)^2 because of how demerits are computed +const BAD_INFTY: i64 = 10_000_000; +const BAD_INFTY_SQ: i64 = BAD_INFTY * BAD_INFTY; +// badness = BAD_MULT * abs(r) ^ 3 +const BAD_MULT: f32 = 100.0; +// DR_MULT is multiplier for delta-R between lines +const DR_MULT: f32 = 600.0; +// DL_MULT is penalty multiplier for short words at end of line +const DL_MULT: f32 = 300.0; + +#[allow(clippy::cast_precision_loss)] +#[allow(clippy::cast_possible_truncation)] +fn compute_demerits(delta_len: isize, stretch: usize, wlen: usize, prev_rat: f32) -> (i64, f32) { + // how much stretch are we using? + let ratio = if delta_len == 0 { + 0.0f32 + } else { + delta_len as f32 / stretch as f32 + }; + + // compute badness given the stretch ratio + let bad_linelen = if ratio.abs() > 1.0f32 { + BAD_INFTY + } else { + (BAD_MULT * ratio.powi(3).abs()) as i64 + }; + + // we penalize lines ending in really short words + let bad_wordlen = if wlen >= stretch { + 0 + } else { + (DL_MULT + * ((stretch - wlen) as f32 / (stretch - 1) as f32) + .powi(3) + .abs()) as i64 + }; + + // we penalize lines that have very different ratios from previous lines + let bad_delta_r = (DR_MULT * ((ratio - prev_rat) / 2.0).powi(3).abs()) as i64; + + let demerits = i64::pow(1 + bad_linelen + bad_wordlen + bad_delta_r, 2); + + (demerits, ratio) +} + +#[allow(clippy::cast_possible_wrap)] +fn restart_active_breaks<'a>( + args: &BreakArgs<'a>, + active: &LineBreak<'a>, + act_idx: usize, + w: &'a WordInfo<'a>, + slen: usize, + min: usize, +) -> LineBreak<'a> { + let (break_before, line_length) = if active.fresh { + // never break before a word if that word would be the first on a line + (false, args.indent_len) + } else { + // choose the lesser evil: breaking too early, or breaking too late + let wlen = w.word_nchars + args.compute_width(w, active.length, active.fresh); + let underlen = min as isize - active.length as isize; + let overlen = (wlen + slen + active.length) as isize - args.opts.width as isize; + if overlen > underlen { + // break early, put this word on the next line + (true, args.indent_len + w.word_nchars) + } else { + (false, args.indent_len) + } + }; + + // restart the linebreak. This will be our only active path. + LineBreak { + prev: act_idx, + linebreak: Some(w), + break_before, + demerits: 0, // this is the only active break, so we can reset the demerit count + prev_rat: if break_before { 1.0 } else { -1.0 }, + length: line_length, + fresh: !break_before, + } +} + +// Number of spaces to add before a word, based on mode, newline, sentence start. +#[allow(clippy::fn_params_excessive_bools)] +fn compute_slen(uniform: bool, newline: bool, start: bool, punct: bool) -> usize { + if uniform || newline { + if start || (newline && punct) { 2 } else { 1 } + } else { + 0 + } +} + +// If we're on a fresh line, slen=0 and we slice off leading whitespace. +// Otherwise, compute slen and leave whitespace alone. +#[allow(clippy::fn_params_excessive_bools)] +fn slice_if_fresh( + fresh: bool, + word: &str, + start: usize, + uniform: bool, + newline: bool, + second_start: bool, + punct: bool, +) -> (usize, &str) { + if fresh { + (0, &word[start..]) + } else { + (compute_slen(uniform, newline, second_start, punct), word) + } +} + +// Write a newline and add the indent. +fn write_newline(indent: &str, output: &mut String) { + output.push('\n'); + output.push_str(indent); +} + +// Write the word, along with slen spaces. +fn write_with_spaces(word: &str, slen: usize, output: &mut String) { + if slen == 2 { + output.push_str(" "); + } else if slen == 1 { + output.push(' '); + } + output.push_str(word); +} diff --git a/crates/fmt/src/parasplit.rs b/crates/fmt/src/parasplit.rs new file mode 100644 index 0000000..d4723cb --- /dev/null +++ b/crates/fmt/src/parasplit.rs @@ -0,0 +1,629 @@ +// yt - A fully featured command line YouTube client +// +// 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. +// +// 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 part of the uutils coreutils package. +// +// For the full copyright and license information, please view the LICENSE +// file that was distributed with this source code. + +use std::iter::Peekable; +use std::slice::Iter; +use unicode_width::UnicodeWidthChar; + +use crate::FmtOptions; + +fn char_width(c: char) -> usize { + if (c as usize) < 0xA0 { + // if it is ASCII, call it exactly 1 wide (including control chars) + // calling control chars' widths 1 is consistent with OpenBSD fmt + 1 + } else { + // otherwise, get the unicode width + // note that we shouldn't actually get None here because only c < 0xA0 + // can return None, but for safety and future-proofing we do it this way + UnicodeWidthChar::width(c).unwrap_or(1) + } +} + +// lines with PSKIP, lacking PREFIX, or which are entirely blank are +// NoFormatLines; otherwise, they are FormatLines +#[derive(Debug)] +pub(super) enum Line { + FormatLine(FileLine), + NoFormatLine(String, bool), +} + +impl Line { + // when we know that it's a FormatLine, as in the ParagraphStream iterator + fn get_formatline(self) -> FileLine { + match self { + Self::FormatLine(fl) => fl, + Self::NoFormatLine(..) => panic!("Found NoFormatLine when expecting FormatLine"), + } + } + + // when we know that it's a NoFormatLine, as in the ParagraphStream iterator + fn get_noformatline(self) -> (String, bool) { + match self { + Self::NoFormatLine(s, b) => (s, b), + Self::FormatLine(..) => panic!("Found FormatLine when expecting NoFormatLine"), + } + } +} + +/// Each line's prefix has to be considered to know whether to merge it with +/// the next line or not +#[derive(Debug)] +pub(super) struct FileLine { + line: String, + /// The end of the indent, always the start of the text + indent_end: usize, + + /// The end of the PREFIX's indent, that is, the spaces before the prefix + prefix_indent_end: usize, + + /// Display length of indent taking into account tabs + indent_len: usize, + + /// PREFIX indent length taking into account tabs + prefix_len: usize, +} + +/// Iterator that produces a stream of Lines from a file +pub(super) struct FileLines<'a> { + opts: &'a FmtOptions, + lines: std::str::Lines<'a>, +} + +impl FileLines<'_> { + fn new<'b>(opts: &'b FmtOptions, lines: std::str::Lines<'b>) -> FileLines<'b> { + FileLines { opts, lines } + } + + /// returns true if this line should be formatted + fn match_prefix(&self, line: &str) -> (bool, usize) { + let Some(prefix) = &self.opts.prefix else { + return (true, 0); + }; + + FileLines::match_prefix_generic(prefix, line, self.opts.exact_prefix) + } + + /// returns true if this line should be formatted + fn match_anti_prefix(&self, line: &str) -> bool { + let Some(anti_prefix) = &self.opts.anti_prefix else { + return true; + }; + + match FileLines::match_prefix_generic(anti_prefix, line, self.opts.exact_anti_prefix) { + (true, _) => false, + (_, _) => true, + } + } + + fn match_prefix_generic(pfx: &str, line: &str, exact: bool) -> (bool, usize) { + if line.starts_with(pfx) { + return (true, 0); + } + + if !exact { + // we do it this way rather than byte indexing to support unicode whitespace chars + for (i, char) in line.char_indices() { + if line[i..].starts_with(pfx) { + return (true, i); + } else if !char.is_whitespace() { + break; + } + } + } + + (false, 0) + } + + fn compute_indent(&self, string: &str, prefix_end: usize) -> (usize, usize, usize) { + let mut prefix_len = 0; + let mut indent_len = 0; + let mut indent_end = 0; + for (os, c) in string.char_indices() { + if os == prefix_end { + // we found the end of the prefix, so this is the printed length of the prefix here + prefix_len = indent_len; + } + + if (os >= prefix_end) && !c.is_whitespace() { + // found first non-whitespace after prefix, this is indent_end + indent_end = os; + break; + } else if c == '\t' { + // compute tab length + indent_len = (indent_len / self.opts.tabwidth + 1) * self.opts.tabwidth; + } else { + // non-tab character + indent_len += char_width(c); + } + } + (indent_end, prefix_len, indent_len) + } +} + +impl Iterator for FileLines<'_> { + type Item = Line; + + fn next(&mut self) -> Option<Line> { + let n = self.lines.next()?; + + // if this line is entirely whitespace, + // emit a blank line + // Err(true) indicates that this was a linebreak, + // which is important to know when detecting mail headers + if n.chars().all(char::is_whitespace) { + return Some(Line::NoFormatLine(String::new(), true)); + } + + let (pmatch, poffset) = self.match_prefix(n); + + // if this line does not match the prefix, + // emit the line unprocessed and iterate again + if !pmatch { + return Some(Line::NoFormatLine(n.to_owned(), false)); + } + + // if the line matches the prefix, but is blank after, + // don't allow lines to be combined through it (that is, + // treat it like a blank line, except that since it's + // not truly blank we will not allow mail headers on the + // following line) + if pmatch + && n[poffset + self.opts.prefix.as_ref().map_or(0, String::len)..] + .chars() + .all(char::is_whitespace) + { + return Some(Line::NoFormatLine(n.to_owned(), false)); + } + + // skip if this line matches the anti_prefix + // (NOTE definition of match_anti_prefix is TRUE if we should process) + if !self.match_anti_prefix(n) { + return Some(Line::NoFormatLine(n.to_owned(), false)); + } + + // figure out the indent, prefix, and prefixindent ending points + let prefix_end = poffset + self.opts.prefix.as_ref().map_or(0, String::len); + let (indent_end, prefix_len, indent_len) = self.compute_indent(n, prefix_end); + + Some(Line::FormatLine(FileLine { + line: n.to_owned(), + indent_end, + prefix_indent_end: poffset, + indent_len, + prefix_len, + })) + } +} + +/// A paragraph : a collection of [`FileLines`] that are to be formatted +/// plus info about the paragraph's indentation +/// +/// We only retain the String from the [`FileLine`]; the other info +/// is only there to help us in deciding how to merge lines into Paragraphs +#[derive(Debug)] +pub(super) struct Paragraph { + /// the lines of the file + lines: Vec<String>, + /// string representing the init, that is, the first line's indent + pub init_str: String, + /// printable length of the init string considering TABWIDTH + pub init_len: usize, + /// byte location of end of init in first line String + init_end: usize, + /// string representing indent + pub indent_str: String, + /// length of above + pub indent_len: usize, + /// byte location of end of indent (in crown and tagged mode, only applies to 2nd line and onward) + indent_end: usize, + /// we need to know if this is a mail header because we do word splitting differently in that case + pub mail_header: bool, +} + +/// An iterator producing a stream of paragraphs from a stream of lines +/// given a set of options. +pub(super) struct ParagraphStream<'a> { + lines: Peekable<FileLines<'a>>, + next_mail: bool, + opts: &'a FmtOptions, +} + +impl ParagraphStream<'_> { + pub(super) fn new<'b>(opts: &'b FmtOptions, text: &'b str) -> ParagraphStream<'b> { + let lines = FileLines::new(opts, text.lines()).peekable(); + // at the beginning of the file, we might find mail headers + ParagraphStream { + lines, + next_mail: true, + opts, + } + } + + /// Detect RFC822 mail header + fn is_mail_header(line: &FileLine) -> bool { + // a mail header begins with either "From " (envelope sender line) + // or with a sequence of printable ASCII chars (33 to 126, inclusive, + // except colon) followed by a colon. + if line.indent_end > 0 { + false + } else { + let l_slice = &line.line[..]; + if l_slice.starts_with("From ") { + true + } else { + let Some(colon_posn) = l_slice.find(':') else { + return false; + }; + + // header field must be nonzero length + if colon_posn == 0 { + return false; + } + + l_slice[..colon_posn] + .chars() + .all(|x| !matches!(x as usize, y if !(33..=126).contains(&y))) + } + } + } +} + +impl Iterator for ParagraphStream<'_> { + type Item = Result<Paragraph, String>; + + #[allow(clippy::cognitive_complexity)] + fn next(&mut self) -> Option<Result<Paragraph, String>> { + // return a NoFormatLine in an Err; it should immediately be output + let noformat = match self.lines.peek()? { + Line::FormatLine(_) => false, + Line::NoFormatLine(_, _) => true, + }; + + // found a NoFormatLine, immediately dump it out + if noformat { + let (s, nm) = self.lines.next().unwrap().get_noformatline(); + self.next_mail = nm; + return Some(Err(s)); + } + + // found a FormatLine, now build a paragraph + let mut init_str = String::new(); + let mut init_end = 0; + let mut init_len = 0; + let mut indent_str = String::new(); + let mut indent_end = 0; + let mut indent_len = 0; + let mut prefix_len = 0; + let mut prefix_indent_end = 0; + let mut p_lines = Vec::new(); + + let mut in_mail = false; + let mut second_done = false; // for when we use crown or tagged mode + loop { + // peek ahead + // need to explicitly force fl out of scope before we can call self.lines.next() + let Some(Line::FormatLine(fl)) = self.lines.peek() else { + break; + }; + + if p_lines.is_empty() { + // first time through the loop, get things set up + // detect mail header + if self.opts.mail && self.next_mail && ParagraphStream::is_mail_header(fl) { + in_mail = true; + // there can't be any indent or prefixindent because otherwise is_mail_header + // would fail since there cannot be any whitespace before the colon in a + // valid header field + indent_str.push_str(" "); + indent_len = 2; + } else { + if self.opts.crown_margin || self.opts.tagged_paragraph { + init_str.push_str(&fl.line[..fl.indent_end]); + init_len = fl.indent_len; + init_end = fl.indent_end; + } else { + second_done = true; + } + + // these will be overwritten in the 2nd line of crown or tagged mode, but + // we are not guaranteed to get to the 2nd line, e.g., if the next line + // is a NoFormatLine or None. Thus, we set sane defaults the 1st time around + indent_str.push_str(&fl.line[..fl.indent_end]); + indent_len = fl.indent_len; + indent_end = fl.indent_end; + + // save these to check for matching lines + prefix_len = fl.prefix_len; + prefix_indent_end = fl.prefix_indent_end; + + // in tagged mode, add 4 spaces of additional indenting by default + // (gnu fmt's behavior is different: it seems to find the closest column to + // indent_end that is divisible by 3. But honestly that behavior seems + // pretty arbitrary. + // Perhaps a better default would be 1 TABWIDTH? But ugh that's so big. + if self.opts.tagged_paragraph { + indent_str.push_str(" "); + indent_len += 4; + } + } + } else if in_mail { + // lines following mail headers must begin with spaces + if fl.indent_end == 0 || (self.opts.prefix.is_some() && fl.prefix_indent_end == 0) { + break; // this line does not begin with spaces + } + } else if !second_done { + // now we have enough info to handle crown margin and tagged mode + + // in both crown and tagged modes we require that prefix_len is the same + if prefix_len != fl.prefix_len || prefix_indent_end != fl.prefix_indent_end { + break; + } + + // in tagged mode, indent has to be *different* on following lines + if self.opts.tagged_paragraph + && indent_len - 4 == fl.indent_len + && indent_end == fl.indent_end + { + break; + } + + // this is part of the same paragraph, get the indent info from this line + indent_str.clear(); + indent_str.push_str(&fl.line[..fl.indent_end]); + indent_len = fl.indent_len; + indent_end = fl.indent_end; + + second_done = true; + } else { + // detect mismatch + if indent_end != fl.indent_end + || prefix_indent_end != fl.prefix_indent_end + || indent_len != fl.indent_len + || prefix_len != fl.prefix_len + { + break; + } + } + + p_lines.push(self.lines.next().unwrap().get_formatline().line); + + // when we're in split-only mode, we never join lines, so stop here + if self.opts.split_only { + break; + } + } + + // if this was a mail header, then the next line can be detected as one. Otherwise, it cannot. + // NOTE next_mail is true at ParagraphStream instantiation, and is set to true after a blank + // NoFormatLine. + self.next_mail = in_mail; + + Some(Ok(Paragraph { + lines: p_lines, + init_str, + init_len, + init_end, + indent_str, + indent_len, + indent_end, + mail_header: in_mail, + })) + } +} + +pub(super) struct ParaWords<'a> { + opts: &'a FmtOptions, + para: &'a Paragraph, + words: Vec<WordInfo<'a>>, +} + +impl<'a> ParaWords<'a> { + pub(super) fn new(opts: &'a FmtOptions, para: &'a Paragraph) -> Self { + let mut pw = ParaWords { + opts, + para, + words: Vec::new(), + }; + pw.create_words(); + pw + } + + fn create_words(&mut self) { + if self.para.mail_header { + // no extra spacing for mail headers; always exactly 1 space + // safe to trim_start on every line of a mail header, since the + // first line is guaranteed not to have any spaces + self.words.extend( + self.para + .lines + .iter() + .flat_map(|x| x.split_whitespace()) + .map(|x| WordInfo { + word: x, + word_start: 0, + word_nchars: x.len(), // OK for mail headers; only ASCII allowed (unicode is escaped) + before_tab: None, + after_tab: 0, + sentence_start: false, + ends_punct: false, + new_line: false, + }), + ); + } else { + // first line + self.words + .extend(if self.opts.crown_margin || self.opts.tagged_paragraph { + // crown and tagged mode has the "init" in the first line, so slice from there + WordSplit::new(self.opts, &self.para.lines[0][self.para.init_end..]) + } else { + // otherwise we slice from the indent + WordSplit::new(self.opts, &self.para.lines[0][self.para.indent_end..]) + }); + + if self.para.lines.len() > 1 { + let indent_end = self.para.indent_end; + let opts = self.opts; + self.words.extend( + self.para + .lines + .iter() + .skip(1) + .flat_map(|x| WordSplit::new(opts, &x[indent_end..])), + ); + } + } + } + + pub(super) fn words(&'a self) -> Iter<'a, WordInfo<'a>> { + self.words.iter() + } +} + +struct WordSplit<'a> { + opts: &'a FmtOptions, + string: &'a str, + length: usize, + position: usize, + prev_punct: bool, +} + +impl WordSplit<'_> { + fn analyze_tabs(&self, string: &str) -> (Option<usize>, usize, Option<usize>) { + // given a string, determine (length before tab) and (printed length after first tab) + // if there are no tabs, beforetab = -1 and aftertab is the printed length + let mut beforetab = None; + let mut aftertab = 0; + let mut word_start = None; + for (os, c) in string.char_indices() { + if !c.is_whitespace() { + word_start = Some(os); + break; + } else if c == '\t' { + if beforetab.is_none() { + beforetab = Some(aftertab); + aftertab = 0; + } else { + aftertab = (aftertab / self.opts.tabwidth + 1) * self.opts.tabwidth; + } + } else { + aftertab += 1; + } + } + (beforetab, aftertab, word_start) + } +} + +impl WordSplit<'_> { + fn new<'b>(opts: &'b FmtOptions, string: &'b str) -> WordSplit<'b> { + // wordsplits *must* start at a non-whitespace character + let trim_string = string.trim_start(); + WordSplit { + opts, + string: trim_string, + length: string.len(), + position: 0, + prev_punct: false, + } + } + + fn is_punctuation(c: char) -> bool { + matches!(c, '!' | '.' | '?') + } +} + +pub(super) struct WordInfo<'a> { + pub word: &'a str, + pub word_start: usize, + pub word_nchars: usize, + pub before_tab: Option<usize>, + pub after_tab: usize, + pub sentence_start: bool, + pub ends_punct: bool, + pub new_line: bool, +} + +// returns (&str, is_start_of_sentence) +impl<'a> Iterator for WordSplit<'a> { + type Item = WordInfo<'a>; + + fn next(&mut self) -> Option<WordInfo<'a>> { + if self.position >= self.length { + return None; + } + + let old_position = self.position; + let new_line = old_position == 0; + + // find the start of the next word, and record if we find a tab character + let (before_tab, after_tab, word_start) = + if let (b, a, Some(s)) = self.analyze_tabs(&self.string[old_position..]) { + (b, a, s + old_position) + } else { + self.position = self.length; + return None; + }; + + // find the beginning of the next whitespace + // note that this preserves the invariant that self.position + // points to whitespace character OR end of string + let mut word_nchars = 0; + self.position = match self.string[word_start..].find(|x: char| { + if x.is_whitespace() { + true + } else { + word_nchars += char_width(x); + false + } + }) { + None => self.length, + Some(s) => s + word_start, + }; + + let word_start_relative = word_start - old_position; + // if the previous sentence was punctuation and this sentence has >2 whitespace or one tab, is a new sentence. + let is_start_of_sentence = + self.prev_punct && (before_tab.is_some() || word_start_relative > 1); + + // now record whether this word ends in punctuation + self.prev_punct = match self.string[..self.position].chars().next_back() { + Some(ch) => WordSplit::is_punctuation(ch), + _ => panic!("fatal: expected word not to be empty"), + }; + + let (word, word_start_relative, before_tab, after_tab) = if self.opts.uniform { + (&self.string[word_start..self.position], 0, None, 0) + } else { + ( + &self.string[old_position..self.position], + word_start_relative, + before_tab, + after_tab, + ) + }; + + Some(WordInfo { + word, + word_start: word_start_relative, + word_nchars, + before_tab, + after_tab, + sentence_start: is_start_of_sentence, + ends_punct: self.prev_punct, + new_line, + }) + } +} 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 a8a4ed6..fb2f5bf 100644 --- a/crates/libmpv2/Cargo.toml +++ b/crates/libmpv2/Cargo.toml @@ -24,7 +24,6 @@ publish = false [dependencies] libmpv2-sys = { path = "libmpv2-sys" } -thiserror = "2.0.7" log.workspace = true [dev-dependencies] diff --git a/crates/libmpv2/examples/events.rs b/crates/libmpv2/examples/events.rs index 8f7c79f..e502d5c 100644 --- a/crates/libmpv2/examples/events.rs +++ b/crates/libmpv2/examples/events.rs @@ -45,25 +45,27 @@ fn main() -> Result<()> { // Trigger `Event::EndFile`. mpv.command("playlist-next", &["force"]).unwrap(); }); - scope.spawn(move |_| loop { - let ev = ev_ctx.wait_event(600.).unwrap_or(Err(Error::Null)); + scope.spawn(move |_| { + loop { + let ev = ev_ctx.wait_event(600.).unwrap_or(Err(Error::Null)); - match ev { - Ok(Event::EndFile(r)) => { - println!("Exiting! Reason: {:?}", r); - break; - } + match ev { + Ok(Event::EndFile(r)) => { + println!("Exiting! Reason: {:?}", r); + break; + } - Ok(Event::PropertyChange { - name: "demuxer-cache-state", - change: PropertyData::Node(mpv_node), - .. - }) => { - let ranges = seekable_ranges(mpv_node); - println!("Seekable ranges updated: {:?}", ranges); + Ok(Event::PropertyChange { + name: "demuxer-cache-state", + change: PropertyData::Node(mpv_node), + .. + }) => { + let ranges = seekable_ranges(mpv_node); + println!("Seekable ranges updated: {:?}", ranges); + } + Ok(e) => println!("Event triggered: {:?}", e), + Err(e) => println!("Event errored: {:?}", e), } - Ok(e) => println!("Event triggered: {:?}", e), - Err(e) => println!("Event errored: {:?}", e), } }); }) diff --git a/crates/libmpv2/examples/opengl.rs b/crates/libmpv2/examples/opengl.rs index 1de307f..9f595aa 100644 --- a/crates/libmpv2/examples/opengl.rs +++ b/crates/libmpv2/examples/opengl.rs @@ -9,8 +9,8 @@ // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. use libmpv2::{ - render::{OpenGLInitParams, RenderContext, RenderParam, RenderParamApiType}, Mpv, + render::{OpenGLInitParams, RenderContext, RenderParam, RenderParamApiType}, }; use std::{env, ffi::c_void}; diff --git a/crates/libmpv2/libmpv2-sys/Cargo.toml b/crates/libmpv2/libmpv2-sys/Cargo.toml index b0514b8..96141d3 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.71.1" } +bindgen = { version = "0.72.0" } 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/lib.rs b/crates/libmpv2/src/lib.rs index 4d8d18a..f6c2103 100644 --- a/crates/libmpv2/src/lib.rs +++ b/crates/libmpv2/src/lib.rs @@ -35,7 +35,7 @@ use std::os::raw as ctype; pub const MPV_CLIENT_API_MAJOR: ctype::c_ulong = 2; pub const MPV_CLIENT_API_MINOR: ctype::c_ulong = 2; pub const MPV_CLIENT_API_VERSION: ctype::c_ulong = - MPV_CLIENT_API_MAJOR << 16 | MPV_CLIENT_API_MINOR; + (MPV_CLIENT_API_MAJOR << 16) | MPV_CLIENT_API_MINOR; mod mpv; #[cfg(test)] @@ -67,8 +67,8 @@ pub mod mpv_error { pub use libmpv2_sys::mpv_error_MPV_ERROR_INVALID_PARAMETER as InvalidParameter; pub use libmpv2_sys::mpv_error_MPV_ERROR_LOADING_FAILED as LoadingFailed; pub use libmpv2_sys::mpv_error_MPV_ERROR_NOMEM as NoMem; - pub use libmpv2_sys::mpv_error_MPV_ERROR_NOTHING_TO_PLAY as NothingToPlay; pub use libmpv2_sys::mpv_error_MPV_ERROR_NOT_IMPLEMENTED as NotImplemented; + pub use libmpv2_sys::mpv_error_MPV_ERROR_NOTHING_TO_PLAY as NothingToPlay; pub use libmpv2_sys::mpv_error_MPV_ERROR_OPTION_ERROR as OptionError; pub use libmpv2_sys::mpv_error_MPV_ERROR_OPTION_FORMAT as OptionFormat; pub use libmpv2_sys::mpv_error_MPV_ERROR_OPTION_NOT_FOUND as OptionNotFound; diff --git a/crates/libmpv2/src/mpv.rs b/crates/libmpv2/src/mpv.rs index 07d0976..29dac8d 100644 --- a/crates/libmpv2/src/mpv.rs +++ b/crates/libmpv2/src/mpv.rs @@ -184,7 +184,7 @@ pub mod mpv_node { pub mod sys_node { use super::{DropWrapper, MpvNode, MpvNodeArrayIter, MpvNodeMapIter}; - use crate::{mpv_error, mpv_format, Error, Result}; + use crate::{Error, Result, mpv_error, mpv_format}; use std::rc::Rc; #[derive(Debug, Clone)] @@ -375,14 +375,14 @@ unsafe impl SetData for String { /// Wrapper around an `&str` returned by mpv, that properly deallocates it with mpv's allocator. #[derive(Debug, Hash, Eq, PartialEq)] pub struct MpvStr<'a>(&'a str); -impl<'a> Deref for MpvStr<'a> { +impl Deref for MpvStr<'_> { type Target = str; fn deref(&self) -> &str { self.0 } } -impl<'a> Drop for MpvStr<'a> { +impl Drop for MpvStr<'_> { fn drop(&mut self) { unsafe { libmpv2_sys::mpv_free(self.0.as_ptr() as *mut u8 as _) }; } @@ -403,7 +403,7 @@ unsafe impl<'a> GetData for MpvStr<'a> { } } -unsafe impl<'a> SetData for &'a str { +unsafe impl SetData for &str { fn call_as_c_void<T, F: FnMut(*mut ctype::c_void) -> Result<T>>(self, mut fun: F) -> Result<T> { let string = CString::new(self)?; fun((&mut string.as_ptr()) as *mut *const ctype::c_char as *mut _) @@ -511,9 +511,8 @@ impl Mpv { } initializer(MpvInitializer { ctx })?; - mpv_err((), unsafe { libmpv2_sys::mpv_initialize(ctx) }).map_err(|err| { + mpv_err((), unsafe { libmpv2_sys::mpv_initialize(ctx) }).inspect_err(|_| { unsafe { libmpv2_sys::mpv_terminate_destroy(ctx) }; - err })?; let ctx = unsafe { NonNull::new_unchecked(ctx) }; @@ -526,19 +525,6 @@ impl Mpv { }) } - /// Execute a command - pub fn execute(&self, name: &str, args: &[&str]) -> Result<()> { - if args.is_empty() { - debug!("Running mpv command: '{}'", name); - } else { - debug!("Running mpv command: '{} {}'", name, args.join(" ")); - } - - self.command(name, args)?; - - Ok(()) - } - /// Load a configuration file. The path has to be absolute, and a file. pub fn load_config(&self, path: &str) -> Result<()> { let file = CString::new(path)?.into_raw(); @@ -562,7 +548,7 @@ impl Mpv { /// Send a command to the `Mpv` instance. This uses `mpv_command_string` internally, /// so that the syntax is the same as described in the [manual for the input.conf](https://mpv.io/manual/master/#list-of-input-commands). /// - /// Note that you may have to escape strings with `""` when they contain spaces. + /// Note that this function escapes the arguments for you. /// /// # Examples /// @@ -583,12 +569,19 @@ impl Mpv { /// # } /// ``` pub fn command(&self, name: &str, args: &[&str]) -> Result<()> { - let mut cmd = name.to_owned(); + fn escape(input: &str) -> String { + input.replace('"', "\\\"") + } + + let mut cmd = escape(name); for elem in args { cmd.push(' '); - cmd.push_str(elem); + cmd.push('"'); + cmd.push_str(&escape(elem)); + cmd.push('"'); } + debug!("Running mpv command: '{}'", cmd); let raw = CString::new(cmd)?; mpv_err((), unsafe { @@ -597,7 +590,9 @@ impl Mpv { } /// Set the value of a property. - pub fn set_property<T: SetData>(&self, name: &str, data: T) -> Result<()> { + pub fn set_property<T: SetData + std::fmt::Display>(&self, name: &str, data: T) -> Result<()> { + debug!("Setting mpv property: '{name}' = '{data}'"); + let name = CString::new(name)?; let format = T::get_format().as_mpv_format() as _; data.call_as_c_void(|ptr| { diff --git a/crates/libmpv2/src/mpv/errors.rs b/crates/libmpv2/src/mpv/errors.rs index a2baee5..a2d3dd8 100644 --- a/crates/libmpv2/src/mpv/errors.rs +++ b/crates/libmpv2/src/mpv/errors.rs @@ -8,36 +8,52 @@ // 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::NulError, os::raw as ctype, str::Utf8Error}; - -use thiserror::Error; +use std::{ffi::NulError, fmt::Display, os::raw as ctype, str::Utf8Error}; use super::mpv_error; #[allow(missing_docs)] pub type Result<T> = ::std::result::Result<T, Error>; -#[derive(Error, Debug)] +#[derive(Debug)] pub enum Error { - #[error("loading file failed: {error}")] - Loadfile { error: String }, + Loadfile { + error: String, + }, - #[error("version mismatch detected! Linked version ({linked}) is unequal to the loaded version ({loaded})")] VersionMismatch { linked: ctype::c_ulong, loaded: ctype::c_ulong, }, - #[error("invalid utf8 returned")] InvalidUtf8, - #[error("null pointer returned")] Null, - #[error("raw error returned: {}", to_string_mpv_error(*(.0)))] Raw(crate::MpvError), } +impl std::error::Error for Error {} + +impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Loadfile { error } => write!(f, "loading file failed: {error}"), + Error::VersionMismatch { linked, loaded } => write!( + f, + "version mismatch detected! Linked version ({linked}) is unequal to the loaded version ({loaded})" + ), + Error::InvalidUtf8 => f.write_str("invalid utf8 returned"), + Error::Null => f.write_str("null pointer returned"), + Error::Raw(raw) => write!( + f, + include_str!("./raw_error_warning.txt"), + to_string_mpv_error(*(raw)) + ), + } + } +} + impl From<NulError> for Error { fn from(_other: NulError) -> Error { Error::Null @@ -76,35 +92,70 @@ fn to_string_mpv_error_raw(num: crate::MpvError) -> (&'static str, &'static str) mpv_error::NoMem => ("Memory allocation failed.", ""), - mpv_error::Uninitialized => ("The mpv core wasn't configured and initialized yet", " See the notes in mpv_create()."), + mpv_error::Uninitialized => ( + "The mpv core wasn't configured and initialized yet", + " See the notes in mpv_create().", + ), - mpv_error::InvalidParameter => ("Generic catch-all error if a parameter is set to an invalid or unsupported value.", "This is used if there is no better error code."), + mpv_error::InvalidParameter => ( + "Generic catch-all error if a parameter is set to an invalid or unsupported value.", + "This is used if there is no better error code.", + ), mpv_error::OptionNotFound => ("Trying to set an option that doesn't exist.", ""), - mpv_error::OptionFormat => ("Trying to set an option using an unsupported MPV_FORMAT.", ""), - mpv_error::OptionError => ("Setting the option failed", " Typically this happens if the provided option value could not be parsed."), + mpv_error::OptionFormat => ( + "Trying to set an option using an unsupported MPV_FORMAT.", + "", + ), + mpv_error::OptionError => ( + "Setting the option failed", + " Typically this happens if the provided option value could not be parsed.", + ), mpv_error::PropertyNotFound => ("The accessed property doesn't exist.", ""), - mpv_error::PropertyFormat => ("Trying to set or get a property using an unsupported MPV_FORMAT.", ""), - mpv_error::PropertyUnavailable => ("The property exists, but is not available", "This usually happens when the associated subsystem is not active, e.g. querying audio parameters while audio is disabled."), + mpv_error::PropertyFormat => ( + "Trying to set or get a property using an unsupported MPV_FORMAT.", + "", + ), + mpv_error::PropertyUnavailable => ( + "The property exists, but is not available", + "This usually happens when the associated subsystem is not active, e.g. querying audio parameters while audio is disabled.", + ), mpv_error::PropertyError => ("Error setting or getting a property.", ""), - mpv_error::Command => ("General error when running a command with mpv_command and similar.", ""), + mpv_error::Command => ( + "General error when running a command with mpv_command and similar.", + "", + ), - mpv_error::LoadingFailed => ("Generic error on loading (usually used with mpv_event_end_file.error).", ""), + mpv_error::LoadingFailed => ( + "Generic error on loading (usually used with mpv_event_end_file.error).", + "", + ), mpv_error::AoInitFailed => ("Initializing the audio output failed.", ""), mpv_error::VoInitFailed => ("Initializing the video output failed.", ""), - mpv_error::NothingToPlay => ("There was no audio or video data to play", "This also happens if the file was recognized, but did not contain any audio or video streams, or no streams were selected."), + mpv_error::NothingToPlay => ( + "There was no audio or video data to play", + "This also happens if the file was recognized, but did not contain any audio or video streams, or no streams were selected.", + ), - mpv_error::UnknownFormat => (" * When trying to load the file, the file format could not be determined, or the file was too broken to open it.", ""), + mpv_error::UnknownFormat => ( + " * When trying to load the file, the file format could not be determined, or the file was too broken to open it.", + "", + ), - mpv_error::Generic => ("Generic error for signaling that certain system requirements are not fulfilled.", ""), + mpv_error::Generic => ( + "Generic error for signaling that certain system requirements are not fulfilled.", + "", + ), mpv_error::NotImplemented => ("The API function which was called is a stub only", ""), mpv_error::Unsupported => ("Unspecified error.", ""), - mpv_error::Success => unreachable!("This is not an error. It's just here, to ensure that the 0 case marks an success'"), + mpv_error::Success => unreachable!( + "This is not an error. It's just here, to ensure that the 0 case marks an success'" + ), _ => unreachable!("Mpv seems to have changed it's constants."), } } diff --git a/crates/libmpv2/src/mpv/events.rs b/crates/libmpv2/src/mpv/events.rs index 6fb4683..f10ff6e 100644 --- a/crates/libmpv2/src/mpv/events.rs +++ b/crates/libmpv2/src/mpv/events.rs @@ -11,7 +11,7 @@ use crate::mpv_node::sys_node::SysMpvNode; use crate::{mpv::mpv_err, *}; -use std::ffi::{c_void, CString}; +use std::ffi::{CString, c_void}; use std::os::raw as ctype; use std::ptr::NonNull; use std::slice; @@ -70,26 +70,28 @@ impl<'a> PropertyData<'a> { // SAFETY: meant to extract the data from an event property. See `mpv_event_property` in // `client.h` unsafe fn from_raw(format: MpvFormat, ptr: *mut ctype::c_void) -> Result<PropertyData<'a>> { - assert!(!ptr.is_null()); - match format { - mpv_format::Flag => Ok(PropertyData::Flag(*(ptr as *mut bool))), - mpv_format::String => { - let char_ptr = *(ptr as *mut *mut ctype::c_char); - Ok(PropertyData::Str(mpv_cstr_to_str!(char_ptr)?)) - } - mpv_format::OsdString => { - let char_ptr = *(ptr as *mut *mut ctype::c_char); - Ok(PropertyData::OsdStr(mpv_cstr_to_str!(char_ptr)?)) - } - mpv_format::Double => Ok(PropertyData::Double(*(ptr as *mut f64))), - mpv_format::Int64 => Ok(PropertyData::Int64(*(ptr as *mut i64))), - mpv_format::Node => { - let sys_node = *(ptr as *mut libmpv2_sys::mpv_node); - let node = SysMpvNode::new(sys_node, false); - return Ok(PropertyData::Node(node.value().unwrap())); + unsafe { + assert!(!ptr.is_null()); + match format { + mpv_format::Flag => Ok(PropertyData::Flag(*(ptr as *mut bool))), + mpv_format::String => { + let char_ptr = *(ptr as *mut *mut ctype::c_char); + Ok(PropertyData::Str(mpv_cstr_to_str!(char_ptr)?)) + } + mpv_format::OsdString => { + let char_ptr = *(ptr as *mut *mut ctype::c_char); + Ok(PropertyData::OsdStr(mpv_cstr_to_str!(char_ptr)?)) + } + mpv_format::Double => Ok(PropertyData::Double(*(ptr as *mut f64))), + mpv_format::Int64 => Ok(PropertyData::Int64(*(ptr as *mut i64))), + mpv_format::Node => { + let sys_node = *(ptr as *mut libmpv2_sys::mpv_node); + let node = SysMpvNode::new(sys_node, false); + Ok(PropertyData::Node(node.value().unwrap())) + } + mpv_format::None => unreachable!(), + _ => unimplemented!(), } - mpv_format::None => unreachable!(), - _ => unimplemented!(), } } } @@ -146,11 +148,13 @@ pub enum Event<'a> { } unsafe extern "C" fn wu_wrapper<F: Fn() + Send + 'static>(ctx: *mut c_void) { - if ctx.is_null() { - panic!("ctx for wakeup wrapper is NULL"); - } + unsafe { + if ctx.is_null() { + panic!("ctx for wakeup wrapper is NULL"); + } - (*(ctx as *mut F))(); + (*(ctx as *mut F))(); + } } /// Context to listen to events. diff --git a/crates/libmpv2/src/mpv/protocol.rs b/crates/libmpv2/src/mpv/protocol.rs index 31a5933..ee33411 100644 --- a/crates/libmpv2/src/mpv/protocol.rs +++ b/crates/libmpv2/src/mpv/protocol.rs @@ -17,7 +17,7 @@ use std::os::raw as ctype; use std::panic; use std::panic::RefUnwindSafe; use std::slice; -use std::sync::{atomic::Ordering, Mutex}; +use std::sync::{Mutex, atomic::Ordering}; impl Mpv { /// Create a context with which custom protocols can be registered. @@ -63,26 +63,28 @@ where T: RefUnwindSafe, U: RefUnwindSafe, { - let data = user_data as *mut ProtocolData<T, U>; + unsafe { + let data = user_data as *mut ProtocolData<T, U>; - (*info).cookie = user_data; - (*info).read_fn = Some(read_wrapper::<T, U>); - (*info).seek_fn = Some(seek_wrapper::<T, U>); - (*info).size_fn = Some(size_wrapper::<T, U>); - (*info).close_fn = Some(close_wrapper::<T, U>); + (*info).cookie = user_data; + (*info).read_fn = Some(read_wrapper::<T, U>); + (*info).seek_fn = Some(seek_wrapper::<T, U>); + (*info).size_fn = Some(size_wrapper::<T, U>); + (*info).close_fn = Some(close_wrapper::<T, U>); - let ret = panic::catch_unwind(|| { - let uri = mpv_cstr_to_str!(uri as *const _).unwrap(); - ptr::write( - (*data).cookie, - ((*data).open_fn)(&mut (*data).user_data, uri), - ); - }); + let ret = panic::catch_unwind(|| { + let uri = mpv_cstr_to_str!(uri as *const _).unwrap(); + ptr::write( + (*data).cookie, + ((*data).open_fn)(&mut (*data).user_data, uri), + ); + }); - if ret.is_ok() { - 0 - } else { - mpv_error::Generic as _ + if ret.is_ok() { + 0 + } else { + mpv_error::Generic as _ + } } } @@ -95,16 +97,14 @@ where T: RefUnwindSafe, U: RefUnwindSafe, { - let data = cookie as *mut ProtocolData<T, U>; + unsafe { + let data = cookie as *mut ProtocolData<T, U>; - let ret = panic::catch_unwind(|| { - let slice = slice::from_raw_parts_mut(buf, nbytes as _); - ((*data).read_fn)(&mut *(*data).cookie, slice) - }); - if let Ok(ret) = ret { - ret - } else { - -1 + let ret = panic::catch_unwind(|| { + let slice = slice::from_raw_parts_mut(buf, nbytes as _); + ((*data).read_fn)(&mut *(*data).cookie, slice) + }); + ret.unwrap_or(-1) } } @@ -113,18 +113,21 @@ where T: RefUnwindSafe, U: RefUnwindSafe, { - let data = cookie as *mut ProtocolData<T, U>; + unsafe { + let data = cookie as *mut ProtocolData<T, U>; - if (*data).seek_fn.is_none() { - return mpv_error::Unsupported as _; - } + if (*data).seek_fn.is_none() { + return mpv_error::Unsupported as _; + } - let ret = - panic::catch_unwind(|| (*(*data).seek_fn.as_ref().unwrap())(&mut *(*data).cookie, offset)); - if let Ok(ret) = ret { - ret - } else { - mpv_error::Generic as _ + let ret = panic::catch_unwind(|| { + (*(*data).seek_fn.as_ref().unwrap())(&mut *(*data).cookie, offset) + }); + if let Ok(ret) = ret { + ret + } else { + mpv_error::Generic as _ + } } } @@ -133,17 +136,20 @@ where T: RefUnwindSafe, U: RefUnwindSafe, { - let data = cookie as *mut ProtocolData<T, U>; + unsafe { + let data = cookie as *mut ProtocolData<T, U>; - if (*data).size_fn.is_none() { - return mpv_error::Unsupported as _; - } + if (*data).size_fn.is_none() { + return mpv_error::Unsupported as _; + } - let ret = panic::catch_unwind(|| (*(*data).size_fn.as_ref().unwrap())(&mut *(*data).cookie)); - if let Ok(ret) = ret { - ret - } else { - mpv_error::Unsupported as _ + let ret = + panic::catch_unwind(|| (*(*data).size_fn.as_ref().unwrap())(&mut *(*data).cookie)); + if let Ok(ret) = ret { + ret + } else { + mpv_error::Unsupported as _ + } } } @@ -153,9 +159,11 @@ where T: RefUnwindSafe, U: RefUnwindSafe, { - let data = Box::from_raw(cookie as *mut ProtocolData<T, U>); + unsafe { + let data = Box::from_raw(cookie as *mut ProtocolData<T, U>); - panic::catch_unwind(|| (data.close_fn)(Box::from_raw(data.cookie))); + panic::catch_unwind(|| (data.close_fn)(Box::from_raw(data.cookie))); + } } struct ProtocolData<T, U> { @@ -177,8 +185,8 @@ pub struct ProtocolContext<'parent, T: RefUnwindSafe, U: RefUnwindSafe> { _does_not_outlive: PhantomData<&'parent Mpv>, } -unsafe impl<'parent, T: RefUnwindSafe, U: RefUnwindSafe> Send for ProtocolContext<'parent, T, U> {} -unsafe impl<'parent, T: RefUnwindSafe, U: RefUnwindSafe> Sync for ProtocolContext<'parent, T, U> {} +unsafe impl<T: RefUnwindSafe, U: RefUnwindSafe> Send for ProtocolContext<'_, T, U> {} +unsafe impl<T: RefUnwindSafe, U: RefUnwindSafe> Sync for ProtocolContext<'_, T, U> {} impl<'parent, T: RefUnwindSafe, U: RefUnwindSafe> ProtocolContext<'parent, T, U> { fn new( @@ -228,20 +236,23 @@ impl<T: RefUnwindSafe, U: RefUnwindSafe> Protocol<T, U> { seek_fn: Option<StreamSeek<T>>, size_fn: Option<StreamSize<T>>, ) -> Protocol<T, U> { - let c_layout = Layout::from_size_align(mem::size_of::<T>(), mem::align_of::<T>()).unwrap(); - let cookie = alloc::alloc(c_layout) as *mut T; - let data = Box::into_raw(Box::new(ProtocolData { - cookie, - user_data, + unsafe { + let c_layout = + Layout::from_size_align(mem::size_of::<T>(), mem::align_of::<T>()).unwrap(); + let cookie = alloc::alloc(c_layout) as *mut T; + let data = Box::into_raw(Box::new(ProtocolData { + cookie, + user_data, - open_fn, - close_fn, - read_fn, - seek_fn, - size_fn, - })); + open_fn, + close_fn, + read_fn, + seek_fn, + size_fn, + })); - Protocol { name, data } + Protocol { name, data } + } } fn register(&self, ctx: *mut libmpv2_sys::mpv_handle) -> Result<()> { diff --git a/crates/libmpv2/src/mpv/raw_error_warning.txt b/crates/libmpv2/src/mpv/raw_error_warning.txt new file mode 100644 index 0000000..277500a --- /dev/null +++ b/crates/libmpv2/src/mpv/raw_error_warning.txt @@ -0,0 +1,5 @@ +Raw mpv error: {} + +This error is directly returned from `mpv`. +This is probably caused by a bug in `yt`, please open an issue about +this and try to replicate it with the `-vvvv` verbosity setting. diff --git a/package/blake3/add_cargo_lock.patch.license b/crates/libmpv2/src/mpv/raw_error_warning.txt.license index d4d410f..7813eb6 100644 --- a/package/blake3/add_cargo_lock.patch.license +++ b/crates/libmpv2/src/mpv/raw_error_warning.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/libmpv2/src/mpv/render.rs b/crates/libmpv2/src/mpv/render.rs index c3f2dc9..02f70bb 100644 --- a/crates/libmpv2/src/mpv/render.rs +++ b/crates/libmpv2/src/mpv/render.rs @@ -8,9 +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>. -use crate::{mpv::mpv_err, Error, Result}; +use crate::{Error, Result, mpv::mpv_err}; use std::collections::HashMap; -use std::ffi::{c_void, CStr}; +use std::ffi::{CStr, c_void}; use std::os::raw::c_int; use std::ptr; @@ -125,26 +125,30 @@ impl<C> From<&RenderParam<C>> for u32 { } unsafe extern "C" fn gpa_wrapper<GLContext>(ctx: *mut c_void, name: *const i8) -> *mut c_void { - if ctx.is_null() { - panic!("ctx for get_proc_address wrapper is NULL"); - } + unsafe { + if ctx.is_null() { + panic!("ctx for get_proc_address wrapper is NULL"); + } - let params: *mut OpenGLInitParams<GLContext> = ctx as _; - let params = &*params; - (params.get_proc_address)( - ¶ms.ctx, - CStr::from_ptr(name) - .to_str() - .expect("Could not convert function name to str"), - ) + let params: *mut OpenGLInitParams<GLContext> = ctx as _; + let params = &*params; + (params.get_proc_address)( + ¶ms.ctx, + CStr::from_ptr(name) + .to_str() + .expect("Could not convert function name to str"), + ) + } } unsafe extern "C" fn ru_wrapper<F: Fn() + Send + 'static>(ctx: *mut c_void) { - if ctx.is_null() { - panic!("ctx for render_update wrapper is NULL"); - } + unsafe { + if ctx.is_null() { + panic!("ctx for render_update wrapper is NULL"); + } - (*(ctx as *mut F))(); + (*(ctx as *mut F))(); + } } impl<C> From<OpenGLInitParams<C>> for libmpv2_sys::mpv_opengl_init_params { @@ -197,14 +201,18 @@ impl<C> From<RenderParam<C>> for libmpv2_sys::mpv_render_param { } unsafe fn free_void_data<T>(ptr: *mut c_void) { - drop(Box::<T>::from_raw(ptr as *mut T)); + unsafe { + drop(Box::<T>::from_raw(ptr as *mut T)); + } } unsafe fn free_init_params<C>(ptr: *mut c_void) { - let params = Box::from_raw(ptr as *mut libmpv2_sys::mpv_opengl_init_params); - drop(Box::from_raw( - params.get_proc_address_ctx as *mut OpenGLInitParams<C>, - )); + unsafe { + let params = Box::from_raw(ptr as *mut libmpv2_sys::mpv_opengl_init_params); + drop(Box::from_raw( + params.get_proc_address_ctx as *mut OpenGLInitParams<C>, + )); + } } impl RenderContext { diff --git a/crates/yt_dlp/.cargo/config.toml b/crates/termsize/.gitignore index d84f14d..5bc2870 100644 --- a/crates/yt_dlp/.cargo/config.toml +++ b/crates/termsize/.gitignore @@ -1,12 +1,12 @@ # 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 softprops <d.tangren@gmail.com> +# SPDX-License-Identifier: MIT # # 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>. -[env] -PYO3_PYTHON = "/nix/store/7xzk119acyws2c4ysygdv66l0grxkr39-python3-3.11.9-env/bin/python3" +target +Cargo.lock diff --git a/crates/termsize/Cargo.toml b/crates/termsize/Cargo.toml new file mode 100644 index 0000000..10ab7ed --- /dev/null +++ b/crates/termsize/Cargo.toml @@ -0,0 +1,36 @@ +# yt - A fully featured command line YouTube client +# +# Copyright (C) 2025 softprops <d.tangren@gmail.com> +# SPDX-License-Identifier: MIT +# +# 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 = "termsize" +authors = [ + "softprops <d.tangren@gmail.com>", + "Benedikt Peetz <benedikt.peetz@b-peetz.de>", +] +description = "Retrieves terminal size" +repository = "https://github.com/softprops/termsize" +homepage = "https://github.com/softprops/termsize" +documentation = "http://softprops.github.io/termsize" +keywords = ["tty", "terminal", "term", "size", "dimensions"] +license = "MIT" +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +publish = false + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +winapi = { version = "0.3", features = ["handleapi", "fileapi", "wincon"] } + +[lints] +workspace = true diff --git a/crates/termsize/LICENSE b/crates/termsize/LICENSE new file mode 100644 index 0000000..78c7d8a --- /dev/null +++ b/crates/termsize/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2015-2024 Doug Tangren + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/crates/termsize/LICENSE.license b/crates/termsize/LICENSE.license new file mode 100644 index 0000000..3562ab9 --- /dev/null +++ b/crates/termsize/LICENSE.license @@ -0,0 +1,9 @@ +yt - A fully featured command line YouTube client + +Copyright (C) 2025 softprops <d.tangren@gmail.com> +SPDX-License-Identifier: MIT + +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/termsize/README.md b/crates/termsize/README.md new file mode 100644 index 0000000..305669b --- /dev/null +++ b/crates/termsize/README.md @@ -0,0 +1,51 @@ +<!-- +yt - A fully featured command line YouTube client + +Copyright (C) 2025 softprops <d.tangren@gmail.com> +SPDX-License-Identifier: MIT + +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>. +--> + +# termsize + +[](https://github.com/softprops/termsize/actions/workflows/ci.yml) +[](https://crates.io/crates/termsize) + +> because terminal size matters + +Termsize is a rust crate providing a multi-platform interface for resolving your +terminal's current size in rows and columns. On most unix systems, this is +similar invoking the [stty(1)](http://man7.org/linux/man-pages/man1/stty.1.html) +program, requesting the terminal size. + +## [Documentation](https://softprops.github.com/termsize) + +## install + +run `cargo add termsize` in your terminal or add the following to your +`Cargo.toml` file + +```toml +[dependencies] +termsize = "0.1" +``` + +## usage + +Termize provides one function, `get`, which returns a `termsize::Size` struct +exposing two fields: `rows` and `cols` representing the number of rows and +columns a a terminal's stdout supports. + +```rust +pub fn main() { + termsize::get().map(|{ rows, cols }| { + println!("rows {} cols {}", size.rows, size.cols) + }); +} +``` + +Doug Tangren (softprops) 2015-2024 diff --git a/crates/termsize/src/lib.rs b/crates/termsize/src/lib.rs new file mode 100644 index 0000000..69e7b78 --- /dev/null +++ b/crates/termsize/src/lib.rs @@ -0,0 +1,52 @@ +#![deny(missing_docs)] +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 softprops <d.tangren@gmail.com> +// SPDX-License-Identifier: MIT +// +// 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>. + +//! Termsize is a tiny crate that provides a simple +//! interface for retrieving the current +//! [terminal interface](http://www.manpagez.com/man/4/tty/) size +//! +//! ```rust +//! extern crate termsize; +//! +//! termsize::get().map(|size| println!("rows {} cols {}", size.rows, size.cols)); +//! ``` + +/// Container for number of rows and columns +#[derive(Debug, Clone, Copy)] +pub struct Size { + /// number of rows + pub rows: u16, + /// number of columns + pub cols: u16, +} + +#[cfg(unix)] +#[path = "nix.rs"] +mod imp; + +#[cfg(windows)] +#[path = "win.rs"] +mod imp; + +#[cfg(not(any(unix, windows)))] +#[path = "other.rs"] +mod imp; + +pub use imp::get; + +#[cfg(test)] +mod tests { + use super::get; + #[test] + fn test_get() { + assert!(get().is_some()); + } +} diff --git a/crates/termsize/src/nix.rs b/crates/termsize/src/nix.rs new file mode 100644 index 0000000..d672f54 --- /dev/null +++ b/crates/termsize/src/nix.rs @@ -0,0 +1,100 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 softprops <d.tangren@gmail.com> +// SPDX-License-Identifier: MIT +// +// 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::IsTerminal; + +use self::super::Size; +use libc::{STDOUT_FILENO, TIOCGWINSZ, c_ushort, ioctl}; + +/// A representation of the size of the current terminal +#[repr(C)] +#[derive(Debug)] +struct UnixSize { + /// number of rows + pub rows: c_ushort, + /// number of columns + pub cols: c_ushort, + x: c_ushort, + y: c_ushort, +} + +/// Gets the current terminal size +#[must_use] +pub fn get() -> Option<Size> { + // http://rosettacode.org/wiki/Terminal_control/Dimensions#Library:_BSD_libc + if !std::io::stdout().is_terminal() { + return None; + } + let mut us = UnixSize { + rows: 0, + cols: 0, + x: 0, + y: 0, + }; + let r = unsafe { ioctl(STDOUT_FILENO, TIOCGWINSZ, &mut us) }; + if r == 0 { + Some(Size { + rows: us.rows, + cols: us.cols, + }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::{super::Size, get}; + use std::process::{Command, Output, Stdio}; + + #[cfg(target_os = "macos")] + fn stty_size() -> Output { + Command::new("stty") + .arg("-f") + .arg("/dev/stderr") + .arg("size") + .stderr(Stdio::inherit()) + .output() + .expect("expected stty output") + } + + #[cfg(not(target_os = "macos"))] + fn stty_size() -> Output { + Command::new("stty") + .arg("-F") + .arg("/dev/stderr") + .arg("size") + .stderr(Stdio::inherit()) + .output() + .expect("expected stty output") + } + + #[test] + fn test_shell() { + let output = stty_size(); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("expected utf8"); + let mut data = stdout.split_whitespace(); + let rs = data + .next() + .expect("expected row") + .parse::<u16>() + .expect("expected u16 col"); + let cs = data + .next() + .expect("expected col") + .parse::<u16>() + .expect("expected u16 col"); + if let Some(Size { rows, cols }) = get() { + assert_eq!(rows, rs); + assert_eq!(cols, cs); + } + } +} diff --git a/crates/termsize/src/other.rs b/crates/termsize/src/other.rs new file mode 100644 index 0000000..8a02f22 --- /dev/null +++ b/crates/termsize/src/other.rs @@ -0,0 +1,14 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 softprops <d.tangren@gmail.com> +// SPDX-License-Identifier: MIT +// +// 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>. + +/// Gets the current terminal size +pub fn get() -> Option<super::Size> { + None +} diff --git a/crates/termsize/src/win.rs b/crates/termsize/src/win.rs new file mode 100644 index 0000000..72d8433 --- /dev/null +++ b/crates/termsize/src/win.rs @@ -0,0 +1,52 @@ +// yt - A fully featured command line YouTube client +// +// Copyright (C) 2025 softprops <d.tangren@gmail.com> +// SPDX-License-Identifier: MIT +// +// 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::ptr; + +use winapi::um::{ + fileapi::{CreateFileA, OPEN_EXISTING}, + handleapi::INVALID_HANDLE_VALUE, + wincon::{CONSOLE_SCREEN_BUFFER_INFO, GetConsoleScreenBufferInfo}, + winnt::{FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE}, +}; + +use self::super::Size; + +/// Gets the current terminal size +pub fn get() -> Option<Size> { + // http://rosettacode.org/wiki/Terminal_control/Dimensions#Windows + let handle = unsafe { + CreateFileA( + b"CONOUT$\0".as_ptr() as *const i8, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_WRITE, + ptr::null_mut(), + OPEN_EXISTING, + 0, + ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return None; + } + let info = unsafe { + // https://msdn.microsoft.com/en-us/library/windows/desktop/ms683171(v=vs.85).aspx + let mut info = ::std::mem::MaybeUninit::<CONSOLE_SCREEN_BUFFER_INFO>::uninit(); + if GetConsoleScreenBufferInfo(handle, info.as_mut_ptr()) == 0 { + None + } else { + Some(info.assume_init()) + } + }; + info.map(|inf| Size { + rows: (inf.srWindow.Bottom - inf.srWindow.Top + 1) as u16, + cols: (inf.srWindow.Right - inf.srWindow.Left + 1) as u16, + }) +} diff --git a/yt/Cargo.toml b/crates/yt/Cargo.toml index 8e4538d..c3ed3b0 100644 --- a/yt/Cargo.toml +++ b/crates/yt/Cargo.toml @@ -1,6 +1,7 @@ # 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. @@ -23,21 +24,21 @@ rust-version.workspace = true publish = false [dependencies] -anyhow = "1.0.94" -blake3 = "1.5.5" -chrono = { version = "0.4.39", features = ["now"] } +anyhow = "1.0.98" +blake3 = "1.8.2" +chrono = { version = "0.4.41", features = ["now"] } chrono-humanize = "0.2.3" -clap = { version = "4.5.23", features = ["derive"] } +clap = { version = "4.5.40", features = ["derive"] } +clap_complete = { version = "4.5.54", features = ["unstable-dynamic"] } futures = "0.3.31" -nucleo-matcher = "0.3.1" -owo-colors = "4.1.0" +owo-colors = "4.2.2" regex = "1.11.1" -sqlx = { version = "0.8.2", features = ["runtime-tokio", "sqlite"] } +sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite"] } stderrlog = "0.6.0" -tempfile = "3.14.0" -toml = "0.8.19" -trinitry = { version = "0.2.2" } -xdg = "2.5.2" +tempfile = "3.20.0" +toml = "0.8.23" +xdg = "3.0.0" +shlex = "1.3.0" bytes.workspace = true libmpv2.workspace = true log.workspace = true @@ -46,6 +47,10 @@ serde_json.workspace = true tokio.workspace = true 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"] } [[bin]] name = "yt" diff --git a/crates/yt/src/ansi_escape_codes.rs b/crates/yt/src/ansi_escape_codes.rs new file mode 100644 index 0000000..462a126 --- /dev/null +++ b/crates/yt/src/ansi_escape_codes.rs @@ -0,0 +1,36 @@ +// 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() { + print!("{CSI}0J"); +} +pub 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 { + print!("{CSI}{number}A"); + } +} + +pub fn clear_whole_line() { + eprint!("{CSI}2K"); +} +pub 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/yt/src/app.rs b/crates/yt/src/app.rs index 1f82214..15a9388 100644 --- a/yt/src/app.rs +++ b/crates/yt/src/app.rs @@ -1,6 +1,7 @@ // 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. @@ -9,9 +10,10 @@ // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. use anyhow::{Context, Result}; -use sqlx::{query, sqlite::SqliteConnectOptions, SqlitePool}; +use log::warn; +use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; -use crate::config::Config; +use crate::{config::Config, storage::migrate::migrate_db}; #[derive(Debug)] pub struct App { @@ -20,7 +22,7 @@ pub struct App { } impl App { - pub async fn new(config: Config) -> Result<Self> { + pub 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) @@ -30,13 +32,19 @@ impl App { .await .context("Failed to connect to database!")?; - query(include_str!("storage/video_database/schema.sql")) - .execute(&pool) - .await?; - - Ok(App { + let app = App { database: pool, config, - }) + }; + + if should_migrate_db { + migrate_db(&app) + .await + .context("Failed to migrate db to new version")?; + } else { + warn!("Skipping database migration."); + } + + Ok(app) } } diff --git a/yt/src/cache/mod.rs b/crates/yt/src/cache/mod.rs index dfbc276..83d5ee0 100644 --- a/yt/src/cache/mod.rs +++ b/crates/yt/src/cache/mod.rs @@ -1,6 +1,7 @@ // 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. @@ -15,8 +16,7 @@ use tokio::fs; use crate::{ app::App, storage::video_database::{ - downloader::set_video_cache_path, getters::get_videos, setters::set_state_change, Video, - VideoStatus, + Video, VideoStatus, VideoStatusMarker, downloader::set_video_cache_path, get, }, }; @@ -24,15 +24,17 @@ async fn invalidate_video(app: &App, video: &Video, hard: bool) -> Result<()> { info!("Invalidating cache of video: '{}'", video.title); if hard { - if let Some(path) = &video.cache_path { + 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.", + "Not actually removing path: '{}'. It is already gone.", path.display() ); } @@ -54,7 +56,7 @@ async fn invalidate_video(app: &App, video: &Video, hard: bool) -> Result<()> { } pub async fn invalidate(app: &App, hard: bool) -> Result<()> { - let all_cached_things = get_videos(app, &[VideoStatus::Cached], None).await?; + let all_cached_things = get::videos(app, &[VideoStatusMarker::Cached]).await?; info!("Got videos to invalidate: '{}'", all_cached_things.len()); @@ -65,36 +67,39 @@ pub async fn invalidate(app: &App, hard: bool) -> Result<()> { Ok(()) } +/// # Panics +/// Only if internal assertions fail. pub async fn maintain(app: &App, all: bool) -> Result<()> { let domain = if all { - vec![ - VideoStatus::Pick, - // - VideoStatus::Watch, - VideoStatus::Cached, - VideoStatus::Watched, - // - VideoStatus::Drop, - VideoStatus::Dropped, - ] + VideoStatusMarker::ALL.as_slice() } else { - vec![VideoStatus::Watch, VideoStatus::Cached] + &[VideoStatusMarker::Watch, VideoStatusMarker::Cached] }; - let cached_videos = get_videos(app, domain.as_slice(), None).await?; + let cached_videos = get::videos(app, domain).await?; + let mut found_focused = 0; for vid in cached_videos { - if let Some(path) = vid.cache_path.as_ref() { + 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 vid.status_change { - info!("Video '{}' has it's changing bit set. This is probably the result of an unexpectet exit. Clearing it", vid.title); - set_state_change(app, &vid.extractor_hash, 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/yt/src/cli.rs b/crates/yt/src/cli.rs index d7e084a..98bbb2d 100644 --- a/yt/src/cli.rs +++ b/crates/yt/src/cli.rs @@ -1,6 +1,7 @@ // 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,21 +9,31 @@ // 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::{ + ffi::OsStr, + fmt::{self, Display, Formatter}, + path::PathBuf, + str::FromStr, + thread, +}; use anyhow::Context; use bytes::Bytes; use chrono::NaiveDate; -use clap::{ArgAction, Args, Parser, Subcommand}; +use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum}; +use clap_complete::{ArgValueCompleter, CompletionCandidate}; +use tokio::runtime::Runtime; use url::Url; use crate::{ - select::selection_file::duration::Duration, - storage::video_database::extractor_hash::LazyExtractorHash, + app::App, + config::Config, + select::selection_file::duration::MaybeDuration, + storage::{subscriptions, video_database::extractor_hash::LazyExtractorHash}, }; #[derive(Parser, Debug)] -#[clap(author, version, about, long_about = None)] +#[clap(author, about, long_about = None)] #[allow(clippy::module_name_repetitions)] /// An command line interface to select, download and watch videos pub struct CliArgs { @@ -30,9 +41,18 @@ pub struct CliArgs { /// The subcommand to execute [default: select] pub command: Option<Command>, - /// Increase message verbosity - #[arg(long="verbose", short = 'v', action = ArgAction::Count)] - pub verbosity: u8, + /// Show the version and exit + #[arg(long, short = 'V', action= ArgAction::SetTrue)] + pub 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, + + /// Display colors [defaults to true, if the config file has no value] + #[arg(long, short = 'C')] + pub color: Option<bool>, /// Set the path to the videos.db. This overrides the default and the config file. #[arg(long, short)] @@ -43,6 +63,10 @@ pub struct CliArgs { #[arg(long, short)] pub config_path: Option<PathBuf>, + /// Increase message verbosity + #[arg(long="verbose", short = 'v', action = ArgAction::Count)] + pub verbosity: u8, + /// Silence all output #[arg(long, short = 'q')] pub quiet: bool, @@ -76,18 +100,19 @@ pub enum Command { /// 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 {}, - /// Perform various tests - Check { - #[command(subcommand)] - command: CheckCommand, - }, - /// Display the comments of the currently playing video Comments {}, /// Display the description of the currently playing video @@ -108,13 +133,36 @@ pub enum Command { /// Update the video database Update { + /// The maximal number of videos to fetch for each subscription. #[arg(short, long)] - /// The number of videos to updating - max_backlog: Option<u32>, - - #[arg(short, long)] - /// The subscriptions to update (can be given multiple times) + 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 + #[arg(add = ArgValueCompleter::new(complete_subscription))] 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 @@ -176,6 +224,7 @@ pub enum SubscriptionCommand { /// Unsubscribe from an URL Remove { /// The human readable name of the subscription + #[arg(add = ArgValueCompleter::new(complete_subscription))] name: String, }, @@ -214,15 +263,15 @@ pub struct SharedSelectionCommandArgs { /// The short extractor hash pub hash: LazyExtractorHash, - pub title: String, + pub title: Option<String>, - pub date: OptionalNaiveDate, + pub date: Option<OptionalNaiveDate>, - pub publisher: OptionalPublisher, + pub publisher: Option<OptionalPublisher>, - pub duration: Duration, + pub duration: Option<MaybeDuration>, - pub url: Url, + pub url: Option<Url>, } #[derive(Clone, Debug, Copy)] pub struct OptionalNaiveDate { @@ -257,6 +306,43 @@ impl FromStr for OptionalPublisher { } } +#[derive(Default, ValueEnum, Clone, Copy, Debug)] +pub 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)] +pub 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"), + } + } +} + #[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> @@ -272,9 +358,38 @@ pub enum SelectCommand { 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> }, + 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")] @@ -320,29 +435,19 @@ impl Default for SelectCommand { } } -#[derive(Subcommand, Clone, Debug)] -pub enum CheckCommand { - /// Check if the given info.json is deserializable - InfoJson { path: PathBuf }, - - /// Check if the given update info.json is deserializable - UpdateInfoJson { path: PathBuf }, -} - #[derive(Subcommand, Clone, Copy, Debug)] pub enum CacheCommand { /// Invalidate all cache entries Invalidate { /// Also delete the cache path - #[arg(short, long)] + #[arg(short = 'f', 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). + /// 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) - /// 2. Reset all `status_change` bits of videos to false. #[command(verbatim_doc_comment)] Maintain { /// Check every video (otherwise only the videos to be watched are checked) @@ -350,3 +455,50 @@ pub enum CacheCommand { all: bool, }, } + +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() +} + +#[cfg(test)] +mod test { + use clap::CommandFactory; + + use super::CliArgs; + #[test] + fn verify_cli() { + CliArgs::command().debug_assert(); + } +} diff --git a/crates/yt/src/comments/comment.rs b/crates/yt/src/comments/comment.rs new file mode 100644 index 0000000..5bc939c --- /dev/null +++ b/crates/yt/src/comments/comment.rs @@ -0,0 +1,152 @@ +// 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/yt/src/description/mod.rs b/crates/yt/src/comments/description.rs index 10f0e0c..878b573 100644 --- a/yt/src/description/mod.rs +++ b/crates/yt/src/comments/description.rs @@ -1,6 +1,7 @@ // 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. @@ -9,17 +10,14 @@ // If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. use crate::{ + App, comments::output::display_fmt_and_less, - storage::video_database::{ - getters::{get_currently_playing_video, get_video_info_json}, - Video, - }, + storage::video_database::{Video, get}, unreachable::Unreachable, - App, }; -use anyhow::{bail, Result}; -use yt_dlp::wrapper::info_json::InfoJson; +use anyhow::{Result, bail}; +use yt_dlp::json_cast; pub async fn description(app: &App) -> Result<()> { let description = get(app).await?; @@ -30,19 +28,19 @@ pub async fn description(app: &App) -> Result<()> { pub async fn get(app: &App) -> Result<String> { let currently_playing_video: Video = - if let Some(video) = get_currently_playing_video(app).await? { + 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) - .await? - .unreachable( - "A currently *playing* must be cached. And thus the info.json should be available", - ); + let info_json = 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 - .description - .unwrap_or("<No description>".to_owned())) + .get("description") + .map(|val| json_cast!(val, as_str)) + .unwrap_or("<No description>") + .to_owned()) } diff --git a/yt/src/comments/display.rs b/crates/yt/src/comments/display.rs index 4d678bc..6166b2b 100644 --- a/yt/src/comments/display.rs +++ b/crates/yt/src/comments/display.rs @@ -1,6 +1,7 @@ // 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/yt/src/comments/mod.rs b/crates/yt/src/comments/mod.rs index afc90de..54031a4 100644 --- a/yt/src/comments/mod.rs +++ b/crates/yt/src/comments/mod.rs @@ -1,6 +1,7 @@ // 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. @@ -10,18 +11,15 @@ use std::mem; -use anyhow::{bail, Context, Result}; -use comment::{CommentExt, Comments}; +use anyhow::{Result, bail}; +use comment::{Comment, CommentExt, Comments, Parent}; use output::display_fmt_and_less; use regex::Regex; -use yt_dlp::wrapper::info_json::{Comment, InfoJson, Parent}; +use yt_dlp::json_cast; use crate::{ app::App, - storage::video_database::{ - getters::{get_currently_playing_video, get_video_info_json}, - Video, - }, + storage::video_database::{Video, get}, unreachable::Unreachable, }; @@ -29,34 +27,37 @@ 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_playing_video(app).await? { + if let Some(video) = get::currently_focused_video(app).await? { video } else { bail!("Could not find a currently playing video!"); }; - let mut info_json: InfoJson = get_video_info_json(¤tly_playing_video) - .await? - .unreachable( - "A currently *playing* must be cached. And thus the info.json should be available", - ); + let info_json = 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 = mem::take(&mut info_json.comments).with_context(|| { - format!( + 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 - .title - .as_ref() - .unwrap_or(&("<No Title>".to_owned())) + .get("title") + .map(|val| json_cast!(val, as_str)) + .unwrap_or("<No Title>") ) - })?; - drop(info_json); + }; 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 { diff --git a/yt/src/comments/output.rs b/crates/yt/src/comments/output.rs index 626db2a..cb3a9c4 100644 --- a/yt/src/comments/output.rs +++ b/crates/yt/src/comments/output.rs @@ -1,6 +1,7 @@ // 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. @@ -14,6 +15,7 @@ use std::{ }; use anyhow::{Context, Result}; +use uu_fmt::{FmtOptions, process_text}; use crate::unreachable::Unreachable; @@ -25,15 +27,8 @@ pub async fn display_fmt_and_less(input: String) -> Result<()> { .spawn() .context("Failed to run less")?; - let mut child = Command::new("fmt") - .args(["--uniform-spacing", "--split-only", "--width=90"]) - .stdin(Stdio::piped()) - .stderr(Stdio::inherit()) - .stdout(less.stdin.take().unreachable("Should be open")) - .spawn() - .context("Failed to run fmt")?; - - let mut stdin = child.stdin.take().context("Failed to open stdin")?; + 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()) @@ -44,3 +39,15 @@ pub async fn display_fmt_and_less(input: String) -> Result<()> { Ok(()) } + +#[must_use] +pub fn format_text(input: &str) -> String { + let width = termsize::get().map_or(90, |size| size.cols); + let fmt_opts = FmtOptions { + uniform: true, + split_only: true, + ..FmtOptions::new(Some(width as usize), None, Some(4)) + }; + + process_text(input, &fmt_opts) +} diff --git a/yt/src/config/default.rs b/crates/yt/src/config/default.rs index 926f422..4ed643b 100644 --- a/yt/src/config/default.rs +++ b/crates/yt/src/config/default.rs @@ -1,6 +1,7 @@ // 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. @@ -13,19 +14,19 @@ 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)?; + 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)?; + 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)?; + let xdg_dirs = xdg::BaseDirectories::with_prefix(PREFIX); xdg_dirs .place_config_file(name) .with_context(|| format!("Failed to place config file: '{name}'")) @@ -44,6 +45,13 @@ pub(super) fn create_path(path: PathBuf) -> Result<PathBuf> { 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 @@ -60,7 +68,7 @@ pub(crate) mod watch { } pub(crate) mod update { - pub(crate) fn max_backlog() -> u32 { + pub(crate) fn max_backlog() -> usize { 20 } } @@ -70,7 +78,7 @@ pub(crate) mod paths { use anyhow::Result; - use super::{create_path, get_config_path, get_data_path, get_runtime_path, PREFIX}; + 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> { diff --git a/yt/src/config/definitions.rs b/crates/yt/src/config/definitions.rs index 3d025b3..ce8c0d4 100644 --- a/yt/src/config/definitions.rs +++ b/crates/yt/src/config/definitions.rs @@ -1,6 +1,7 @@ // 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. @@ -15,6 +16,7 @@ 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>, @@ -24,8 +26,14 @@ pub(crate) struct ConfigFile { #[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<u32>, + pub max_backlog: Option<usize>, } #[derive(Debug, Deserialize, PartialEq, Clone)] diff --git a/yt/src/config/file_system.rs b/crates/yt/src/config/file_system.rs index 6709a2b..2463e9d 100644 --- a/yt/src/config/file_system.rs +++ b/crates/yt/src/config/file_system.rs @@ -1,6 +1,7 @@ // 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. @@ -11,8 +12,8 @@ use crate::config::{DownloadConfig, PathsConfig, SelectConfig, WatchConfig}; use super::{ - default::{create_path, download, paths, select, update, watch}, - Config, UpdateConfig, + Config, GlobalConfig, UpdateConfig, + default::{create_path, download, global, paths, select, update, watch}, }; use std::{fs::read_to_string, path::PathBuf}; @@ -70,6 +71,7 @@ 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)?; @@ -79,6 +81,13 @@ impl Config { .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}, diff --git a/yt/src/config/mod.rs b/crates/yt/src/config/mod.rs index 36dd3fc..a10f7c2 100644 --- a/yt/src/config/mod.rs +++ b/crates/yt/src/config/mod.rs @@ -1,6 +1,7 @@ // 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. @@ -21,18 +22,24 @@ pub mod file_system; #[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. + +#[derive(Serialize, Debug)] +#[allow(missing_copy_implementations)] +pub struct GlobalConfig { + pub display_colors: bool, +} #[derive(Serialize, Debug)] -// This structure could get non-copy fields in the future. -// The same thing applies to all the other structures here. #[allow(missing_copy_implementations)] pub struct UpdateConfig { - pub max_backlog: u32, + pub max_backlog: usize, } #[derive(Serialize, Debug)] #[allow(missing_copy_implementations)] diff --git a/yt/src/constants.rs b/crates/yt/src/constants.rs index 54cae89..0f5b918 100644 --- a/yt/src/constants.rs +++ b/crates/yt/src/constants.rs @@ -1,6 +1,7 @@ // 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/src/download/download_options.rs b/crates/yt/src/download/download_options.rs new file mode 100644 index 0000000..558adfd --- /dev/null +++ b/crates/yt/src/download/download_options.rs @@ -0,0 +1,118 @@ +// 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; +use serde_json::{Value, json}; +use yt_dlp::{YoutubeDL, options::YoutubeDLOptions}; + +use crate::{app::App, storage::video_database::YtDlpOptions}; + +use super::progress_hook::wrapped_progress_hook; + +pub fn download_opts(app: &App, additional_opts: &YtDlpOptions) -> anyhow::Result<YoutubeDL> { + YoutubeDLOptions::new() + .with_progress_hook(wrapped_progress_hook) + .set("extract_flat", "in_playlist") + .set( + "extractor_args", + json! { + { + "youtube": { + "comment_sort": [ "top" ], + "max_comments": [ "150", "all", "100" ] + } + } + }, + ) + //.set("cookiesfrombrowser", json! {("firefox", "me.google", None::<String>, "youtube_dlp")}) + .set("prefer_free_formats", true) + .set("ffmpeg_location", env!("FFMPEG_LOCATION")) + .set("format", "bestvideo[height<=?1080]+bestaudio/best") + .set("fragment_retries", 10) + .set("getcomments", true) + .set("ignoreerrors", false) + .set("retries", 10) + .set("writeinfojson", true) + // NOTE: This results in a constant warning message. <2025-01-04> + //.set("writeannotations", true) + .set("writesubtitles", true) + .set("writeautomaticsub", true) + .set( + "outtmpl", + json! { + { + "default": app.config.paths.download_dir.join("%(channel)s/%(title)s.%(ext)s"), + "chapter": "%(title)s - %(section_number)03d %(section_title)s [%(id)s].%(ext)s" + } + }, + ) + .set("compat_opts", json! {{}}) + .set("forceprint", json! {{}}) + .set("print_to_file", json! {{}}) + .set("windowsfilenames", false) + .set("restrictfilenames", false) + .set("trim_file_names", false) + .set( + "postprocessors", + json! { + [ + { + "api": "https://sponsor.ajay.app", + "categories": [ + "interaction", + "intro", + "music_offtopic", + "sponsor", + "outro", + "poi_highlight", + "preview", + "selfpromo", + "filler", + "chapter" + ], + "key": "SponsorBlock", + "when": "after_filter" + }, + { + "force_keyframes": false, + "key": "ModifyChapters", + "remove_chapters_patterns": [], + "remove_ranges": [], + "remove_sponsor_segments": [ "sponsor" ], + "sponsorblock_chapter_title": "[SponsorBlock]: %(category_names)l" + }, + { + "add_chapters": true, + "add_infojson": null, + "add_metadata": false, + "key": "FFmpegMetadata" + }, + { + "key": "FFmpegConcat", + "only_multi_video": true, + "when": "playlist" + } + ] + }, + ) + .set( + "subtitleslangs", + Value::Array( + additional_opts + .subtitle_langs + .split(',') + .map(|val| Value::String(val.to_owned())) + .collect::<Vec<_>>(), + ), + ) + .build() + .context("Failed to instanciate download yt_dlp") +} diff --git a/yt/src/download/mod.rs b/crates/yt/src/download/mod.rs index 8bd8a75..6065cf9 100644 --- a/yt/src/download/mod.rs +++ b/crates/yt/src/download/mod.rs @@ -1,6 +1,7 @@ // 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. @@ -14,22 +15,25 @@ 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, - getters::get_video_yt_dlp_opts, - Video, YtDlpOptions, + get::get_video_yt_dlp_opts, + notify::wait_for_cache_reduction, }, unreachable::Unreachable, }; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use bytes::Bytes; -use futures::{future::BoxFuture, FutureExt}; +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)] @@ -107,7 +111,7 @@ impl Downloader { } } let cache_allocation = Self::get_current_cache_allocation(app).await?; - let video_size = self.get_approx_video_size(app, next_video).await?; + let video_size = self.get_approx_video_size(app, next_video)?; if video_size >= max_cache_size { error!( @@ -126,8 +130,11 @@ impl Downloader { 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) - ); + next_video.title, + Bytes::new(video_size), + &cache_allocation, + Bytes::new(max_cache_size) + ); self.printed_warning = true; // Update this value immediately. @@ -157,12 +164,10 @@ impl Downloader { "The `printed_warning` should be false in this case, \ and thus should have already set the `cached_cache_allocation`." ); - // info!("Current cache size allocation: '{}'", cache_allocation); - // self.cached_cache_allocation = Some(cache_allocation); } // Wait and hope, that a large video is deleted from the cache. - time::sleep(Duration::from_secs(10)).await; + wait_for_cache_reduction(app).await?; Ok(CacheSizeCheck::TooLarge) } else { self.printed_warning = false; @@ -198,11 +203,16 @@ impl Downloader { 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? + "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 = @@ -220,7 +230,8 @@ impl Downloader { self.current_download = Some(new_current_download); } - time::sleep(Duration::new(1, 0)).await; + // TODO(@bpeetz): Why do we sleep here? <2025-02-21> + time::sleep(Duration::from_secs(1)).await; } info!("Finished downloading!"); @@ -282,7 +293,7 @@ impl Downloader { dir_size(read_dir_result).await } - async fn get_approx_video_size(&mut self, app: &App, video: &Video) -> Result<u64> { + 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 { @@ -290,25 +301,28 @@ impl Downloader { let add_opts = YtDlpOptions { subtitle_langs: String::new(), }; - let opts = &download_opts(app, &add_opts); + let yt_dlp = download_opts(app, &add_opts)?; - let result = yt_dlp::extract_info(opts, &video.url, false, true) - .await + 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.filesize { - val - } else if let Some(val) = result.filesize_approx { - val - } else if result.duration.is_some() && result.tbr.is_some() { + let size = if let Some(val) = result.get("filesize") { + json_cast!(val, as_u64) + } else if let Some(serde_json::Value::Number(num)) = result.get("filesize_approx") { + // NOTE(@bpeetz): yt_dlp sets this value to `Null`, instead of omitting it when it + // can't calculate the approximate filesize. + // Thus, we have to check, that it is actually non-null, before we cast it. <2025-06-15> + json_cast!(num, 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 = result.duration.expect("Is some").ceil() as u64; + 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 = result.tbr.expect("Is Some").ceil() as u64; + let tbr = json_get!(result, "tbr", as_f64).ceil() as u64; duration * tbr * (1000 / 8) } else { @@ -333,9 +347,10 @@ impl Downloader { 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.clone()], &download_opts(app, &addional_opts)) - .await + let result = yt_dlp + .download(&[video.url.clone()]) .with_context(|| format!("Failed to download video: '{}'", video.title))?; assert_eq!(result.len(), 1); diff --git a/crates/yt/src/download/progress_hook.rs b/crates/yt/src/download/progress_hook.rs new file mode 100644 index 0000000..ad754b0 --- /dev/null +++ b/crates/yt/src/download/progress_hook.rs @@ -0,0 +1,198 @@ +// 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}, + 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/yt/src/main.rs b/crates/yt/src/main.rs index fbfdac0..f78c23e 100644 --- a/yt/src/main.rs +++ b/crates/yt/src/main.rs @@ -1,6 +1,7 @@ // 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. @@ -12,29 +13,27 @@ // to print it anyways. #![allow(clippy::missing_errors_doc)] -use std::{collections::HashMap, fs, sync::Arc}; +use std::{env::current_exe, sync::Arc}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use app::App; use bytes::Bytes; use cache::{invalidate, maintain}; -use clap::Parser; -use cli::{CacheCommand, CheckCommand, SelectCommand, SubscriptionCommand, VideosCommand}; +use clap::{CommandFactory, Parser}; +use cli::{CacheCommand, SelectCommand, SubscriptionCommand, VideosCommand}; use config::Config; -use log::info; +use log::{error, info}; use select::cmds::handle_select_cmd; -use storage::video_database::getters::get_video_by_hash; +use storage::video_database::get::video_by_hash; use tokio::{ fs::File, - io::{stdin, BufReader}, + io::{BufReader, stdin}, task::JoinHandle, }; -use url::Url; -use videos::display::format_video::FormatVideo; -use yt_dlp::wrapper::info_json::InfoJson; use crate::{cli::Command, storage::subscriptions}; +pub mod ansi_escape_codes; pub mod app; pub mod cli; pub mod unreachable; @@ -43,13 +42,13 @@ pub mod cache; pub mod comments; pub mod config; pub mod constants; -pub mod description; 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; @@ -57,6 +56,8 @@ pub mod watch; // 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) @@ -83,10 +84,13 @@ async fn main() -> Result<()> { } }); - let app = { - let config = Config::from_config_file(args.db_path, args.config_path)?; - App::new(config).await? - }; + let config = Config::from_config_file(args.db_path, args.config_path, args.color)?; + 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 { @@ -113,12 +117,17 @@ async fn main() -> Result<()> { SelectCommand::File { done, use_last_selection, - } => select::select(&app, done, use_last_selection).await?, - _ => handle_select_cmd(&app, cmd, None).await?, + } => Box::pin(select::select_file(&app, done, use_last_selection)).await?, + SelectCommand::Split { + done, + sort_key, + sort_mode, + } => Box::pin(select::select_split(&app, done, sort_key, sort_mode)).await?, + _ => Box::pin(handle_select_cmd(&app, cmd, None)).await?, } } Command::Sedowa {} => { - select::select(&app, false, false).await?; + Box::pin(select::select_file(&app, false, false)).await?; let arc_app = Arc::new(app); dowa(arc_app).await?; @@ -137,22 +146,23 @@ async fn main() -> Result<()> { .context("Failed to query videos")?; } VideosCommand::Info { hash } => { - let video = get_video_by_hash(&app, &hash.realize(&app).await?).await?; + let video = video_by_hash(&app, &hash.realize(&app).await?).await?; print!( "{}", - (&video - .to_formatted_video(&app) + &video + .to_info_display(&app) .await .context("Failed to format video")? - .colorize()) - .to_info_display() ); } }, Command::Update { max_backlog, subscriptions, + grouped, + current_progress, + total_number, } => { let all_subs = subscriptions::get(&app).await?; @@ -167,7 +177,57 @@ async fn main() -> Result<()> { let max_backlog = max_backlog.unwrap_or(app.config.update.max_backlog); - update::update(&app, max_backlog, subscriptions, verbosity).await?; + 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")?, + ) + .args((0..args.verbosity).map(|_| "-v")) + .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 } => { @@ -200,11 +260,12 @@ async fn main() -> Result<()> { subscribe::import(&app, BufReader::new(f), force).await?; } else { subscribe::import(&app, BufReader::new(stdin()), force).await?; - }; + } } }, - Command::Watch {} => watch::watch(&app).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)?, @@ -214,31 +275,11 @@ async fn main() -> Result<()> { CacheCommand::Maintain { all } => maintain(&app, all).await?, }, - Command::Check { command } => match command { - CheckCommand::InfoJson { path } => { - let string = fs::read_to_string(&path) - .with_context(|| format!("Failed to read '{}' to string!", path.display()))?; - - drop( - serde_json::from_str::<InfoJson>(&string) - .context("Failed to deserialize value")?, - ); - } - CheckCommand::UpdateInfoJson { path } => { - let string = fs::read_to_string(&path) - .with_context(|| format!("Failed to read '{}' to string!", path.display()))?; - - drop( - serde_json::from_str::<HashMap<Url, InfoJson>>(&string) - .context("Failed to deserialize value")?, - ); - } - }, Command::Comments {} => { comments::comments(&app).await?; } Command::Description {} => { - description::description(&app).await?; + comments::description(&app).await?; } } @@ -247,18 +288,20 @@ async fn main() -> Result<()> { 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); + info!("Max cache size: '{max_cache_size}'"); let arc_app_clone = Arc::clone(&arc_app); - let download: JoinHandle<Result<()>> = tokio::spawn(async move { - download::Downloader::new() + let download: JoinHandle<()> = tokio::spawn(async move { + let result = download::Downloader::new() .consume(arc_app_clone, max_cache_size.as_u64()) - .await?; + .await; - Ok(()) + if let Err(err) = result { + error!("Error from downloader: {err:?}"); + } }); - watch::watch(&arc_app).await?; - download.await??; + watch::watch(arc_app).await?; + download.await?; Ok(()) } diff --git a/crates/yt/src/select/cmds/add.rs b/crates/yt/src/select/cmds/add.rs new file mode 100644 index 0000000..2c9a323 --- /dev/null +++ b/crates/yt/src/select/cmds/add.rs @@ -0,0 +1,193 @@ +// 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, + download::download_options::download_opts, + storage::video_database::{ + self, extractor_hash::ExtractorHash, get::get_all_hashes, set::add_video, + }, + update::video_entry_to_video, +}; + +use anyhow::{Context, Result, bail}; +use log::{error, warn}; +use url::Url; +use yt_dlp::{YoutubeDL, info_json::InfoJson, json_cast, json_get}; + +#[allow(clippy::too_many_lines)] +pub(super) async fn add( + app: &App, + urls: Vec<Url>, + start: Option<usize>, + stop: Option<usize>, +) -> 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}'"))?; + + add_entry(app, entry).await?; + + Ok(()) + } + + 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) + .await + .context("Failed to fetch all video hashes")?; + + let extractor_hash = blake3::hash(json_get!(entry, "id", as_str).as_bytes()); + if hashes.contains(&extractor_hash) { + error!( + "Video '{}'{} is already in the database. Skipped adding it", + ExtractorHash::from_hash(extractor_hash) + .into_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) + ))?, + entry.get("title").map_or(String::new(), |title| format!( + " (\"{}\")", + json_cast!(title, as_str) + )) + ); + return Ok(()); + } + + let video = video_entry_to_video(&entry, None)?; + add_video(app, video.clone()).await?; + + println!("{}", &video.to_line_display(app).await?); + + Ok(()) + } + + let yt_dlp = download_opts( + app, + &video_database::YtDlpOptions { + subtitle_langs: String::new(), + }, + )?; + + 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") => { + add_entry(app, entry).await?; + if start.is_some() || stop.is_some() { + warn!( + "You added `start` and/or `stop` markers for a single *video*! These will be ignored." + ); + } + } + Some("playlist") => { + if let Some(entries) = entry.get("entries") { + let entries = json_cast!(entries, as_array); + let start = start.unwrap_or(0); + let stop = stop.unwrap_or(entries.len() - 1); + + let respected_entries = + take_vector(entries, start, stop).with_context(|| { + format!( + "Failed to take entries starting at: {start} and ending with {stop}" + ) + })?; + + if respected_entries.is_empty() { + warn!("No entries found, after applying your start/stop limits."); + } else { + // Pre-warm the cache + process_and_add( + app, + json_cast!(respected_entries[0], as_object).to_owned(), + &yt_dlp, + ) + .await?; + let respected_entries = &respected_entries[1..]; + + let futures: Vec<_> = respected_entries + .iter() + .map(|entry| { + process_and_add( + app, + json_cast!(entry, as_object).to_owned(), + &yt_dlp, + ) + }) + .collect(); + + for fut in futures { + fut.await?; + } + } + } else { + bail!("Your playlist does not seem to have any entries!") + } + } + other => bail!( + "Your URL should point to a video or a playlist, but points to a '{:#?}'", + other + ), + } + } + + Ok(()) +} + +fn take_vector<T>(vector: &[T], start: usize, stop: usize) -> Result<&[T]> { + let length = vector.len(); + + if stop >= length { + bail!( + "Your stop marker ({stop}) exceeds the possible entries ({length})! Remember that it is zero indexed." + ); + } + + Ok(&vector[start..=stop]) +} + +#[cfg(test)] +mod test { + use crate::select::cmds::add::take_vector; + + #[test] + fn test_vector_take() { + let vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + let new_vec = take_vector(&vec, 2, 8).unwrap(); + + assert_eq!(new_vec, vec![2, 3, 4, 5, 6, 7, 8]); + } + + #[test] + fn test_vector_take_overflow() { + let vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + assert!(take_vector(&vec, 0, 12).is_err()); + } + + #[test] + fn test_vector_take_equal() { + let vec = vec![0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + assert!(take_vector(&vec, 0, 11).is_err()); + } +} diff --git a/crates/yt/src/select/cmds/mod.rs b/crates/yt/src/select/cmds/mod.rs new file mode 100644 index 0000000..9da795a --- /dev/null +++ b/crates/yt/src/select/cmds/mod.rs @@ -0,0 +1,113 @@ +// 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 { .. } | SelectCommand::Split { .. } => { + 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/mod.rs b/crates/yt/src/select/mod.rs new file mode 100644 index 0000000..2478b76 --- /dev/null +++ b/crates/yt/src/select/mod.rs @@ -0,0 +1,321 @@ +// 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, + env::{self}, + fs::{self, File, OpenOptions}, + io::{BufRead, BufReader, BufWriter, Read, Seek, Write}, + iter, + path::Path, + string::String, +}; + +use crate::{ + app::App, + cli::{CliArgs, SelectSplitSortKey, SelectSplitSortMode}, + 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, + sort_key: SelectSplitSortKey, + sort_mode: SelectSplitSortMode, +) -> 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(); + + 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(true) + .write(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.rewind()?; + + let processed = process_file(app, &persistent_file).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).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) -> Result<i64> { + let reader = BufReader::new(file); + + let mut line_number = 0; + + 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) +} + +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) + } +} + +// // 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> +// async fn get_help() -> Result<String> { +// let binary_name = current_exe()?; +// let cmd = Command::new(binary_name) +// .args(&["select", "--help"]) +// .output() +// .await?; +// +// assert_eq!(cmd.status.code(), Some(0)); +// +// let output = String::from_utf8(cmd.stdout).expect("Our help output was not utf8?"); +// +// let out = output +// .lines() +// .map(|line| format!("# {}\n", line)) +// .collect::<String>(); +// +// debug!("Returning help: '{}'", &out); +// +// Ok(out) +// } diff --git a/crates/yt/src/select/selection_file/duration.rs b/crates/yt/src/select/selection_file/duration.rs new file mode 100644 index 0000000..668a0b8 --- /dev/null +++ b/crates/yt/src/select/selection_file/duration.rs @@ -0,0 +1,246 @@ +// 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 std::time::Duration; + +use anyhow::{Result, bail}; + +const SECOND: u64 = 1; +const MINUTE: u64 = 60 * SECOND; +const HOUR: u64 = 60 * MINUTE; +const DAY: u64 = 24 * HOUR; + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct MaybeDuration { + time: Option<Duration>, +} + +impl MaybeDuration { + #[must_use] + pub fn from_std(d: Duration) -> Self { + Self { time: Some(d) } + } + + #[must_use] + pub 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 { + Self { + time: d.map(Duration::from_secs_f64), + } + } + #[must_use] + pub 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> { + 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> { + self.time.map(|v| v.as_secs_f64()) + } +} + +impl FromStr for MaybeDuration { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + #[derive(Debug, Clone, Copy)] + enum Token { + Number(u64), + UnitConstant((char, u64)), + } + + struct Tokenizer<'a> { + input: &'a str, + } + + impl Tokenizer<'_> { + fn next(&mut self) -> Result<Option<Token>> { + loop { + if let Some(next) = self.peek() { + match next { + '0'..='9' => { + let mut number = self.expect_num(); + while matches!(self.peek(), Some('0'..='9')) { + number *= 10; + number += self.expect_num(); + } + break Ok(Some(Token::Number(number))); + } + 's' => { + self.chomp(); + break Ok(Some(Token::UnitConstant(('s', SECOND)))); + } + 'm' => { + self.chomp(); + break Ok(Some(Token::UnitConstant(('m', MINUTE)))); + } + 'h' => { + self.chomp(); + break Ok(Some(Token::UnitConstant(('h', HOUR)))); + } + 'd' => { + self.chomp(); + break Ok(Some(Token::UnitConstant(('d', DAY)))); + } + ' ' => { + // Simply ignore white space + self.chomp(); + } + other => bail!("Unknown unit: {other:#?}"), + } + } else { + break Ok(None); + } + } + } + + fn chomp(&mut self) { + self.input = &self.input[1..]; + } + + fn peek(&self) -> Option<char> { + self.input.chars().next() + } + + fn expect_num(&mut self) -> u64 { + let next = self.peek().expect("Should be some at this point"); + self.chomp(); + assert!(next.is_ascii_digit()); + (next as u64) - ('0' as u64) + } + } + + if s == "[No duration]" { + return Ok(Self { time: None }); + } + + let mut tokenizer = Tokenizer { input: s }; + + let mut value = 0; + let mut current_val = None; + while let Some(token) = tokenizer.next()? { + match token { + Token::Number(number) => { + if let Some(current_val) = current_val { + bail!("Failed to find unit for number: {current_val}"); + } + + { + current_val = Some(number); + } + } + Token::UnitConstant((name, unit)) => { + if let Some(cval) = current_val { + value += cval * unit; + current_val = None; + } else { + bail!("Found unit without number: {name:#?}"); + } + } + } + } + + if let Some(current_val) = current_val { + bail!("Duration endet without unit, number was: {current_val}"); + } + + Ok(Self { + time: Some(Duration::from_secs(value)), + }) + } +} + +impl std::fmt::Display for MaybeDuration { + fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { + if let Some(self_seconds) = self.as_secs() { + let base_day = self_seconds - (self_seconds % DAY); + let base_hour = (self_seconds % DAY) - ((self_seconds % DAY) % HOUR); + let base_min = (self_seconds % HOUR) - (((self_seconds % DAY) % HOUR) % MINUTE); + let base_sec = ((self_seconds % DAY) % HOUR) % MINUTE; + + let d = base_day / DAY; + let h = base_hour / HOUR; + let m = base_min / MINUTE; + let s = base_sec / SECOND; + + if d > 0 { + write!(fmt, "{d}d {h}h {m}m") + } else if h > 0 { + write!(fmt, "{h}h {m}m") + } else { + write!(fmt, "{m}m {s}s") + } + } else { + write!(fmt, "[No duration]") + } + } +} +#[cfg(test)] +mod test { + use std::str::FromStr; + + use crate::select::selection_file::duration::{DAY, HOUR, MINUTE}; + + use super::MaybeDuration; + + fn mk_roundtrip(input: MaybeDuration, expected: &str) { + let output = MaybeDuration::from_str(expected).unwrap(); + + assert_eq!(input.to_string(), output.to_string()); + assert_eq!(input.to_string(), expected); + assert_eq!( + MaybeDuration::from_str(input.to_string().as_str()).unwrap(), + output + ); + } + + #[test] + fn test_roundtrip_duration_1h() { + mk_roundtrip(MaybeDuration::from_secs(HOUR), "1h 0m"); + } + #[test] + fn test_roundtrip_duration_30min() { + mk_roundtrip(MaybeDuration::from_secs(MINUTE * 30), "30m 0s"); + } + #[test] + fn test_roundtrip_duration_1d() { + mk_roundtrip( + MaybeDuration::from_secs(DAY + MINUTE * 30 + HOUR * 2), + "1d 2h 30m", + ); + } + #[test] + fn test_roundtrip_duration_none() { + mk_roundtrip(MaybeDuration::from_maybe_secs_f64(None), "[No duration]"); + } +} diff --git a/yt/src/select/selection_file/help.str b/crates/yt/src/select/selection_file/help.str index eb76ce5..e3cc347 100644 --- a/yt/src/select/selection_file/help.str +++ b/crates/yt/src/select/selection_file/help.str @@ -9,4 +9,4 @@ # See `yt select <cmd_name> --help` for more help. # # These lines can be re-ordered; they are executed from top to bottom. -# vim: filetype=yts conceallevel=2 concealcursor=nc colorcolumn= +# vim: filetype=yts conceallevel=2 concealcursor=nc colorcolumn= nowrap diff --git a/yt/src/select/selection_file/help.str.license b/crates/yt/src/select/selection_file/help.str.license index d4d410f..a0e196c 100644 --- a/yt/src/select/selection_file/help.str.license +++ b/crates/yt/src/select/selection_file/help.str.license @@ -1,6 +1,7 @@ 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/yt/src/select/selection_file/mod.rs b/crates/yt/src/select/selection_file/mod.rs index 5e5643d..f5e0531 100644 --- a/yt/src/select/selection_file/mod.rs +++ b/crates/yt/src/select/selection_file/mod.rs @@ -1,6 +1,7 @@ // 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. @@ -10,22 +11,32 @@ //! The data structures needed to express the file, which the user edits -use anyhow::{Context, Result}; -use trinitry::Trinitry; +use anyhow::{Result, bail}; +use shlex::Shlex; pub mod duration; +/// # Panics +/// If internal assertions fail. 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 split: Vec<_> = { + let mut shl = Shlex::new(line); + let res = shl.by_ref().collect(); - let mut vec = Vec::with_capacity(tri.arguments().len() + 1); - vec.push(tri.command().to_owned()); - vec.extend(tri.arguments().to_vec()); + if shl.had_error { + bail!("Failed to parse line '{line}'") + } - Ok(Some(vec)) + assert_eq!(shl.line_no, 1, "A unexpected newline appeared"); + res + }; + + assert!(!split.is_empty()); + + Ok(Some(split)) } } diff --git a/crates/yt/src/status/mod.rs b/crates/yt/src/status/mod.rs new file mode 100644 index 0000000..6883802 --- /dev/null +++ b/crates/yt/src/status/mod.rs @@ -0,0 +1,130 @@ +// 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/migrate/mod.rs b/crates/yt/src/storage/migrate/mod.rs new file mode 100644 index 0000000..953d079 --- /dev/null +++ b/crates/yt/src/storage/migrate/mod.rs @@ -0,0 +1,279 @@ +// 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, + future::Future, + time::{SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, bail}; +use chrono::TimeDelta; +use log::{debug, info}; +use sqlx::{Sqlite, SqlitePool, Transaction, query}; + +use crate::app::App; + +macro_rules! make_upgrade { + ($app:expr, $old_version:expr, $new_version:expr, $sql_name:expr) => { + add_error_context( + async { + let mut tx = $app + .database + .begin() + .await + .context("Failed to start the update transaction")?; + debug!("Migrating: {} -> {}", $old_version, $new_version); + + sqlx::raw_sql(include_str!($sql_name)) + .execute(&mut *tx) + .await + .context("Failed to run the update sql script")?; + + set_db_version( + &mut tx, + if $old_version == Self::Empty { + // There is no previous version we would need to remove + None + } else { + Some($old_version) + }, + $new_version, + ) + .await + .with_context(|| format!("Failed to set the new version ({})", $new_version))?; + + tx.commit() + .await + .context("Failed to commit the update transaction")?; + + // NOTE: This is needed, so that sqlite "sees" our changes to the table + // without having to reconnect. <2025-02-18> + query!("VACUUM") + .execute(&$app.database) + .await + .context("Failed to vacuum database")?; + + Ok(()) + }, + $new_version, + ) + .await?; + + Box::pin($new_version.update($app)).await.context(concat!( + "While updating to version: ", + stringify!($new_version) + )) + }; +} + +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub enum DbVersion { + /// The database is not yet initialized. + Empty, + + /// The first database version. + /// Introduced: 2025-02-16. + Zero, + + /// Introduced: 2025-02-17. + One, + + /// Introduced: 2025-02-18. + Two, + + /// Introduced: 2025-03-21. + Three, +} +const CURRENT_VERSION: DbVersion = DbVersion::Three; + +async fn add_error_context( + function: impl Future<Output = Result<()>>, + level: DbVersion, +) -> Result<()> { + function + .await + .with_context(|| format!("Failed to migrate database to version: {level}")) +} + +async fn set_db_version( + tx: &mut Transaction<'_, Sqlite>, + old_version: Option<DbVersion>, + new_version: DbVersion, +) -> Result<()> { + let valid_from = get_current_date(); + + if let Some(old_version) = old_version { + let valid_to = valid_from + 1; + let old_version = old_version.as_sql_integer(); + + query!( + "UPDATE version SET valid_to = ? WHERE namespace = 'yt' AND number = ?;", + valid_to, + old_version + ) + .execute(&mut *(*tx)) + .await?; + } + + let version = new_version.as_sql_integer(); + + query!( + "INSERT INTO version (namespace, number, valid_from, valid_to) VALUES ('yt', ?, ?, NULL);", + version, + valid_from + ) + .execute(&mut *(*tx)) + .await?; + + Ok(()) +} + +impl DbVersion { + fn as_sql_integer(self) -> i32 { + match self { + DbVersion::Zero => 0, + DbVersion::One => 1, + DbVersion::Two => 2, + DbVersion::Three => 3, + + DbVersion::Empty => unreachable!("A empty version does not have an associated integer"), + } + } + + fn from_db(number: i64, namespace: &str) -> Result<Self> { + match (number, namespace) { + (0, "yt") => Ok(DbVersion::Zero), + (1, "yt") => Ok(DbVersion::One), + (2, "yt") => Ok(DbVersion::Two), + (3, "yt") => Ok(DbVersion::Three), + + (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}'"), + + (other, "yt") => bail!("Got unkown version for 'yt' namespace: {other}"), + (num, nasp) => bail!("Got unkown version number ({num}) and namespace ('{nasp}')"), + } + } + + /// Try to update the database from version [`self`] to the [`CURRENT_VERSION`]. + /// + /// Each update is atomic, so if this function fails you are still guaranteed to have a + /// database at version `get_version`. + #[allow(clippy::too_many_lines)] + async fn update(self, app: &App) -> Result<()> { + match self { + Self::Empty => { + make_upgrade! {app, Self::Empty, Self::Zero, "./sql/0_Empty_to_Zero.sql"} + } + + Self::Zero => { + make_upgrade! {app, Self::Zero, Self::One, "./sql/1_Zero_to_One.sql"} + } + + Self::One => { + make_upgrade! {app, Self::One, Self::Two, "./sql/2_One_to_Two.sql"} + } + + Self::Two => { + make_upgrade! {app, Self::Two, Self::Three, "./sql/3_Two_to_Three.sql"} + } + + // This is the current_version + Self::Three => { + assert_eq!(self, CURRENT_VERSION); + assert_eq!(self, get_version(app).await?); + Ok(()) + } + } + } +} +impl Display for DbVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // It is a unit only enum, thus we can simply use the Debug formatting + <Self as std::fmt::Debug>::fmt(self, f) + } +} + +/// Returns the current data as UNIX time stamp. +fn get_current_date() -> i64 { + let start = SystemTime::now(); + let seconds_since_epoch: TimeDelta = TimeDelta::from_std( + start + .duration_since(UNIX_EPOCH) + .expect("Time went backwards"), + ) + .expect("Time does not go backwards"); + + // All database dates should be after the UNIX_EPOCH (and thus positiv) + seconds_since_epoch.num_milliseconds() +} + +/// Return the current database version. +/// +/// # Panics +/// Only if internal assertions fail. +pub 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 +/// a fully instantiated [`App`], a database connection suffices. +/// +/// # Panics +/// Only if internal assertions fail. +pub 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'" + ) + .fetch_optional(pool) + .await?; + if let Some(output) = query { + assert_eq!(output.result, 1); + true + } else { + false + } + }; + if !version_table_exists { + return Ok(DbVersion::Empty); + } + + let current_version = query!( + " + SELECT namespace, number FROM version WHERE valid_to IS NULL; + " + ) + .fetch_one(pool) + .await + .context("Failed to fetch version number")?; + + DbVersion::from_db(current_version.number, current_version.namespace.as_str()) +} + +pub async fn migrate_db(app: &App) -> Result<()> { + let current_version = get_version(app) + .await + .context("Failed to determine initial version")?; + + if current_version == CURRENT_VERSION { + return Ok(()); + } + + info!("Migrate database from version '{current_version}' to version '{CURRENT_VERSION}'"); + + current_version.update(app).await?; + + Ok(()) +} diff --git a/yt/src/storage/video_database/schema.sql b/crates/yt/src/storage/migrate/sql/0_Empty_to_Zero.sql index 3afd091..d703bfc 100644 --- a/yt/src/storage/video_database/schema.sql +++ b/crates/yt/src/storage/migrate/sql/0_Empty_to_Zero.sql @@ -1,6 +1,7 @@ -- 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. @@ -11,8 +12,22 @@ -- All tables should be declared STRICT, as I actually like to have types checking (and a -- db that doesn't lie to me). +-- Keep this table in sync with the `DbVersion` enumeration. +CREATE TABLE version ( + -- The `namespace` is only useful, if other tools ever build on this database + namespace TEXT NOT NULL, + + -- The version. + number INTEGER UNIQUE NOT NULL PRIMARY KEY, + + -- The validity of this version as UNIX time stamp + valid_from INTEGER NOT NULL CHECK (valid_from < valid_to), + -- If set to `NULL`, represents the current version + valid_to INTEGER UNIQUE CHECK (valid_to > valid_from) +) STRICT; + -- Keep this table in sync with the `Video` structure -CREATE TABLE IF NOT EXISTS videos ( +CREATE TABLE videos ( cache_path TEXT UNIQUE CHECK (CASE WHEN cache_path IS NOT NULL THEN status == 2 ELSE @@ -43,7 +58,7 @@ CREATE TABLE IF NOT EXISTS videos ( ) STRICT; -- Store additional metadata for the videos marked to be watched -CREATE TABLE IF NOT EXISTS video_options ( +CREATE TABLE video_options ( extractor_hash TEXT UNIQUE NOT NULL PRIMARY KEY, subtitle_langs TEXT NOT NULL, playback_speed REAL NOT NULL, @@ -51,7 +66,7 @@ CREATE TABLE IF NOT EXISTS video_options ( ) STRICT; -- Store subscriptions -CREATE TABLE IF NOT EXISTS subscriptions ( +CREATE TABLE subscriptions ( name TEXT UNIQUE NOT NULL PRIMARY KEY, url TEXT NOT NULL ) STRICT; diff --git a/crates/yt/src/storage/migrate/sql/1_Zero_to_One.sql b/crates/yt/src/storage/migrate/sql/1_Zero_to_One.sql new file mode 100644 index 0000000..da9315b --- /dev/null +++ b/crates/yt/src/storage/migrate/sql/1_Zero_to_One.sql @@ -0,0 +1,28 @@ +-- 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>. + +-- Is the video currently in a playlist? +ALTER TABLE videos ADD in_playlist INTEGER NOT NULL DEFAULT 0 CHECK (in_playlist IN (0, 1)); +UPDATE videos SET in_playlist = 0; + +-- Is it 'focused' (i.e., the select video)? +-- Only of video should be focused at a time. +ALTER TABLE videos +ADD COLUMN is_focused INTEGER NOT NULL DEFAULT 0 +CHECK (is_focused IN (0, 1)); +UPDATE videos SET is_focused = 0; + +-- The progress the user made in watching the video. +ALTER TABLE videos ADD watch_progress INTEGER NOT NULL DEFAULT 0 CHECK (watch_progress <= duration); +-- Assume, that the user has watched the video to end, if it is marked as watched +UPDATE videos SET watch_progress = ifnull(duration, 0) WHERE status = 3; +UPDATE videos SET watch_progress = 0 WHERE status != 3; + +ALTER TABLE videos DROP COLUMN status_change; diff --git a/crates/yt/src/storage/migrate/sql/2_One_to_Two.sql b/crates/yt/src/storage/migrate/sql/2_One_to_Two.sql new file mode 100644 index 0000000..806de07 --- /dev/null +++ b/crates/yt/src/storage/migrate/sql/2_One_to_Two.sql @@ -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>. + +ALTER TABLE videos DROP in_playlist; diff --git a/crates/yt/src/storage/migrate/sql/3_Two_to_Three.sql b/crates/yt/src/storage/migrate/sql/3_Two_to_Three.sql new file mode 100644 index 0000000..b33f849 --- /dev/null +++ b/crates/yt/src/storage/migrate/sql/3_Two_to_Three.sql @@ -0,0 +1,85 @@ +-- 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>. + + +-- 1. Create new table +-- 2. Copy data +-- 3. Drop old table +-- 4. Rename new into old + +-- remove the original TRANSACTION +COMMIT TRANSACTION; + +-- tweak config +PRAGMA foreign_keys=OFF; + +-- start your own TRANSACTION +BEGIN TRANSACTION; + +CREATE TABLE videos_new ( + cache_path TEXT UNIQUE CHECK (CASE + WHEN cache_path IS NOT NULL THEN status == 2 + ELSE 1 + END), + description TEXT, + duration REAL, + extractor_hash TEXT UNIQUE NOT NULL PRIMARY KEY, + last_status_change INTEGER NOT NULL, + parent_subscription_name TEXT, + priority INTEGER NOT NULL DEFAULT 0, + publish_date INTEGER, + status INTEGER NOT NULL DEFAULT 0 CHECK (status IN (0, 1, 2, 3, 4, 5) AND + CASE + WHEN status == 2 THEN cache_path IS NOT NULL + WHEN status != 2 THEN cache_path IS NULL + ELSE 1 + END), + thumbnail_url TEXT, + title TEXT NOT NULL, + url TEXT UNIQUE NOT NULL, + is_focused INTEGER UNIQUE DEFAULT NULL CHECK (CASE + WHEN is_focused IS NOT NULL THEN is_focused == 1 + ELSE 1 + END), + watch_progress INTEGER NOT NULL DEFAULT 0 CHECK (watch_progress <= duration) +) STRICT; + +INSERT INTO videos_new SELECT + videos.cache_path, + videos.description, + videos.duration, + videos.extractor_hash, + videos.last_status_change, + videos.parent_subscription_name, + videos.priority, + videos.publish_date, + videos.status, + videos.thumbnail_url, + videos.title, + videos.url, + dummy.is_focused, + videos.watch_progress +FROM videos, (SELECT NULL AS is_focused) AS dummy; + +DROP TABLE videos; + +ALTER TABLE videos_new RENAME TO videos; + +-- check foreign key constraint still upholding. +PRAGMA foreign_key_check; + +-- commit your own TRANSACTION +COMMIT TRANSACTION; + +-- rollback all config you setup before. +PRAGMA foreign_keys=ON; + +-- start a new TRANSACTION to let migrator commit it. +BEGIN TRANSACTION; diff --git a/yt/src/storage/mod.rs b/crates/yt/src/storage/mod.rs index 6a12d8b..d352b41 100644 --- a/yt/src/storage/mod.rs +++ b/crates/yt/src/storage/mod.rs @@ -1,6 +1,7 @@ // 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,5 +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; diff --git a/yt/src/storage/subscriptions.rs b/crates/yt/src/storage/subscriptions.rs index 8e089f0..0e8ae85 100644 --- a/yt/src/storage/subscriptions.rs +++ b/crates/yt/src/storage/subscriptions.rs @@ -1,6 +1,7 @@ // 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. @@ -14,10 +15,9 @@ use std::collections::HashMap; use anyhow::Result; use log::debug; -use serde_json::{json, Value}; use sqlx::query; use url::Url; -use yt_dlp::wrapper::info_json::InfoType; +use yt_dlp::{json_cast, options::YoutubeDLOptions}; use crate::{app::App, unreachable::Unreachable}; @@ -38,21 +38,19 @@ impl Subscription { } /// Check whether an URL could be used as a subscription URL -pub async fn check_url(url: &Url) -> Result<bool> { - let Value::Object(yt_opts) = json!( { - "playliststart": 1, - "playlistend": 10, - "noplaylist": false, - "extract_flat": "in_playlist", - }) else { - unreachable!("This is hardcoded"); - }; +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(&yt_opts, url, false, false).await?; + let info = yt_dlp.extract_info(&url, false, false)?; - debug!("{:#?}", info); + debug!("{info:#?}"); - Ok(info._type == Some(InfoType::Playlist)) + Ok(info.get("_type").map(|v| json_cast!(v, as_str)) == Some("playlist")) } #[derive(Default, Debug)] diff --git a/yt/src/storage/video_database/downloader.rs b/crates/yt/src/storage/video_database/downloader.rs index bfb7aa3..a95081e 100644 --- a/yt/src/storage/video_database/downloader.rs +++ b/crates/yt/src/storage/video_database/downloader.rs @@ -1,6 +1,7 @@ // 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. @@ -13,9 +14,13 @@ use std::path::{Path, PathBuf}; use anyhow::Result; use log::debug; use sqlx::query; -use url::Url; -use crate::{app::App, storage::video_database::VideoStatus, unreachable::Unreachable}; +use crate::{ + app::App, + storage::video_database::{VideoStatus, VideoStatusMarker}, + unreachable::Unreachable, + video_from_record, +}; use super::{ExtractorHash, Video}; @@ -25,9 +30,9 @@ use super::{ExtractorHash, Video}; /// # Panics /// Only if assertions fail. pub async fn get_next_uncached_video(app: &App) -> Result<Option<Video>> { - let status = VideoStatus::Watch.as_db_integer(); + let status = VideoStatus::Watch.as_marker().as_db_integer(); - // NOTE: The ORDER BY statement should be the same as the one in [`getters::get_videos`].<2024-08-22> + // NOTE: The ORDER BY statement should be the same as the one in [`get::videos`].<2024-08-22> let result = query!( r#" SELECT * @@ -46,39 +51,7 @@ pub async fn get_next_uncached_video(app: &App) -> Result<Option<Video>> { } else { let base = result?; - let thumbnail_url = base - .thumbnail_url - .as_ref() - .map(|url| Url::parse(url).unreachable("Parsing this as url should always work")); - - let status_change = if base.status_change == 1 { - true - } else { - assert_eq!(base.status_change, 0, "Can only be 1 or 0"); - false - }; - - let video = Video { - cache_path: base.cache_path.as_ref().map(PathBuf::from), - description: base.description.clone(), - duration: base.duration, - extractor_hash: ExtractorHash::from_hash( - base.extractor_hash - .parse() - .expect("The hash in the db should be valid"), - ), - last_status_change: base.last_status_change, - parent_subscription_name: base.parent_subscription_name.clone(), - priority: base.priority, - publish_date: base.publish_date, - status: VideoStatus::from_db_integer(base.status), - status_change, - thumbnail_url, - title: base.title.clone(), - url: Url::parse(&base.url).expect("Parsing this as url should always work"), - }; - - Ok(Some(video)) + Ok(Some(video_from_record! {base})) } } @@ -99,7 +72,7 @@ pub async fn set_video_cache_path( let path_str = path.display().to_string(); let extractor_hash = video.hash().to_string(); - let status = VideoStatus::Cached.as_db_integer(); + let status = VideoStatusMarker::Cached.as_db_integer(); query!( r#" @@ -122,7 +95,7 @@ pub async fn set_video_cache_path( ); let extractor_hash = video.hash().to_string(); - let status = VideoStatus::Watch.as_db_integer(); + let status = VideoStatus::Watch.as_marker().as_db_integer(); query!( r#" diff --git a/yt/src/storage/video_database/extractor_hash.rs b/crates/yt/src/storage/video_database/extractor_hash.rs index 3aa3cd2..df545d7 100644 --- a/yt/src/storage/video_database/extractor_hash.rs +++ b/crates/yt/src/storage/video_database/extractor_hash.rs @@ -1,6 +1,7 @@ // 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. @@ -10,12 +11,12 @@ use std::{collections::HashSet, fmt::Display, str::FromStr}; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use blake3::Hash; use log::debug; use tokio::sync::OnceCell; -use crate::{app::App, storage::video_database::getters::get_all_hashes, unreachable::Unreachable}; +use crate::{app::App, storage::video_database::get::get_all_hashes, unreachable::Unreachable}; static EXTRACTOR_HASH_LENGTH: OnceCell<usize> = OnceCell::const_new(); diff --git a/crates/yt/src/storage/video_database/get/mod.rs b/crates/yt/src/storage/video_database/get/mod.rs new file mode 100644 index 0000000..e76131e --- /dev/null +++ b/crates/yt/src/storage/video_database/get/mod.rs @@ -0,0 +1,307 @@ +// 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::info_json::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 new file mode 100644 index 0000000..4c45bf7 --- /dev/null +++ b/crates/yt/src/storage/video_database/get/playlist/iterator.rs @@ -0,0 +1,101 @@ +// 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 new file mode 100644 index 0000000..f6aadbf --- /dev/null +++ b/crates/yt/src/storage/video_database/get/playlist/mod.rs @@ -0,0 +1,167 @@ +// 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/mod.rs b/crates/yt/src/storage/video_database/mod.rs new file mode 100644 index 0000000..74d09f0 --- /dev/null +++ b/crates/yt/src/storage/video_database/mod.rs @@ -0,0 +1,329 @@ +// 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::{ + fmt::{Display, Write}, + path::PathBuf, + time::Duration, +}; + +use chrono::{DateTime, Utc}; +use url::Url; + +use crate::{ + app::App, select::selection_file::duration::MaybeDuration, + storage::video_database::extractor_hash::ExtractorHash, +}; + +pub mod downloader; +pub mod extractor_hash; +pub mod get; +pub mod notify; +pub mod set; + +#[derive(Debug, Clone)] +pub struct Video { + pub description: Option<String>, + pub duration: MaybeDuration, + pub extractor_hash: ExtractorHash, + pub 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, + + /// The seconds the user has already watched the video + pub watch_progress: Duration, +} + +/// The priority of a [`Video`]. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub 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 { + self.value + } +} +impl From<i64> for Priority { + fn from(value: i64) -> Self { + Self { value } + } +} +impl Display for Priority { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.value.fmt(f) + } +} + +/// An UNIX time stamp. +#[derive(Debug, Default, Clone, Copy)] +pub struct TimeStamp { + value: i64, +} +impl TimeStamp { + /// Return the seconds since the UNIX epoch for this [`TimeStamp`]. + #[must_use] + pub 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 { + Self { value } + } + + /// Construct a [`TimeStamp`] from the current time. + #[must_use] + pub fn from_now() -> Self { + Self { + value: Utc::now().timestamp(), + } + } +} +impl Display for TimeStamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + DateTime::from_timestamp(self.value, 0) + .expect("The timestamps should always be valid") + .format("%Y-%m-%d") + .fmt(f) + } +} + +#[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> +/// / \ +/// <Watch> <Drop> -> Dropped // yt select +/// | +/// Cache // yt cache +/// | +/// Watched // yt watch +#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone)] +pub enum VideoStatus { + #[default] + Pick, + + /// The video has been select to be watched + Watch, + /// The video has been cached and is ready to be watched + Cached { + cache_path: PathBuf, + is_focused: bool, + }, + /// The video has been watched + Watched, + + /// The video has been select to be dropped + Drop, + /// The video has been dropped + Dropped, +} + +impl VideoStatus { + /// Reconstruct a [`VideoStatus`] for it's marker and the optional parts. + /// This should only be used by the db record to [`Video`] code. + /// + /// # Panics + /// Only if internal expectations fail. + #[must_use] + pub fn from_marker(marker: VideoStatusMarker, optional: Option<(PathBuf, bool)>) -> Self { + match marker { + VideoStatusMarker::Pick => Self::Pick, + VideoStatusMarker::Watch => Self::Watch, + VideoStatusMarker::Cached => { + let (cache_path, is_focused) = + optional.expect("This should be some, when the video status is cached"); + Self::Cached { + cache_path, + is_focused, + } + } + VideoStatusMarker::Watched => Self::Watched, + VideoStatusMarker::Drop => Self::Drop, + VideoStatusMarker::Dropped => Self::Dropped, + } + } + + /// 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 { + match self { + VideoStatus::Pick => VideoStatusMarker::Pick, + VideoStatus::Watch => VideoStatusMarker::Watch, + VideoStatus::Cached { .. } => VideoStatusMarker::Cached, + VideoStatus::Watched => VideoStatusMarker::Watched, + VideoStatus::Drop => VideoStatusMarker::Drop, + VideoStatus::Dropped => VideoStatusMarker::Dropped, + } + } +} + +/// Unit only variant of [`VideoStatus`] +#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] +pub enum VideoStatusMarker { + #[default] + Pick, + + /// The video has been select to be watched + Watch, + /// The video has been cached and is ready to be watched + Cached, + /// The video has been watched + Watched, + + /// The video has been select to be dropped + Drop, + /// The video has been dropped + Dropped, +} + +impl VideoStatusMarker { + pub const ALL: &'static [Self; 6] = &[ + Self::Pick, + // + Self::Watch, + Self::Cached, + Self::Watched, + // + Self::Drop, + Self::Dropped, + ]; + + #[must_use] + pub 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 { + Self::Pick => "pick ", + + Self::Watch | Self::Cached => "watch ", + Self::Watched => "watched", + + Self::Drop | Self::Dropped => "drop ", + } + } + + #[must_use] + pub 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 { + Self::Pick => 0, + + Self::Watch => 1, + Self::Cached => 2, + Self::Watched => 3, + + Self::Drop => 4, + Self::Dropped => 5, + } + } + #[must_use] + pub fn from_db_integer(num: i64) -> Self { + match num { + 0 => Self::Pick, + + 1 => Self::Watch, + 2 => Self::Cached, + 3 => Self::Watched, + + 4 => Self::Drop, + 5 => Self::Dropped, + other => unreachable!( + "The database returned a enum discriminator, unknown to us: '{}'", + other + ), + } + } + + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::Pick => "Pick", + + Self::Watch => "Watch", + Self::Cached => "Cache", + Self::Watched => "Watched", + + Self::Drop => "Drop", + Self::Dropped => "Dropped", + } + } +} diff --git a/crates/yt/src/storage/video_database/notify.rs b/crates/yt/src/storage/video_database/notify.rs new file mode 100644 index 0000000..b55c00a --- /dev/null +++ b/crates/yt/src/storage/video_database/notify.rs @@ -0,0 +1,77 @@ +// 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}, + sync::mpsc, + thread::sleep, + time::Duration, +}; + +use crate::app::App; + +use anyhow::{Context, Result}; +use notify::{ + Event, EventKind, RecursiveMode, Watcher, + event::{DataChange, ModifyKind}, +}; +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<()> { + let db_path: PathBuf = app.config.paths.database_path.clone(); + task::spawn_blocking(move || wait_for_db_write_sync(&db_path)).await? +} + +fn wait_for_db_write_sync(db_path: &Path) -> Result<()> { + let (tx, rx) = mpsc::channel::<notify::Result<Event>>(); + + let mut watcher = notify::recommended_watcher(tx)?; + + watcher.watch(db_path, RecursiveMode::NonRecursive)?; + + for res in rx { + let event = res.context("Failed to wait for db write")?; + + if let EventKind::Modify(ModifyKind::Data(DataChange::Any)) = event.kind { + // Buffer some of the `Modify` event burst. + sleep(Duration::from_millis(10)); + + return Ok(()); + } + } + + Ok(()) +} + +/// 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<()> { + let download_directory: PathBuf = app.config.paths.download_dir.clone(); + task::spawn_blocking(move || wait_for_cache_reduction_sync(&download_directory)).await? +} + +fn wait_for_cache_reduction_sync(download_directory: &Path) -> Result<()> { + let (tx, rx) = mpsc::channel::<notify::Result<Event>>(); + + let mut watcher = notify::recommended_watcher(tx)?; + + watcher.watch(download_directory, RecursiveMode::Recursive)?; + + for res in rx { + let event = res.context("Failed to wait for cache size reduction")?; + + if let EventKind::Remove(_) = event.kind { + return Ok(()); + } + } + + Ok(()) +} diff --git a/crates/yt/src/storage/video_database/set/mod.rs b/crates/yt/src/storage/video_database/set/mod.rs new file mode 100644 index 0000000..1b19011 --- /dev/null +++ b/crates/yt/src/storage/video_database/set/mod.rs @@ -0,0 +1,327 @@ +// 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 new file mode 100644 index 0000000..547df21 --- /dev/null +++ b/crates/yt/src/storage/video_database/set/playlist.rs @@ -0,0 +1,101 @@ +// 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/yt/src/subscribe/mod.rs b/crates/yt/src/subscribe/mod.rs index ef46627..66797e8 100644 --- a/yt/src/subscribe/mod.rs +++ b/crates/yt/src/subscribe/mod.rs @@ -1,6 +1,7 @@ // 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. @@ -10,18 +11,17 @@ use std::str::FromStr; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result, bail}; use futures::FutureExt; -use log::warn; -use serde_json::{json, Value}; +use log::{error, warn}; use tokio::io::{AsyncBufRead, AsyncBufReadExt}; use url::Url; -use yt_dlp::wrapper::info_json::InfoType; +use yt_dlp::{json_cast, json_get, options::YoutubeDLOptions}; use crate::{ app::App, storage::subscriptions::{ - add_subscription, check_url, get, remove_all, remove_subscription, Subscription, + Subscription, add_subscription, check_url, get, remove_all, remove_subscription, }, unreachable::Unreachable, }; @@ -76,7 +76,9 @@ pub async fn subscribe(app: &App, name: Option<String>, url: Url) -> Result<()> || 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..."); + 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"); @@ -119,17 +121,26 @@ pub async fn subscribe(app: &App, name: Option<String>, url: Url) -> Result<()> out?; } else { - actual_subscribe(app, None, url.join("videos/").unreachable("See above.")) + let _ = actual_subscribe(app, None, url.join("videos/").unreachable("See above.")) .await - .with_context(|| format!("Failed to subscribe to the '{}' variant", "{Videos}"))?; + .map_err(|err| { + error!("Failed to subscribe to the '{}' variant: {err}", "{Videos}"); + }); - actual_subscribe(app, None, url.join("streams/").unreachable("See above.")) + let _ = 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.")) + .map_err(|err| { + error!( + "Failed to subscribe to the '{}' variant: {err}", + "{Streams}" + ); + }); + + let _ = actual_subscribe(app, None, url.join("shorts/").unreachable("See above.")) .await - .with_context(|| format!("Failed to subscribe to the '{}' variant", "{Shorts}"))?; + .map_err(|err| { + error!("Failed to subscribe to the '{}' variant: {err}", "{Shorts}"); + }); } } else { actual_subscribe(app, name, url).await?; @@ -139,26 +150,24 @@ pub async fn subscribe(app: &App, name: Option<String>, url: Url) -> Result<()> } async fn actual_subscribe(app: &App, name: Option<String>, url: Url) -> Result<()> { - if !check_url(&url).await? { + 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 Value::Object(yt_opts) = json!( { - "playliststart": 1, - "playlistend": 10, - "noplaylist": false, - "extract_flat": "in_playlist", - }) else { - unreachable!("This is hardcoded") - }; - - let info = yt_dlp::extract_info(&yt_opts, &url, false, false).await?; - - if info._type == Some(InfoType::Playlist) { - info.title.expect("This should be some for a playlist") + 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").map(|v| json_cast!(v, as_str)) == Some("playlist") { + json_get!(info, "title", as_str).to_owned() } else { bail!("The url ('{}') does not represent a playlist!", &url) } diff --git a/yt/src/unreachable.rs b/crates/yt/src/unreachable.rs index 3ce895c..436fbb6 100644 --- a/yt/src/unreachable.rs +++ b/crates/yt/src/unreachable.rs @@ -1,6 +1,7 @@ // 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/src/update/mod.rs b/crates/yt/src/update/mod.rs new file mode 100644 index 0000000..7f9bee7 --- /dev/null +++ b/crates/yt/src/update/mod.rs @@ -0,0 +1,204 @@ +// 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::{info_json::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 new file mode 100644 index 0000000..75d12dc --- /dev/null +++ b/crates/yt/src/update/updater.rs @@ -0,0 +1,194 @@ +// 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::{ + info_json::InfoJson, json_cast, json_get, options::YoutubeDLOptions, process_ie_result, + python_error::PythonError, +}; + +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(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? + } +} diff --git a/crates/yt/src/version/mod.rs b/crates/yt/src/version/mod.rs new file mode 100644 index 0000000..2cc41c7 --- /dev/null +++ b/crates/yt/src/version/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 anyhow::{Context, Result}; +use sqlx::{SqlitePool, sqlite::SqliteConnectOptions}; +use yt_dlp::options::YoutubeDLOptions; + +use crate::{config::Config, storage::migrate::get_version_db}; + +pub async fn show(config: &Config) -> Result<()> { + let db_version = { + let options = SqliteConnectOptions::new() + .filename(&config.paths.database_path) + .optimize_on_close(true, None) + .create_if_missing(true); + + let pool = SqlitePool::connect_with(options) + .await + .context("Failed to connect to database!")?; + + get_version_db(&pool) + .await + .context("Failed to determine database version")? + }; + + let (yt_dlp, python) = { + let yt_dlp = YoutubeDLOptions::new().build()?; + yt_dlp.version() + }; + + let python = python.replace('\n', " "); + + println!( + "{}: {} + +db version: {db_version} + +yt-dlp: {yt_dlp} +python: {python}", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + ); + + Ok(()) +} diff --git a/crates/yt/src/videos/display/format_video.rs b/crates/yt/src/videos/display/format_video.rs new file mode 100644 index 0000000..b97acb1 --- /dev/null +++ b/crates/yt/src/videos/display/format_video.rs @@ -0,0 +1,94 @@ +// 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 new file mode 100644 index 0000000..1188569 --- /dev/null +++ b/crates/yt/src/videos/display/mod.rs @@ -0,0 +1,229 @@ +// 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/mod.rs b/crates/yt/src/videos/mod.rs new file mode 100644 index 0000000..960340b --- /dev/null +++ b/crates/yt/src/videos/mod.rs @@ -0,0 +1,54 @@ +// 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 futures::{TryStreamExt, stream::FuturesUnordered}; + +pub mod display; + +use crate::{ + app::App, + storage::video_database::{Video, VideoStatusMarker, get}, +}; + +async fn to_line_display_owned(video: Video, app: &App) -> Result<String> { + video.to_line_display(app).await +} + +pub async fn query(app: &App, limit: Option<usize>, search_query: Option<String>) -> Result<()> { + let all_videos = get::videos(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).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)) + .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")); + } + + Ok(()) +} diff --git a/crates/yt/src/watch/mod.rs b/crates/yt/src/watch/mod.rs new file mode 100644 index 0000000..c32a76f --- /dev/null +++ b/crates/yt/src/watch/mod.rs @@ -0,0 +1,178 @@ +// 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 new file mode 100644 index 0000000..ff383d0 --- /dev/null +++ b/crates/yt/src/watch/playlist.rs @@ -0,0 +1,99 @@ +// 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/client_messages/mod.rs b/crates/yt/src/watch/playlist_handler/client_messages/mod.rs new file mode 100644 index 0000000..c05ca87 --- /dev/null +++ b/crates/yt/src/watch/playlist_handler/client_messages/mod.rs @@ -0,0 +1,99 @@ +// 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, time::Duration}; + +use crate::{app::App, comments}; + +use anyhow::{Context, Result, bail}; +use libmpv2::Mpv; +use tokio::process::Command; + +use super::mpv_message; + +async fn run_self_in_external_command(app: &App, args: &[&str]) -> Result<()> { + // TODO(@bpeetz): Can we trust this value? <2025-06-15> + 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")?, + "--db-path", + app.config + .paths + .database_path + .to_str() + .context("Failed to parse the database_path as a utf8-string")?, + ], + args, + ] + .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"]) + .status() + .await?; + + if !status.success() { + bail!("focusing the next output failed!"); + } + + Ok(()) +} + +pub(super) async fn handle_yt_description_external(app: &App) -> Result<()> { + run_self_in_external_command(app, &["description"]).await?; + Ok(()) +} +pub(super) async fn handle_yt_description_local(app: &App, mpv: &Mpv) -> Result<()> { + let description: String = comments::description::get(app) + .await? + .chars() + .take(app.config.watch.local_displays_length) + .collect(); + + mpv_message(mpv, &description, Duration::from_secs(6))?; + Ok(()) +} + +pub(super) async fn handle_yt_comments_external(app: &App) -> Result<()> { + run_self_in_external_command(app, &["comments"]).await?; + Ok(()) +} +pub(super) async fn handle_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()> { + let comments: String = comments::get(app) + .await? + .render(false) + .chars() + .take(app.config.watch.local_displays_length) + .collect(); + + mpv_message(mpv, &comments, Duration::from_secs(6))?; + Ok(()) +} diff --git a/crates/yt/src/watch/playlist_handler/mod.rs b/crates/yt/src/watch/playlist_handler/mod.rs new file mode 100644 index 0000000..29b8f39 --- /dev/null +++ b/crates/yt/src/watch/playlist_handler/mod.rs @@ -0,0 +1,342 @@ +// 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_dlp/Cargo.toml b/crates/yt_dlp/Cargo.toml index 1d34371..3632b23 100644 --- a/crates/yt_dlp/Cargo.toml +++ b/crates/yt_dlp/Cargo.toml @@ -10,7 +10,7 @@ [package] name = "yt_dlp" -description = "A wrapper around the python yt_dlp library" +description = "A rust ffi wrapper library for the python yt_dlp library" keywords = [] categories = [] version.workspace = true @@ -19,19 +19,25 @@ authors.workspace = true license.workspace = true repository.workspace = true rust-version.workspace = true -publish = false +publish = true [dependencies] -pyo3 = { version = "0.23.3", features = ["auto-initialize"] } -bytes.workspace = true +curl = "0.4.48" +indexmap = { version = "2.9.0", default-features = false } log.workspace = true -serde.workspace = true +rustpython = { git = "https://github.com/RustPython/RustPython.git", rev = "6a992d4f", features = [ + "threading", + "stdlib", + "stdio", + "freeze-stdlib", + "importlib", + "ssl", +], default-features = false } +serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +thiserror = "2.0.12" url.workspace = true -[dev-dependencies] -tokio.workspace = true - [lints] workspace = true 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/src/duration.rs b/crates/yt_dlp/src/duration.rs deleted file mode 100644 index 19181a5..0000000 --- a/crates/yt_dlp/src/duration.rs +++ /dev/null @@ -1,78 +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>. - -// TODO: This file should be de-duplicated with the same file in the 'yt' crate <2024-06-25> - -#[derive(Debug, Clone, Copy)] -pub struct Duration { - time: u32, -} - -impl From<&str> for Duration { - fn from(v: &str) -> Self { - let buf: Vec<_> = v.split(':').take(2).collect(); - Self { - time: (buf[0].parse::<u32>().expect("Should be a number") * 60) - + buf[1].parse::<u32>().expect("Should be a number"), - } - } -} - -impl From<Option<f64>> for Duration { - fn from(value: Option<f64>) -> Self { - Self { - #[allow( - clippy::cast_possible_truncation, - clippy::cast_precision_loss, - clippy::cast_sign_loss - )] - time: value.unwrap_or(0.0).ceil() as u32, - } - } -} - -impl std::fmt::Display for Duration { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - const SECOND: u32 = 1; - const MINUTE: u32 = 60 * SECOND; - const HOUR: u32 = 60 * MINUTE; - - let base_hour = self.time - (self.time % HOUR); - let base_min = (self.time % HOUR) - ((self.time % HOUR) % MINUTE); - let base_sec = (self.time % HOUR) % MINUTE; - - let h = base_hour / HOUR; - let m = base_min / MINUTE; - let s = base_sec / SECOND; - - if self.time == 0 { - write!(f, "0s") - } else if h > 0 { - write!(f, "{h}h {m}m") - } else { - write!(f, "{m}m {s}s") - } - } -} -#[cfg(test)] -mod test { - use super::Duration; - - #[test] - fn test_display_duration_1h() { - let dur = Duration { time: 60 * 60 }; - assert_eq!("1h 0m".to_owned(), dur.to_string()); - } - #[test] - fn test_display_duration_30min() { - let dur = Duration { time: 60 * 30 }; - assert_eq!("30m 0s".to_owned(), dur.to_string()); - } -} diff --git a/crates/yt_dlp/src/info_json.rs b/crates/yt_dlp/src/info_json.rs new file mode 100644 index 0000000..31f4a69 --- /dev/null +++ b/crates/yt_dlp/src/info_json.rs @@ -0,0 +1,60 @@ +// 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 rustpython::vm::{ + PyRef, VirtualMachine, + builtins::{PyDict, PyStr}, +}; + +pub type InfoJson = serde_json::Map<String, serde_json::Value>; + +pub 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") +} + +/// # 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"), + } +} diff --git a/crates/yt_dlp/src/lib.rs b/crates/yt_dlp/src/lib.rs index 970bfe2..a03e444 100644 --- a/crates/yt_dlp/src/lib.rs +++ b/crates/yt_dlp/src/lib.rs @@ -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. @@ -8,461 +8,322 @@ // 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 pyo3 `pyfunction` proc-macros call unsafe functions internally, which trigger this lint. -#![allow(unsafe_op_in_unsafe_fn)] -#![allow(clippy::missing_errors_doc)] +//! The `yt_dlp` interface is completely contained in the [`YoutubeDL`] structure. -use std::env; -use std::io::stdout; -use std::{fs::File, io::Write}; +use std::path::PathBuf; -use std::{path::PathBuf, sync::Once}; - -use crate::{duration::Duration, logging::setup_logging, wrapper::info_json::InfoJson}; - -use bytes::Bytes; -use log::{info, log_enabled, Level}; -use pyo3::types::{PyString, PyTuple, PyTupleMethods}; -use pyo3::{ - pyfunction, - types::{PyAnyMethods, PyDict, PyDictMethods, PyList, PyListMethods, PyModule}, - wrap_pyfunction, Bound, PyAny, PyResult, Python, +use indexmap::IndexMap; +use log::info; +use rustpython::vm::{ + Interpreter, PyObjectRef, PyRef, VirtualMachine, + builtins::{PyDict, PyList, PyStr}, + function::{FuncArgs, KwArgs, PosArgs}, }; -use serde::Serialize; -use serde_json::{Map, Value}; use url::Url; -pub mod duration; -pub mod logging; -pub mod wrapper; - -#[cfg(test)] -mod tests; - -/// Synchronisation helper, to ensure that we don't setup the logger multiple times -static SYNC_OBJ: Once = Once::new(); - -/// Add a logger to the yt-dlp options. -/// If you have an logger set (i.e. for rust), than this will log to rust -/// -/// # Panics -/// This should never panic. -pub fn add_logger_and_sig_handler<'a>( - opts: Bound<'a, PyDict>, - py: Python<'_>, -) -> PyResult<Bound<'a, PyDict>> { - setup_logging(py, "yt_dlp")?; - - let logging = PyModule::import(py, "logging")?; - let ytdl_logger = logging.call_method1("getLogger", ("yt_dlp",))?; - - // Ensure that all events are logged by setting the log level to NOTSET (we filter on rust's side) - // Also use this static, to ensure that we don't configure the logger every time - SYNC_OBJ.call_once(|| { - // Disable the SIGINT (Ctrl+C) handler, python installs. - // This allows the user to actually stop the application with Ctrl+C. - // This is here because it can only be run in the main thread and this was here already. - py.run( - c"\ -import signal -signal.signal(signal.SIGINT, signal.SIG_DFL)", - None, - None, - ) - .expect("This code should always work"); - - let config_opts = PyDict::new(py); - config_opts - .set_item("level", 0) - .expect("Setting this item should always work"); - - logging - .call_method("basicConfig", (), Some(&config_opts)) - .expect("This method exists"); - }); - - // This was taken from `ytcc`, I don't think it is still applicable - // ytdl_logger.setattr("propagate", false)?; - // let logging_null_handler = logging.call_method0("NullHandler")?; - // ytdl_logger.setattr("addHandler", logging_null_handler)?; - - opts.set_item("logger", ytdl_logger).expect("Should work"); - - Ok(opts) +use crate::{ + info_json::{InfoJson, json_dumps, json_loads}, + python_error::PythonError, +}; + +pub mod info_json; +pub mod options; +pub mod post_processors; +pub mod progress_hook; +pub mod python_error; + +mod logging; +mod package_hacks; + +#[macro_export] +macro_rules! json_get { + ($value:expr, $name:literal, $into:ident) => {{ + match $value.get($name) { + Some(val) => $crate::json_cast!(val, $into), + None => panic!( + concat!( + "Expected '", + $name, + "' to be a key for the'", + stringify!($value), + "' object: {:#?}" + ), + $value + ), + } + }}; } -#[pyfunction] -#[allow(clippy::too_many_lines)] -#[allow(clippy::missing_panics_doc)] -#[allow(clippy::items_after_statements)] -#[allow( - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - clippy::cast_precision_loss -)] -pub fn progress_hook(py: Python<'_>, input: &Bound<'_, PyDict>) -> PyResult<()> { - // 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_export] +macro_rules! json_cast { + ($value:expr, $into:ident) => {{ + match $value.$into() { + Some(result) => result, + None => panic!( + concat!( + "Expected to be able to cast value ({:#?}) ", + stringify!($into) + ), + $value + ), + } + }}; +} - // ANSI ESCAPE CODES Wrappers {{{ - // see: https://en.wikipedia.org/wiki/ANSI_escape_code#Control_Sequence_Introducer_commands - const CSI: &str = "\x1b["; - fn clear_whole_line() { - print!("{CSI}2K"); - } - fn move_to_col(x: usize) { - print!("{CSI}{x}G"); - } - // }}} - - let input: Map<String, Value> = serde_json::from_str(&json_dumps( - py, - input - .downcast::<PyAny>() - .expect("Will always work") - .to_owned(), - )?) - .expect("Python should always produce valid json"); - - 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) - ); - } - }}; +/// The core of the `yt_dlp` interface. +pub struct YoutubeDL { + interpreter: Interpreter, + youtube_dl_class: PyObjectRef, + yt_dlp_module: PyObjectRef, + options: serde_json::Map<String, serde_json::Value>, +} - ($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 - }}; +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") + } +} - ($type_fun:ident, $get_fun:ident, $name:expr) => {{ - get! {@interrogate input, $type_fun, $get_fun, $name} - }}; +impl YoutubeDL { + /// Fetch the underlying `yt_dlp` and `python` version. + /// + /// + /// # Panics + /// + /// If `yt_dlp` changed their location or type of `__version__`. + pub fn version(&self) -> (String, String) { + let yt_dlp: 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", + ); + + let python: PyRef<PyStr> = self.interpreter.enter_and_expect( + |vm| { + let version_module = vm.import("sys", 0)?; + let version = version_module.get_attr("version", vm)?; + let version = version.downcast().expect("This should always be a string"); + Ok(version) + }, + "python version location has changed", + ); + + (yt_dlp.to_string(), python.to_string()) } - 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) + /// Download a given list of URLs. + /// Returns the paths they were downloaded to. + /// + /// # Errors + /// If one of the downloads error. + pub fn download(&self, urls: &[Url]) -> Result<Vec<PathBuf>, extract_info::Error> { + let mut out_paths = Vec::with_capacity(urls.len()); + + for url in urls { + info!("Started downloading url: '{url}'"); + 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)) } else { - $default + PathBuf::from(json_get!( + json_cast!( + json_get!(info_json, "requested_downloads", as_array)[0], + as_object + ), + "filename", + as_str + )) }; - 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) - }; - } - - fn format_bytes(bytes: u64) -> String { - let bytes = Bytes::new(bytes); - bytes.to_string() - } + out_paths.push(result_string); + info!("Finished downloading url"); + } - 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") + Ok(out_paths) } - let get_title = |add_extension: bool| -> String { - match get! {is_string, as_str, "info_dict", "ext"} { - "vtt" => { - format!( - "Subtitles ({})", - default_get! {as_str, "<No Subtitle Language>", "info_dict", "name"} - ) - } - title_extension @ ("webm" | "mp4" | "m4a") => { - if add_extension { - format!( - "{} ({})", - default_get! { as_str, "<No title>", "info_dict", "title"}, - title_extension - ) + /// `extract_info(self, url, download=True, ie_key=None, extra_info=None, process=True, force_generic_extractor=False)` + /// + /// Extract and return the information dictionary of the URL + /// + /// Arguments: + /// - `url` URL to extract + /// + /// Keyword arguments: + /// :`download` Whether to download videos + /// :`process` Whether to resolve all unresolved references (URLs, playlist items). + /// Must be True for download to work + /// + /// # Panics + /// If expectations about python fail to hold. + /// + /// # Errors + /// If python operations fail. + pub fn extract_info( + &self, + url: &Url, + download: bool, + process: bool, + ) -> Result<InfoJson, extract_info::Error> { + 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 + }); + + let fun_args = FuncArgs::new(pos_args, kw_args); + + let inner = self + .youtube_dl_class + .get_attr("extract_info", vm) + .map_err(|exc| PythonError::from_exception(vm, &exc))?; + let result = inner + .call_with_args(fun_args, vm) + .map_err(|exc| PythonError::from_exception(vm, &exc))? + .downcast::<PyDict>() + .expect("This is a dict"); + + // Resolve the generator object + if let Ok(generator) = result.get_item("entries", vm) { + if generator.payload_is::<PyList>() { + // already resolved. Do nothing } else { - 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"); - - 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, "~") + let max_backlog = self.options.get("playlistend").map_or(10000, |value| { + usize::try_from(value.as_u64().expect("Works")).expect("Should work") + }); + + let mut out = vec![]; + let next = generator + .get_attr("__next__", vm) + .map_err(|exc| PythonError::from_exception(vm, &exc))?; + while let Ok(output) = next.call((), vm) { + out.push(output); + + if out.len() == max_backlog { + break; + } } - } else { - (total_bytes, "") + result + .set_item("entries", vm.new_pyobj(out), vm) + .map_err(|exc| PythonError::from_exception(vm, &exc))?; } - }; - let percent: f64 = { - if total_bytes == 0 { - 100.0 - } else { - (downloaded_bytes as f64 / total_bytes as f64) * 100.0 - } - }; - - clear_whole_line(); - move_to_col(1); - - print!( - "'{}' [{}/{} at {}] -> [{} of {}{} {}] ", - c!("34;1", get_title(true)), - c!("33;1", Duration::from(Some(elapsed))), - c!("33;1", Duration::from(Some(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)) - ); - stdout().flush()?; - } - "finished" => { - println!("-> Finished downloading."); - } - "error" => { - panic!("-> Error while downloading: {}", get_title(true)) - } - other => unreachable!("'{other}' should not be a valid state!"), - }; - - Ok(()) -} - -pub fn add_hooks<'a>(opts: Bound<'a, PyDict>, py: Python<'_>) -> PyResult<Bound<'a, PyDict>> { - if let Some(hooks) = opts.get_item("progress_hooks")? { - let hooks = hooks.downcast::<PyList>()?; - hooks.append(wrap_pyfunction!(progress_hook, py)?)?; - - opts.set_item("progress_hooks", hooks)?; - } else { - // No hooks are set yet - let hooks_list = PyList::new(py, &[wrap_pyfunction!(progress_hook, py)?])?; - - opts.set_item("progress_hooks", hooks_list)?; - } - - Ok(opts) -} - -/// `extract_info(self, url, download=True, ie_key=None, extra_info=None, process=True, force_generic_extractor=False)` -/// -/// Extract and return the information dictionary of the URL -/// -/// Arguments: -/// @param url URL to extract -/// -/// Keyword arguments: -/// @param download Whether to download videos -/// @param process Whether to resolve all unresolved references (URLs, playlist items). -/// Must be True for download to work -/// @param `ie_key` Use only the extractor with this key -/// -/// @param `extra_info` Dictionary containing the extra values to add to the info (For internal use only) -/// @`force_generic_extractor` Force using the generic extractor (Deprecated; use `ie_key`='Generic') -#[allow(clippy::unused_async)] -#[allow(clippy::missing_panics_doc)] -pub async fn extract_info( - yt_dlp_opts: &Map<String, Value>, - url: &Url, - download: bool, - process: bool, -) -> PyResult<InfoJson> { - Python::with_gil(|py| { - let opts = json_map_to_py_dict(yt_dlp_opts, py)?; - - let instance = get_yt_dlp(py, opts)?; - let args = (url.as_str(),); - - let kwargs = PyDict::new(py); - kwargs.set_item("download", download)?; - kwargs.set_item("process", process)?; - - let result = instance.call_method("extract_info", args, Some(&kwargs))?; - - // Remove the `<generator at 0xsome_hex>`, by setting it to null - if !process { - result.set_item("entries", ())?; - } - - let result_str = json_dumps(py, result)?; - - if let Ok(confirm) = env::var("YT_STORE_INFO_JSON") { - if confirm == "yes" { - let mut file = File::create("output.info.json")?; - write!(file, "{result_str}").unwrap(); } - } - Ok(serde_json::from_str(&result_str) - .expect("Python should be able to produce correct json")) - }) -} - -/// # Panics -/// Only if python fails to return a valid URL. -pub fn unsmuggle_url(smug_url: &Url) -> PyResult<Url> { - Python::with_gil(|py| { - let utils = get_yt_dlp_utils(py)?; - let url = utils - .call_method1("unsmuggle_url", (smug_url.as_str(),))? - .downcast::<PyTuple>()? - .get_item(0)?; - - let url: Url = url - .downcast::<PyString>()? - .to_string() - .parse() - .expect("Python should be able to return a valid url"); - - Ok(url) - }) -} + let result = self.prepare_info_json(result, vm)?; -/// Download a given list of URLs. -/// Returns the paths they were downloaded to. -/// -/// # Panics -/// Only if `yt_dlp` changes their `info_json` schema. -pub async fn download( - urls: &[Url], - download_options: &Map<String, Value>, -) -> PyResult<Vec<PathBuf>> { - let mut out_paths = Vec::with_capacity(urls.len()); - - for url in urls { - info!("Started downloading url: '{}'", url); - let info_json = extract_info(download_options, url, true, true).await?; - - // Try to work around yt-dlp type weirdness - let result_string = if let Some(filename) = info_json.filename { - filename - } else { - info_json.requested_downloads.expect("This must exist")[0] - .filename - .clone() - }; - - out_paths.push(result_string); - info!("Finished downloading url: '{}'", url); + Ok(result) + }) } - Ok(out_paths) -} - -fn json_map_to_py_dict<'a>( - map: &Map<String, Value>, - py: Python<'a>, -) -> PyResult<Bound<'a, PyDict>> { - let json_string = serde_json::to_string(&map).expect("This must always work"); - - let python_dict = json_loads(py, json_string)?; - - Ok(python_dict) -} - -fn json_dumps(py: Python<'_>, input: Bound<'_, PyAny>) -> PyResult<String> { - // json.dumps(yt_dlp.sanitize_info(input)) - - let yt_dlp = get_yt_dlp(py, PyDict::new(py))?; - let sanitized_result = yt_dlp.call_method1("sanitize_info", (input,))?; - - let json = PyModule::import(py, "json")?; - let dumps = json.getattr("dumps")?; + /// Take the (potentially modified) result of the information extractor (i.e., + /// [`Self::extract_info`] with `process` and `download` set to false) + /// and resolve all unresolved references (URLs, + /// playlist items). + /// + /// It will also download the videos if 'download' is true. + /// Returns the resolved `ie_result`. + /// + /// # Panics + /// If expectations about python fail to hold. + /// + /// # Errors + /// If python operations fail. + pub fn process_ie_result( + &self, + ie_result: InfoJson, + download: bool, + ) -> Result<InfoJson, process_ie_result::Error> { + self.interpreter.enter(|vm| { + let pos_args = PosArgs::new(vec![vm.new_pyobj(json_loads(ie_result, vm))]); + + 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) + .map_err(|exc| PythonError::from_exception(vm, &exc))?; + let result = inner + .call_with_args(fun_args, vm) + .map_err(|exc| PythonError::from_exception(vm, &exc))? + .downcast::<PyDict>() + .expect("This is a dict"); + + let result = self.prepare_info_json(result, vm)?; + + Ok(result) + }) + } - let output = dumps.call1((sanitized_result,))?; + fn prepare_info_json( + &self, + info: PyRef<PyDict>, + vm: &VirtualMachine, + ) -> Result<InfoJson, prepare::Error> { + let sanitize = self + .youtube_dl_class + .get_attr("sanitize_info", vm) + .map_err(|exc| PythonError::from_exception(vm, &exc))?; - let output_str = output.extract::<String>()?; + let value = sanitize + .call((info,), vm) + .map_err(|exc| PythonError::from_exception(vm, &exc))?; - Ok(output_str) -} + let result = value.downcast::<PyDict>().expect("This should stay a dict"); -fn json_loads_str<T: Serialize>(py: Python<'_>, input: T) -> PyResult<Bound<'_, PyDict>> { - let string = serde_json::to_string(&input).expect("Correct json must be pased"); - - json_loads(py, string) + Ok(json_dumps(result, vm)) + } } -fn json_loads(py: Python<'_>, input: String) -> PyResult<Bound<'_, PyDict>> { - // json.loads(input) +#[allow(missing_docs)] +pub mod process_ie_result { + use crate::{prepare, python_error::PythonError}; - let json = PyModule::import(py, "json")?; - let dumps = json.getattr("loads")?; + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error(transparent)] + Python(#[from] PythonError), - let output = dumps.call1((input,))?; - - Ok(output - .downcast::<PyDict>() - .expect("This should always be a PyDict") - .clone()) + #[error("Failed to prepare the info json")] + InfoJsonPrepare(#[from] prepare::Error), + } } +#[allow(missing_docs)] +pub mod extract_info { + use crate::{prepare, python_error::PythonError}; -fn get_yt_dlp_utils(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> { - let yt_dlp = PyModule::import(py, "yt_dlp")?; - let utils = yt_dlp.getattr("utils")?; + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error(transparent)] + Python(#[from] PythonError), - Ok(utils) + #[error("Failed to prepare the info json")] + InfoJsonPrepare(#[from] prepare::Error), + } } -fn get_yt_dlp<'a>(py: Python<'a>, opts: Bound<'a, PyDict>) -> PyResult<Bound<'a, PyAny>> { - // Unconditionally set a logger - let opts = add_logger_and_sig_handler(opts, py)?; - let opts = add_hooks(opts, py)?; - - let yt_dlp = PyModule::import(py, "yt_dlp")?; - let youtube_dl = yt_dlp.call_method1("YoutubeDL", (opts,))?; - - Ok(youtube_dl) +#[allow(missing_docs)] +pub mod prepare { + use crate::python_error::PythonError; + + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error(transparent)] + Python(#[from] PythonError), + } } diff --git a/crates/yt_dlp/src/logging.rs b/crates/yt_dlp/src/logging.rs index 670fc1c..112836e 100644 --- a/crates/yt_dlp/src/logging.rs +++ b/crates/yt_dlp/src/logging.rs @@ -10,33 +10,66 @@ // 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 +// It was modified by Benedikt Peetz 2024, 2025 + +use log::{Level, MetadataBuilder, Record, logger}; +use rustpython::vm::{ + PyObjectRef, PyRef, PyResult, VirtualMachine, + builtins::{PyInt, PyStr}, + convert::ToPyObject, + function::FuncArgs, +}; -// The pyo3 `pyfunction` proc-macros call unsafe functions internally, which trigger this lint. -#![allow(unsafe_op_in_unsafe_fn)] +/// 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() + }; -use std::ffi::CString; + 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() + }; -use log::{logger, Level, MetadataBuilder, Record}; -use pyo3::{ - prelude::{PyAnyMethods, PyListMethods, PyModuleMethods}, - pyfunction, wrap_pyfunction, Bound, PyAny, PyResult, Python, -}; + let pathname = { + let pathname: PyRef<PyStr> = record + .get_attr("pathname", vm)? + .downcast() + .expect("Is a string"); -/// Consume a Python `logging.LogRecord` and emit a Rust `Log` instead. -#[allow(clippy::needless_pass_by_value)] -#[pyfunction] -fn host_log(record: Bound<'_, PyAny>, rust_target: &str) -> PyResult<()> { - let level = record.getattr("levelno")?; - let message = record.getattr("getMessage")?.call0()?.to_string(); - let pathname = record.getattr("pathname")?.to_string(); - let lineno = record - .getattr("lineno")? - .to_string() - .parse::<u32>() - .expect("This should always be a u32"); - - let logger_name = record.getattr("name")?.to_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 @@ -47,25 +80,25 @@ fn host_log(record: Bound<'_, PyAny>, rust_target: &str) -> PyResult<()> { Some(format!("{rust_target}::{logger_name}")) }; - let target = full_target.as_deref().unwrap_or(rust_target); + let target = full_target.as_deref().unwrap_or(&rust_target); // error - let error_metadata = if level.ge(40u8)? { + let error_metadata = if level >= 40 { MetadataBuilder::new() .target(target) .level(Level::Error) .build() - } else if level.ge(30u8)? { + } else if level >= 30 { MetadataBuilder::new() .target(target) .level(Level::Warn) .build() - } else if level.ge(20u8)? { + } else if level >= 20 { MetadataBuilder::new() .target(target) .level(Level::Info) .build() - } else if level.ge(10u8)? { + } else if level >= 10 { MetadataBuilder::new() .target(target) .level(Level::Debug) @@ -97,13 +130,24 @@ fn host_log(record: Bound<'_, PyAny>, rust_target: &str) -> PyResult<()> { /// # Panics /// Only if internal assertions fail. #[allow(clippy::module_name_repetitions)] -pub fn setup_logging(py: Python<'_>, target: &str) -> PyResult<()> { - let logging = py.import("logging")?; +pub(super) fn setup_logging(vm: &VirtualMachine, target: &str) -> PyResult<PyObjectRef> { + let logging = vm.import("logging", 0)?; - logging.setattr("host_log", wrap_pyfunction!(host_log, &logging)?)?; + let scope = vm.new_scope_with_builtins(); - py.run( - CString::new(format!( + 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): @@ -118,15 +162,10 @@ def basicConfig(*pargs, **kwargs): kwargs["handlers"] = [HostHandler()] return oldBasicConfig(*pargs, **kwargs) "# - )) - .expect("This is hardcoded") - .as_c_str(), - Some(&logging.dict()), - None, + ) + .as_str(), + "<embedded logging inintializing code>".to_owned(), )?; - let all = logging.index()?; - all.append("HostHandler")?; - - Ok(()) + 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..dc3c154 --- /dev/null +++ b/crates/yt_dlp/src/options.rs @@ -0,0 +1,286 @@ +// 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; + +use indexmap::IndexMap; +use log::{Level, debug, error, log_enabled}; +use rustpython::{ + InterpreterConfig, + vm::{ + self, PyObjectRef, PyRef, PyResult, VirtualMachine, + builtins::{PyBaseException, PyStr}, + function::{FuncArgs, KwArgs, PosArgs}, + }, +}; + +use crate::{ + YoutubeDL, json_loads, logging::setup_logging, package_hacks, post_processors, + python_error::process_exception, +}; + +/// Wrap your function with [`mk_python_function`]. +pub type ProgressHookFunction = fn(input: FuncArgs, vm: &VirtualMachine); + +pub type PostProcessorFunction = fn(vm: &VirtualMachine) -> PyResult<PyObjectRef>; + +/// 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> { + 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 a yt_dlp python package!" + ); + } + + 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| { + { + // Add missing (and required) values to the stdlib + package_hacks::urllib3::apply_hacks(vm)?; + } + + let yt_dlp_module = vm.import("yt_dlp", 0)?; + let class = yt_dlp_module.get_attr("YoutubeDL", vm)?; + + let opts = json_loads(options.options, vm); + + { + // Setup the progress hook + if let Some(function) = options.progress_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)?; + + { + // Setup the post processors + + let add_post_processor_fun = youtube_dl_class.get_attr("add_post_processor", vm)?; + + for pp in options.post_processors { + let args = { + FuncArgs::new( + PosArgs::new(vec![pp(vm)?]), + KwArgs::new({ + let mut map = IndexMap::new(); + // "when" can take any value in yt_dlp.utils.POSTPROCESS_WHEN + map.insert("when".to_owned(), vm.new_pyobj("pre_process")); + map + }), + ) + }; + + add_post_processor_fun.call(args, 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, + }) + } +} + +#[allow(missing_docs)] +pub mod build { + #[derive(Debug, thiserror::Error)] + pub enum Error { + #[error("Python threw an exception: {0}")] + Python(String), + } +} diff --git a/crates/yt_dlp/src/wrapper/mod.rs b/crates/yt_dlp/src/package_hacks/mod.rs index 3fe3247..53fe323 100644 --- a/crates/yt_dlp/src/wrapper/mod.rs +++ b/crates/yt_dlp/src/package_hacks/mod.rs @@ -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. @@ -8,5 +8,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>. -pub mod info_json; -// pub mod yt_dlp_options; +pub(super) mod urllib3; diff --git a/crates/yt_dlp/src/package_hacks/urllib3.rs b/crates/yt_dlp/src/package_hacks/urllib3.rs new file mode 100644 index 0000000..28ae37a --- /dev/null +++ b/crates/yt_dlp/src/package_hacks/urllib3.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 rustpython::vm::{PyResult, VirtualMachine}; + +// NOTE(@bpeetz): Remove this, once rust-python supports these features. <2025-06-27> +pub(crate) fn apply_hacks(vm: &VirtualMachine) -> PyResult<()> { + { + // Urllib3 tries to import this value, regardless if it is set. + let ssl_module = vm.import("ssl", 0)?; + ssl_module.set_attr("VERIFY_X509_STRICT", vm.ctx.new_int(0x20), vm)?; + } + + { + // Urllib3 tries to set the SSLContext.verify_flags value, regardless if it exists or not. + // So we need to provide a polyfill. + + let scope = vm.new_scope_with_builtins(); + + vm.run_code_string( + scope, + include_str!("urllib3_polyfill.py"), + "<embedded urllib3 polyfill workaround code>".to_owned(), + )?; + } + + Ok(()) +} diff --git a/.env b/crates/yt_dlp/src/package_hacks/urllib3_polyfill.py index f4ffbe7..610fd99 100644 --- a/.env +++ b/crates/yt_dlp/src/package_hacks/urllib3_polyfill.py @@ -1,10 +1,13 @@ # 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>. -DATABASE_URL=sqlite://target/database.sqlite + +import ssl + +ssl.SSLContext.verify_flags = 0 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..3cac745 --- /dev/null +++ b/crates/yt_dlp/src/post_processors/dearrow.rs @@ -0,0 +1,184 @@ +// 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, warn}; +use rustpython::vm::{ + PyRef, VirtualMachine, + builtins::{PyDict, PyStr}, +}; +use serde::{Deserialize, Serialize}; + +use crate::{pydict_cast, pydict_get, wrap_post_processor}; + +wrap_post_processor!("DeArrow", unwrapped_process, process); + +/// # Errors +/// If the API access fails. +pub fn unwrapped_process(info: PyRef<PyDict>, vm: &VirtualMachine) -> Result<PyRef<PyDict>, Error> { + if pydict_get!(@vm, info, "extractor_key", PyStr).as_str() != "Youtube" { + warn!("DeArrow: Extractor did not match, exiting."); + return Ok(info); + } + + let mut output: DeArrowApi = { + 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!(@vm, info, "id", PyStr).as_str() + ) + .as_str(), + )?; + + let mut transfer = easy.transfer(); + transfer.write_function(|data| { + dst.extend_from_slice(data); + Ok(data.len()) + })?; + transfer.perform()?; + drop(transfer); + + dst + }; + + serde_json::from_slice(&output_bytes)? + }; + + // 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, vm); + + 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, vm); + } + + Ok(info) +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[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: &PyRef<PyDict>, new_title: &str, vm: &VirtualMachine) { + assert!(!info.contains_key("original_title", vm)); + + if let Ok(old_title) = info.get_item("title", vm) { + warn!( + "DeArrow: Updating title from {:#?} to {:#?}", + pydict_cast!(@ref old_title, PyStr).as_str(), + new_title + ); + + info.set_item("original_title", old_title, vm) + .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("title", vm.new_pyobj(cleaned_title), vm) + .expect("This should work?"); +} + +#[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..00b0ad5 --- /dev/null +++ b/crates/yt_dlp/src/post_processors/mod.rs @@ -0,0 +1,123 @@ +// 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 { + (@$vm:expr, $value:expr, $name:literal, $into:ident) => {{ + match $value.get_item($name, $vm) { + 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:ident) => {{ + match $value.downcast::<$into>() { + Ok(result) => result, + Err(val) => panic!( + concat!( + "Expected to be able to downcast value ({:#?}) as ", + stringify!($into) + ), + val + ), + } + }}; + (@ref $value:expr, $into:ident) => {{ + match $value.downcast_ref::<$into>() { + Some(result) => result, + None => panic!( + concat!( + "Expected to be able to downcast value ({:#?}) as ", + stringify!($into) + ), + $value + ), + } + }}; +} + +#[macro_export] +macro_rules! wrap_post_processor { + ($name:literal, $unwrap:ident, $wrapped:ident) => { + use $crate::progress_hook::__priv::vm; + + /// # Errors + /// - If the underlying function returns an error. + /// - If python operations fail. + pub fn $wrapped(vm: &vm::VirtualMachine) -> vm::PyResult<vm::PyObjectRef> { + fn actual_processor( + mut input: vm::function::FuncArgs, + vm: &vm::VirtualMachine, + ) -> vm::PyResult<vm::PyRef<vm::builtins::PyDict>> { + let input = input + .args + .remove(0) + .downcast::<vm::builtins::PyDict>() + .expect("Should be a py dict"); + + let output = match unwrapped_process(input, vm) { + Ok(ok) => ok, + Err(err) => { + return Err(vm.new_runtime_error(err.to_string())); + } + }; + + Ok(output) + } + + let scope = vm.new_scope_with_builtins(); + + scope.globals.set_item( + "actual_processor", + vm.new_function("actual_processor", actual_processor).into(), + vm, + )?; + + let local_scope = scope.clone(); + vm.run_code_string( + local_scope, + format!( + " +import yt_dlp + +class {}(yt_dlp.postprocessor.PostProcessor): + def run(self, info): + info = actual_processor(info) + return [], info + +inst = {}() +", + $name, $name + ) + .as_str(), + "<embedded post processor initializing code>".to_owned(), + )?; + + Ok(scope + .globals + .get_item("inst", vm) + .expect("We just declared it")) + } + }; +} diff --git a/crates/yt_dlp/src/progress_hook.rs b/crates/yt_dlp/src/progress_hook.rs new file mode 100644 index 0000000..b42ae21 --- /dev/null +++ b/crates/yt_dlp/src/progress_hook.rs @@ -0,0 +1,54 @@ +// 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 { + ($name:ident, $new_name:ident) => { + pub fn $new_name( + mut args: $crate::progress_hook::__priv::vm::function::FuncArgs, + vm: &$crate::progress_hook::__priv::vm::VirtualMachine, + ) { + use $crate::progress_hook::__priv::vm; + + let input = { + let dict: vm::PyRef<vm::builtins::PyDict> = args + .args + .remove(0) + .downcast() + .expect("The progress hook is always called with these args"); + let new_dict = vm::builtins::PyDict::new_ref(&vm.ctx); + dict.into_iter() + .filter_map(|(name, value)| { + let real_name: vm::PyRefExact<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::progress_hook::__priv::json_dumps(new_dict, vm) + }; + $name(input).expect("Shall not fail!"); + } + }; +} + +pub mod __priv { + pub use crate::info_json::{json_dumps, json_loads}; + pub use rustpython::vm; +} diff --git a/crates/yt_dlp/src/python_error.rs b/crates/yt_dlp/src/python_error.rs new file mode 100644 index 0000000..9513956 --- /dev/null +++ b/crates/yt_dlp/src/python_error.rs @@ -0,0 +1,116 @@ +// 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; + +use log::{Level, debug, log_enabled}; +use rustpython::vm::{ + AsObject, PyPayload, PyRef, VirtualMachine, + builtins::{PyBaseException, PyBaseExceptionRef, PyStr}, + py_io::Write, + suggestion::offer_suggestions, +}; + +#[derive(thiserror::Error, Debug)] +pub struct PythonError(pub String); + +impl Display for PythonError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Python threw an exception: {}", self.0) + } +} + +impl PythonError { + pub(super) fn from_exception(vm: &VirtualMachine, exc: &PyRef<PyBaseException>) -> Self { + let buffer = process_exception(vm, exc); + Self(buffer) + } +} + +pub(super) 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}"); + } + + buffer +} + +// 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" + ); + } + + 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(()), + } +} diff --git a/crates/yt_dlp/src/tests.rs b/crates/yt_dlp/src/tests.rs deleted file mode 100644 index b48deb4..0000000 --- a/crates/yt_dlp/src/tests.rs +++ /dev/null @@ -1,85 +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 std::sync::LazyLock; - -use serde_json::{json, Value}; -use url::Url; - -static YT_OPTS: LazyLock<serde_json::Map<String, Value>> = LazyLock::new(|| { - match json!({ - "playliststart": 1, - "playlistend": 10, - "noplaylist": false, - "extract_flat": false, - }) { - Value::Object(obj) => obj, - _ => unreachable!("This json is hardcoded"), - } -}); - -#[tokio::test] -async fn test_extract_info_video() { - let info = crate::extract_info( - &YT_OPTS, - &Url::parse("https://www.youtube.com/watch?v=dbjPnXaacAU").expect("Is valid."), - false, - false, - ) - .await - .map_err(|err| format!("Encountered error: '{err}'")) - .unwrap(); - - println!("{info:#?}"); -} - -#[tokio::test] -async fn test_extract_info_url() { - let err = crate::extract_info( - &YT_OPTS, - &Url::parse("https://google.com").expect("Is valid."), - false, - false, - ) - .await - .map_err(|err| format!("Encountered error: '{err}'")) - .unwrap(); - - println!("{err:#?}"); -} - -#[tokio::test] -async fn test_extract_info_playlist() { - let err = crate::extract_info( - &YT_OPTS, - &Url::parse("https://www.youtube.com/@TheGarriFrischer/videos").expect("Is valid."), - false, - true, - ) - .await - .map_err(|err| format!("Encountered error: '{err}'")) - .unwrap(); - - println!("{err:#?}"); -} -#[tokio::test] -async fn test_extract_info_playlist_full() { - let err = crate::extract_info( - &YT_OPTS, - &Url::parse("https://www.youtube.com/@NixOS-Foundation/videos").expect("Is valid."), - false, - true, - ) - .await - .map_err(|err| format!("Encountered error: '{err}'")) - .unwrap(); - - println!("{err:#?}"); -} diff --git a/crates/yt_dlp/src/wrapper/info_json.rs b/crates/yt_dlp/src/wrapper/info_json.rs deleted file mode 100644 index 35d155e..0000000 --- a/crates/yt_dlp/src/wrapper/info_json.rs +++ /dev/null @@ -1,556 +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>. - -// `yt_dlp` named them like this. -#![allow(clippy::pub_underscore_fields)] - -use std::{collections::HashMap, path::PathBuf}; - -use pyo3::{types::PyDict, Bound, PyResult, Python}; -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::Value; -use url::Url; - -use crate::json_loads_str; - -type Todo = String; -type Extractor = String; -type ExtractorKey = String; - -// TODO: Change this to map `_type` to a structure of values, instead of the options <2024-05-27> -// And replace all the strings with better types (enums or urls) -#[derive(Debug, Deserialize, Serialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct InfoJson { - pub __files_to_move: Option<FilesToMove>, - pub __last_playlist_index: Option<u32>, - pub __post_extractor: Option<String>, - pub __x_forwarded_for_ip: Option<String>, - pub _filename: Option<PathBuf>, - pub _format_sort_fields: Option<Vec<String>>, - pub _has_drm: Option<Todo>, - pub _type: Option<InfoType>, - pub _version: Option<Version>, - pub abr: Option<f64>, - pub acodec: Option<String>, - pub age_limit: Option<u32>, - pub aspect_ratio: Option<f64>, - pub asr: Option<u32>, - pub audio_channels: Option<u32>, - pub audio_ext: Option<String>, - pub automatic_captions: Option<HashMap<String, Vec<Caption>>>, - pub availability: Option<String>, - pub average_rating: Option<String>, - pub categories: Option<Vec<String>>, - pub channel: Option<String>, - pub channel_follower_count: Option<u32>, - pub channel_id: Option<String>, - pub channel_is_verified: Option<bool>, - pub channel_url: Option<String>, - pub chapters: Option<Vec<Chapter>>, - pub comment_count: Option<u32>, - pub comments: Option<Vec<Comment>>, - pub concurrent_view_count: Option<u32>, - pub description: Option<String>, - pub display_id: Option<String>, - pub downloader_options: Option<DownloaderOptions>, - pub duration: Option<f64>, - pub duration_string: Option<String>, - pub dynamic_range: Option<String>, - pub entries: Option<Vec<InfoJson>>, - pub episode: Option<String>, - pub episode_number: Option<u32>, - pub epoch: Option<u32>, - pub ext: Option<String>, - pub extractor: Option<Extractor>, - pub extractor_key: Option<ExtractorKey>, - pub filename: Option<PathBuf>, - pub filesize: Option<u64>, - pub filesize_approx: Option<u64>, - pub format: Option<String>, - pub format_id: Option<String>, - pub format_index: Option<u32>, - pub format_note: Option<String>, - pub formats: Option<Vec<Format>>, - pub fps: Option<f64>, - pub fulltitle: Option<String>, - pub has_drm: Option<bool>, - pub heatmap: Option<Vec<HeatMapEntry>>, - pub height: Option<u32>, - pub http_headers: Option<HttpHeader>, - pub id: Option<String>, - pub ie_key: Option<ExtractorKey>, - pub is_live: Option<bool>, - pub language: Option<String>, - pub language_preference: Option<i32>, - pub license: Option<Todo>, - pub like_count: Option<u32>, - pub live_status: Option<String>, - pub location: Option<Todo>, - pub manifest_url: Option<Url>, - pub modified_date: Option<String>, - pub n_entries: Option<u32>, - pub original_url: Option<String>, - pub playable_in_embed: Option<bool>, - pub playlist: Option<Todo>, - pub playlist_autonumber: Option<u32>, - pub playlist_channel: Option<Todo>, - pub playlist_channel_id: Option<Todo>, - pub playlist_count: Option<u32>, - pub playlist_id: Option<Todo>, - pub playlist_index: Option<u64>, - pub playlist_title: Option<Todo>, - pub playlist_uploader: Option<Todo>, - pub playlist_uploader_id: Option<Todo>, - pub preference: Option<Todo>, - pub protocol: Option<String>, - pub quality: Option<f64>, - pub release_date: Option<String>, - pub release_timestamp: Option<u64>, - pub release_year: Option<u32>, - pub requested_downloads: Option<Vec<RequestedDownloads>>, - pub requested_entries: Option<Vec<u32>>, - pub requested_formats: Option<Vec<Format>>, - pub requested_subtitles: Option<HashMap<String, Subtitle>>, - pub resolution: Option<String>, - pub season: Option<String>, - pub season_number: Option<u32>, - pub series: Option<String>, - pub source_preference: Option<i32>, - pub sponsorblock_chapters: Option<Vec<SponsorblockChapter>>, - pub stretched_ratio: Option<Todo>, - pub subtitles: Option<HashMap<String, Vec<Caption>>>, - pub tags: Option<Vec<String>>, - pub tbr: Option<f64>, - pub thumbnail: Option<Url>, - pub thumbnails: Option<Vec<ThumbNail>>, - pub timestamp: Option<u64>, - pub title: Option<String>, - pub upload_date: Option<String>, - pub uploader: Option<String>, - pub uploader_id: Option<String>, - pub uploader_url: Option<String>, - pub url: Option<Url>, - pub vbr: Option<f64>, - pub vcodec: Option<String>, - pub video_ext: Option<String>, - pub view_count: Option<u32>, - pub was_live: Option<bool>, - pub webpage_url: Option<Url>, - pub webpage_url_basename: Option<String>, - pub webpage_url_domain: Option<String>, - pub width: Option<u32>, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq)] -#[serde(deny_unknown_fields)] -#[allow(missing_copy_implementations)] -pub struct FilesToMove {} - -#[derive(Debug, Deserialize, Serialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct RequestedDownloads { - pub __files_to_merge: Option<Vec<Todo>>, - pub __finaldir: PathBuf, - pub __infojson_filename: PathBuf, - pub __postprocessors: Vec<Todo>, - pub __real_download: bool, - pub __write_download_archive: bool, - pub _filename: PathBuf, - pub _type: InfoType, - pub _version: Version, - pub abr: f64, - pub acodec: String, - pub aspect_ratio: Option<f64>, - pub asr: Option<u32>, - pub audio_channels: Option<u32>, - pub audio_ext: Option<String>, - pub chapters: Option<Vec<SponsorblockChapter>>, - pub duration: Option<f64>, - pub dynamic_range: Option<String>, - pub ext: String, - pub filename: PathBuf, - pub filepath: PathBuf, - pub filesize_approx: Option<u64>, - pub format: String, - pub format_id: String, - pub format_note: String, - pub fps: Option<f64>, - pub has_drm: Option<bool>, - pub height: Option<u32>, - pub http_headers: Option<HttpHeader>, - pub infojson_filename: PathBuf, - pub language: Option<String>, - pub manifest_url: Option<Url>, - pub protocol: String, - pub requested_formats: Option<Vec<Format>>, - pub resolution: String, - pub tbr: f64, - pub url: Option<Url>, - pub vbr: f64, - pub vcodec: String, - pub video_ext: Option<String>, - pub width: Option<u32>, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd)] -#[serde(deny_unknown_fields)] -pub struct Subtitle { - pub ext: SubtitleExt, - pub filepath: PathBuf, - pub filesize: Option<u64>, - pub fragment_base_url: Option<Url>, - pub fragments: Option<Vec<Fragment>>, - pub manifest_url: Option<Url>, - pub name: Option<String>, - pub protocol: Option<Todo>, - pub url: Url, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Clone, Copy)] -pub enum SubtitleExt { - #[serde(alias = "vtt")] - Vtt, - - #[serde(alias = "mp4")] - Mp4, - - #[serde(alias = "json")] - Json, - #[serde(alias = "json3")] - Json3, - - #[serde(alias = "ttml")] - Ttml, - - #[serde(alias = "srv1")] - Srv1, - #[serde(alias = "srv2")] - Srv2, - #[serde(alias = "srv3")] - Srv3, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd)] -#[serde(deny_unknown_fields)] -pub struct Caption { - pub ext: SubtitleExt, - pub filepath: Option<PathBuf>, - pub filesize: Option<u64>, - pub fragments: Option<Vec<SubtitleFragment>>, - pub fragment_base_url: Option<Url>, - pub manifest_url: Option<Url>, - pub name: Option<String>, - pub protocol: Option<String>, - pub url: String, - pub video_id: Option<String>, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd)] -#[serde(deny_unknown_fields)] -pub struct SubtitleFragment { - path: PathBuf, - duration: Option<f64>, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd)] -#[serde(deny_unknown_fields)] -pub struct Chapter { - pub end_time: f64, - pub start_time: f64, - pub title: String, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq)] -#[serde(deny_unknown_fields)] -pub struct SponsorblockChapter { - /// This is an utterly useless field, and should thus be ignored - pub _categories: Option<Vec<Vec<Value>>>, - - pub categories: Option<Vec<SponsorblockChapterCategory>>, - pub category: Option<SponsorblockChapterCategory>, - pub category_names: Option<Vec<String>>, - pub end_time: f64, - pub name: Option<String>, - pub r#type: Option<SponsorblockChapterType>, - pub start_time: f64, - pub title: String, -} - -pub fn get_none<'de, D, T>(_: D) -> Result<Option<T>, D::Error> -where - D: Deserializer<'de>, -{ - Ok(None) -} - -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Clone, Copy)] -#[serde(deny_unknown_fields)] -pub enum SponsorblockChapterType { - #[serde(alias = "skip")] - Skip, - - #[serde(alias = "chapter")] - Chapter, - - #[serde(alias = "poi")] - Poi, -} -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Clone, Copy)] -#[serde(deny_unknown_fields)] -pub enum SponsorblockChapterCategory { - #[serde(alias = "filler")] - Filler, - - #[serde(alias = "interaction")] - Interaction, - - #[serde(alias = "music_offtopic")] - MusicOfftopic, - - #[serde(alias = "poi_highlight")] - PoiHighlight, - - #[serde(alias = "preview")] - Preview, - - #[serde(alias = "sponsor")] - Sponsor, - - #[serde(alias = "selfpromo")] - SelfPromo, - - #[serde(alias = "chapter")] - Chapter, - - #[serde(alias = "intro")] - Intro, - - #[serde(alias = "outro")] - Outro, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd)] -#[serde(deny_unknown_fields)] -#[allow(missing_copy_implementations)] -pub struct HeatMapEntry { - pub start_time: f64, - pub end_time: f64, - pub value: f64, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd, Ord, Eq, Clone, Copy)] -#[serde(deny_unknown_fields)] -pub enum InfoType { - #[serde(alias = "playlist")] - Playlist, - - #[serde(alias = "url")] - Url, - - #[serde(alias = "video")] - Video, -} - -#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, PartialOrd, Ord)] -#[serde(deny_unknown_fields)] -pub struct Version { - pub current_git_head: Option<String>, - pub release_git_head: String, - pub repository: String, - pub version: String, -} - -#[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)] -#[serde(deny_unknown_fields)] -#[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: 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, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)] -#[serde(deny_unknown_fields)] -pub struct ThumbNail { - pub id: Option<String>, - pub preference: Option<i32>, - /// in the form of "[`height`]x[`width`]" - pub resolution: Option<String>, - pub url: Url, - pub width: Option<u32>, - pub height: Option<u32>, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd)] -#[serde(deny_unknown_fields)] -pub struct Format { - pub __needs_testing: Option<bool>, - pub __working: Option<bool>, - pub abr: Option<f64>, - pub acodec: Option<String>, - pub aspect_ratio: Option<f64>, - pub asr: Option<f64>, - pub audio_channels: Option<u32>, - pub audio_ext: Option<String>, - pub columns: Option<u32>, - pub container: Option<String>, - pub downloader_options: Option<DownloaderOptions>, - pub dynamic_range: Option<String>, - pub ext: String, - pub filepath: Option<PathBuf>, - pub filesize: Option<u64>, - pub filesize_approx: Option<u64>, - pub format: Option<String>, - pub format_id: String, - pub format_index: Option<String>, - pub format_note: Option<String>, - pub fps: Option<f64>, - pub fragment_base_url: Option<Todo>, - pub fragments: Option<Vec<Fragment>>, - pub has_drm: Option<bool>, - pub height: Option<u32>, - pub http_headers: Option<HttpHeader>, - pub is_dash_periods: Option<bool>, - pub language: Option<String>, - pub language_preference: Option<i32>, - pub manifest_stream_number: Option<u32>, - pub manifest_url: Option<Url>, - pub preference: Option<i32>, - pub protocol: Option<String>, - pub quality: Option<f64>, - pub resolution: Option<String>, - pub rows: Option<u32>, - pub source_preference: Option<i32>, - pub tbr: Option<f64>, - pub url: Url, - pub vbr: Option<f64>, - pub vcodec: String, - pub video_ext: Option<String>, - pub width: Option<u32>, -} - -#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, PartialOrd, Ord)] -#[serde(deny_unknown_fields)] -#[allow(missing_copy_implementations)] -pub struct DownloaderOptions { - http_chunk_size: u64, -} - -#[derive(Debug, Deserialize, Serialize, Eq, PartialEq, PartialOrd, Ord)] -#[serde(deny_unknown_fields)] -pub struct HttpHeader { - #[serde(alias = "User-Agent")] - pub user_agent: Option<String>, - - #[serde(alias = "Accept")] - pub accept: Option<String>, - - #[serde(alias = "X-Forwarded-For")] - pub x_forwarded_for: Option<String>, - - #[serde(alias = "Accept-Language")] - pub accept_language: Option<String>, - - #[serde(alias = "Sec-Fetch-Mode")] - pub sec_fetch_mode: Option<String>, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq, PartialOrd)] -#[serde(deny_unknown_fields)] -pub struct Fragment { - pub url: Option<Url>, - pub duration: Option<f64>, - pub path: Option<PathBuf>, -} - -impl InfoJson { - pub fn to_py_dict(self, py: Python<'_>) -> PyResult<Bound<'_, PyDict>> { - let output: Bound<'_, PyDict> = json_loads_str(py, self)?; - Ok(output) - } -} diff --git a/crates/yt_dlp/src/wrapper/yt_dlp_options.rs b/crates/yt_dlp/src/wrapper/yt_dlp_options.rs deleted file mode 100644 index c2a86df..0000000 --- a/crates/yt_dlp/src/wrapper/yt_dlp_options.rs +++ /dev/null @@ -1,62 +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 pyo3::{types::PyDict, Bound, PyResult, Python}; -use serde::Serialize; - -use crate::json_loads; - -#[derive(Serialize, Clone)] -pub struct YtDlpOptions { - pub playliststart: u32, - pub playlistend: u32, - pub noplaylist: bool, - pub extract_flat: ExtractFlat, - // pub extractor_args: ExtractorArgs, - // pub format: String, - // pub fragment_retries: u32, - // #[serde(rename(serialize = "getcomments"))] - // pub get_comments: bool, - // #[serde(rename(serialize = "ignoreerrors"))] - // pub ignore_errors: bool, - // pub retries: u32, - // #[serde(rename(serialize = "writeinfojson"))] - // pub write_info_json: bool, - // pub postprocessors: Vec<serde_json::Map<String, serde_json::Value>>, -} - -#[derive(Serialize, Copy, Clone)] -pub enum ExtractFlat { - #[serde(rename(serialize = "in_playlist"))] - InPlaylist, - - #[serde(rename(serialize = "discard_in_playlist"))] - DiscardInPlaylist, -} - -#[derive(Serialize, Clone)] -pub struct ExtractorArgs { - pub youtube: YoutubeExtractorArgs, -} - -#[derive(Serialize, Clone)] -pub struct YoutubeExtractorArgs { - comment_sort: Vec<String>, - max_comments: Vec<String>, -} - -impl YtDlpOptions { - pub fn to_py_dict(self, py: Python) -> PyResult<Bound<PyDict>> { - let string = serde_json::to_string(&self).expect("This should always work"); - - let output: Bound<PyDict> = json_loads(py, string)?; - Ok(output) - } -} diff --git a/flake.lock b/flake.lock index bb9cc15..6ebd85f 100644 --- a/flake.lock +++ b/flake.lock @@ -1,61 +1,27 @@ { "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "nixpkgs": { "locked": { - "lastModified": 1733940404, - "narHash": "sha256-Pj39hSoUA86ZePPF/UXiYHHM7hMIkios8TYG29kQT4g=", + "lastModified": 1751009538, + "narHash": "sha256-H5v0MWj6OuuX0ct9INuwJj5kLDA0jKozmUcd5XfR9a4=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "5d67ea6b4b63378b9c13be21e2ec9d1afc921713", + "rev": "ce34f10e7180bdae28e8a3b0ab2f8c0ad4383506", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-unstable", + "ref": "nixos-unstable-small", "repo": "nixpkgs", "type": "github" } }, "root": { "inputs": { - "flake-utils": "flake-utils", "nixpkgs": "nixpkgs", "treefmt-nix": "treefmt-nix" } }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - }, "treefmt-nix": { "inputs": { "nixpkgs": [ @@ -63,11 +29,11 @@ ] }, "locked": { - "lastModified": 1733761991, - "narHash": "sha256-s4DalCDepD22jtKL5Nw6f4LP5UwoMcPzPZgHWjAfqbQ=", + "lastModified": 1749194973, + "narHash": "sha256-eEy8cuS0mZ2j/r/FE0/LYBSBcIs/MKOIVakwHVuqTfk=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "0ce9d149d99bc383d1f2d85f31f6ebd146e46085", + "rev": "a05be418a1af1198ca0f63facb13c985db4cb3c5", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index abac232..770105e 100644 --- a/flake.nix +++ b/flake.nix @@ -11,9 +11,8 @@ description = "yt"; inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable-small"; - flake-utils.url = "github:numtide/flake-utils"; treefmt-nix = { url = "github:numtide/treefmt-nix"; inputs = { @@ -25,52 +24,53 @@ outputs = { self, nixpkgs, - flake-utils, treefmt-nix, - }: (flake-utils.lib.eachDefaultSystem (system: let + }: let + system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages."${system}"; - python = pkgs.python3.withPackages (ps: [ - ps.yt-dlp - blake3 - ]); - buildInputs = with pkgs; [ mpv-unwrapped.dev + libffi + openssl + zlib + curl.dev ]; nativeBuildInputs = with pkgs; [ llvmPackages_latest.clang-unwrapped.lib + pkg-config # Needed for the tests in `libmpv2` SDL2 ]; - yt = pkgs.callPackage ./package/package.nix {inherit blake3 tree-sitter-yts;}; - blake3 = pkgs.callPackage ./package/blake3/blake3.nix {}; + 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;}; in { - packages = { - inherit yt blake3 tree-sitter-yts; + packages."${system}" = { + inherit yt tree-sitter-yts; default = self.packages.${system}.yt; }; - checks = { + checks."${system}" = { inherit yt; formatting = treefmtEval.config.build.check self; }; - formatter = treefmtEval.config.build.wrapper; + formatter."${system}" = treefmtEval.config.build.wrapper; - devShells.default = pkgs.mkShell { + devShells."${system}".default = pkgs.mkShell { env = let clang_version = pkgs.lib.versions.major pkgs.llvmPackages_latest.clang-unwrapped.version; in { FFMPEG_LOCATION = "${pkgs.lib.getExe pkgs.ffmpeg}"; + + # These are needed for `libmpv` to compile. LIBCLANG_PATH = "${pkgs.llvmPackages_latest.clang-unwrapped.lib}/lib/libclang.so"; LIBCLANG_INCLUDE_PATH = "${pkgs.llvmPackages_latest.clang-unwrapped.lib}/lib/clang/${clang_version}/include"; C_INCLUDE_PATH = "${pkgs.glibc.dev}/include"; @@ -91,6 +91,7 @@ pkgs.cargo-flamegraph # Releng + pkgs.git-bug pkgs.reuse pkgs.cocogitto @@ -102,13 +103,15 @@ pkgs.sqlite-interactive # yt_dlp - python + pkgs.python3Packages.yt-dlp + pkgs.python3Packages.chardet pkgs.jq + pkgs.ffmpeg # Tree-sitter pkgs.nodejs pkgs.tree-sitter ]; }; - })); + }; } diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..1c7d836 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,149 @@ +# 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, + python3Packages, + python3, + ffmpeg, + openssl, + libffi, + zlib, + curl, + # NativeBuildInputs + makeWrapper, + llvmPackages_latest, + glibc, + sqlite, + fd, + pkg-config, + SDL2, + # Passthru + tree-sitter-yts, +}: +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" + "urllib3_polyfill.py" + ]) + || (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 + openssl + libffi + zlib + curl.dev + ]; + + 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}"; + + # 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; + + 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; + outputHashes = { + "ruff_python_ast-0.0.0" = "sha256-/CVpNBOBpvQhz7X80nUHC2x7ZxxCJH8O0WAABJKEriA="; + "rustpython-0.4.0" = "sha256-VRWmqwbuaxvI4cR3wWQZlYiiMAiRbqpKcsNpI7T+AP8="; + "rustpython-doc-0.3.0" = "sha256-34ERuLFKzUD9Xmf1zlafe42GLWZfUlw17ejf/NN6yH4="; + }; + }; + + postInstall = let + collectDeps = pkg: let + next = pkg.propagatedBuildInputs or []; + in + [pkg] + ++ next + ++ (lib.flatten (builtins.map collectDeps next)); + + loadPythonDep = der: "${der}/lib/python${lib.versions.majorMinor python3.version}/site-packages"; + + pythonPath = builtins.concatStringsSep ":" (lib.lists.unique ( + builtins.map loadPythonDep ( + (collectDeps python3Packages.yt-dlp) + ++ [ + # HACK(@bpeetz): These packages are not picked up in the traversal up top. <2025-06-16> + python3Packages.chardet + ] + ) + )); + in '' + 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 \ + --set PYTHONPATH ${pythonPath} + ''; +}) diff --git a/old/url.old/downloader.rs b/old/url.old/downloader.rs deleted file mode 100644 index b30b03c..0000000 --- a/old/url.old/downloader.rs +++ /dev/null @@ -1,224 +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 std::{ - fs::{self, canonicalize}, - io::{stderr, stdout, Read}, - mem, - os::unix::fs::symlink, - path::PathBuf, - process::Command, - sync::mpsc::{self, Receiver, Sender}, - thread::{self, JoinHandle}, -}; - -use anyhow::{bail, Context, Result}; -use log::{debug, error, warn}; -use url::Url; - -use crate::constants::{status_path, CONCURRENT, DOWNLOAD_DIR, MPV_FLAGS, YT_DLP_FLAGS}; - -#[derive(Debug)] -pub struct Downloadable { - pub url: Url, - pub id: Option<u32>, -} - -impl std::fmt::Display for Downloadable { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - write!( - f, - "{}|{}", - self.url.as_str().replace('|', ";"), - self.id.unwrap_or(0), - ) - } -} - -pub struct Downloader { - sent: usize, - download_thread: JoinHandle<Result<()>>, - orx: Receiver<(PathBuf, Option<u32>)>, - itx: Option<Sender<Downloadable>>, - playspec: Vec<Downloadable>, -} - -impl Downloader { - pub fn new(mut playspec: Vec<Downloadable>) -> anyhow::Result<Downloader> { - let (itx, irx): (Sender<Downloadable>, Receiver<Downloadable>) = mpsc::channel(); - let (otx, orx) = mpsc::channel(); - - let jh = thread::spawn(move || -> Result<()> { - while let Ok(pt) = irx.recv() { - debug!("Got '{}' to be downloaded", pt); - let path = download_url(&pt.url) - .with_context(|| format!("Failed to download url: '{}'", &pt.url))?; - otx.send((path, pt.id)).expect("Should not be dropped"); - } - debug!("Finished Downloading everything"); - Ok(()) - }); - - playspec.reverse(); - let mut output = Downloader { - sent: 0, - download_thread: jh, - orx, - itx: Some(itx), - playspec, - }; - - if output.playspec.len() <= CONCURRENT as usize { - output.add(output.playspec.len() as u32)?; - } else { - output.add(CONCURRENT)?; - } - Ok(output) - } - - pub fn add(&mut self, number_to_add: u32) -> Result<()> { - debug!("Adding {} to be downloaded concurrently", number_to_add); - for _ in 0..number_to_add { - let pt = self.playspec.pop().expect("This call should be guarded"); - self.itx.as_ref().expect("Should still be valid").send(pt)?; - self.sent += 1; - } - Ok(()) - } - - /// Return the next video already downloaded, will block until the download is complete - pub fn next(&mut self) -> Option<(PathBuf, Option<u32>)> { - debug!("Requesting next output"); - match self.orx.recv() { - Ok(ok) => { - debug!("Output downloaded to: {}", ok.0.display()); - if !self.playspec.is_empty() { - self.add(1).ok()?; - } else { - debug!( - "Done sending videos to be downloaded, downoladed: {} videos", - self.sent - ); - let itx = mem::take(&mut self.itx); - drop(itx) - } - debug!("Returning: {}|{}", ok.0.display(), ok.1.unwrap_or(0)); - Some(ok) - } - Err(err) => { - debug!("Received error while listening: {}", err); - None - } - } - } - - pub fn drop(self) -> anyhow::Result<()> { - // Check that we really downloaded everything - assert_eq!(self.playspec.len(), 0); - match self.download_thread.join() { - Ok(ok) => ok, - Err(err) => panic!("Failed to join downloader thread: '{:#?}'", err), - } - } - - pub fn consume(mut self) -> anyhow::Result<()> { - while let Some((path, id)) = self.next() { - debug!("Next path to play is: '{}'", path.display()); - let mut info_json = canonicalize(&path).context("Failed to canoncialize path")?; - info_json.set_extension("info.json"); - - if status_path()?.is_symlink() { - fs::remove_file(status_path()?).context("Failed to delete old status file")?; - } else if !status_path()?.exists() { - debug!( - "The status path at '{}' does not exists", - status_path()?.display() - ); - } else { - bail!( - "The status path ('{}') is not a symlink but exists!", - status_path()?.display() - ); - } - - symlink(info_json, status_path()?).context("Failed to symlink")?; - - let mut mpv = Command::new("mpv"); - mpv.stdout(stdout()); - mpv.stderr(stderr()); - mpv.args(MPV_FLAGS); - // TODO: Set the title to the name of the video, not the path <2024-02-09> - // mpv.arg(format!("--title=")) - mpv.arg(&path); - - let status = mpv.status().context("Failed to run mpv")?; - if status.success() { - fs::remove_file(&path)?; - if let Some(id) = id { - println!("\x1b[32;1mMarking {} as watched!\x1b[0m", id); - let mut ytcc = std::process::Command::new("ytcc"); - ytcc.stdout(stdout()); - ytcc.stderr(stderr()); - ytcc.args(["mark"]); - ytcc.arg(id.to_string()); - let status = ytcc.status().context("Failed to run ytcc")?; - if let Some(code) = status.code() { - if code != 0 { - bail!("Ytcc failed with status: {}", code); - } - } - } - debug!("mpv exited with: '{}'", status); - } else { - warn!("mpv exited with: '{}'", status); - } - } - self.drop()?; - Ok(()) - } -} - -fn download_url(url: &Url) -> Result<PathBuf> { - let output_file = tempfile::NamedTempFile::new().context("Failed to create tempfile")?; - output_file - .as_file() - .set_len(0) - .context("Failed to truncate temp-file")?; - if !Into::<PathBuf>::into(DOWNLOAD_DIR).exists() { - fs::create_dir_all(DOWNLOAD_DIR) - .with_context(|| format!("Failed to create download dir at: {}", DOWNLOAD_DIR))? - } - let mut yt_dlp = Command::new("yt-dlp"); - yt_dlp.current_dir(DOWNLOAD_DIR); - yt_dlp.stdout(stdout()); - yt_dlp.stderr(stderr()); - yt_dlp.args(YT_DLP_FLAGS); - yt_dlp.args([ - "--output", - "%(channel)s/%(title)s.%(ext)s", - url.as_str(), - "--print-to-file", - "after_move:filepath", - ]); - yt_dlp.arg(output_file.path().as_os_str()); - - let status = yt_dlp.status().context("Failed to run yt-dlp")?; - if !status.success() { - error!("yt-dlp execution failed with error: '{}'", status); - } - - let mut path = String::new(); - output_file - .as_file() - .read_to_string(&mut path) - .context("Failed to read output file temp file")?; - let path = path.trim(); - Ok(path.into()) -} diff --git a/old/url.old/mod.rs b/old/url.old/mod.rs deleted file mode 100644 index cff6310..0000000 --- a/old/url.old/mod.rs +++ /dev/null @@ -1,25 +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 anyhow::Result; -use url::Url; - -use self::downloader::{Downloadable, Downloader}; - -mod downloader; - -pub fn download(urls: Vec<Url>) -> Result<()> { - let downloadables = urls - .into_iter() - .map(|url| Downloadable { url, id: None }) - .collect(); - let downloader = Downloader::new(downloadables)?; - downloader.consume() -} diff --git a/old/ytc/main.rs b/old/ytc/main.rs deleted file mode 100644 index e1359f9..0000000 --- a/old/ytc/main.rs +++ /dev/null @@ -1,85 +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 std::{env, process::Command as StdCmd}; - -use anyhow::{bail, Context, Result}; -use clap::Parser; -use log::debug; -use url::Url; -use yt::{ - downloader::{Downloadable, Downloader}, - YtccListData, -}; - -use crate::args::{Args, Command}; - -fn main() -> Result<()> { - let args = Args::parse(); - cli_log::init_cli_log!(); - - let playspec: Vec<Downloadable> = match args.subcommand { - Command::Id { ids } => { - let mut output = Vec::with_capacity(ids.len()); - for id in ids { - debug!("Adding {}", id); - let mut ytcc = StdCmd::new("ytcc"); - ytcc.args([ - "--output", - "json", - "list", - "--watched", - "--unwatched", - "--attributes", - "url", - "--ids", - id.to_string().as_str(), - ]); - let json = serde_json::from_slice::<Vec<YtccListData>>( - &ytcc.output().context("Failed to get url from id")?.stdout, - ) - .context("Failed to deserialize json output")?; - - if json.is_empty() { - bail!("Could not find a video with id: {}", id); - } - assert_eq!(json.len(), 1); - let json = json.first().expect("Has only one element"); - - debug!("Id resolved to: '{}'", &json.url); - - output.push(Downloadable { - url: Url::parse(&json.url)?, - id: Some(json.id), - }) - } - output - } - Command::Url { urls } => { - let mut output = Vec::with_capacity(urls.len()); - for url in urls { - output.push(Downloadable { - url: Url::parse(&url).context("Failed to parse url")?, - id: None, - }) - } - output - } - }; - - debug!("Initializing downloader"); - let downloader = Downloader::new(playspec)?; - - downloader - .consume() - .context("Failed to consume downloader")?; - - Ok(()) -} diff --git a/old/yts/main.rs b/old/yts/main.rs deleted file mode 100644 index cd4ef35..0000000 --- a/old/yts/main.rs +++ /dev/null @@ -1,99 +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 anyhow::{bail, Context, Result}; -use clap::Parser; -use std::{ - env, - io::{BufRead, BufReader, Write}, - process::Command as StdCmd, -}; -use tempfile::NamedTempFile; -use yt::{constants::HELP_STR, filter_line, YtccListData}; - -use crate::args::{Args, Command, OrderCommand}; - -fn main() -> Result<()> { - let args = Args::parse(); - cli_log::init_cli_log!(); - - let ordering = match args.subcommand.unwrap_or(Command::Order { - command: OrderCommand::Date { - desc: true, - asc: false, - }, - }) { - Command::Order { command } => match command { - OrderCommand::Date { desc, asc } => { - if desc { - vec!["--order-by".into(), "publish_date".into(), "desc".into()] - } else if asc { - vec!["--order-by".into(), "publish_date".into(), "asc".into()] - } else { - vec!["--order-by".into(), "publish_date".into(), "desc".into()] - } - } - OrderCommand::Raw { value } => [vec!["--order-by".into()], value].concat(), - }, - }; - - let json_map = { - let mut ytcc = StdCmd::new("ytcc"); - ytcc.args(["--output", "json", "list"]); - ytcc.args(ordering); - - serde_json::from_slice::<Vec<YtccListData>>( - &ytcc.output().context("Failed to json from ytcc")?.stdout, - ) - .context("Failed to deserialize json output")? - }; - - let mut edit_file = NamedTempFile::new().context("Failed to get tempfile")?; - - json_map.iter().for_each(|line| { - let line = line.to_string(); - edit_file - .write_all(line.as_bytes()) - .expect("This write should not fail"); - }); - - write!(&edit_file, "{}", HELP_STR)?; - edit_file.flush().context("Failed to flush edit file")?; - - let read_file = edit_file.reopen()?; - - let mut nvim = StdCmd::new("nvim"); - nvim.arg(edit_file.path()); - - let status = nvim.status().context("Falied to run nvim")?; - if !status.success() { - bail!("Nvim exited with error status: {}", status) - } - - let mut watching = Vec::new(); - let reader = BufReader::new(&read_file); - for line in reader.lines() { - let line = line.context("Failed to read line")?; - - if let Some(downloadable) = - filter_line(&line).with_context(|| format!("Failed to process line: '{}'", line))? - { - watching.push(downloadable); - } - } - - let watching: String = watching - .iter() - .map(|d| d.to_string()) - .collect::<Vec<String>>() - .join("\n"); - println!("{}", &watching); - Ok(()) -} diff --git a/package/blake3/add_cargo_lock.patch b/package/blake3/add_cargo_lock.patch deleted file mode 100644 index 19a5d1d..0000000 --- a/package/blake3/add_cargo_lock.patch +++ /dev/null @@ -1,431 +0,0 @@ -From 45fd97400c01f39f841f84d43f1d28f8102cd927 Mon Sep 17 00:00:00 2001 -From: Benedikt Peetz <benedikt.peetz@b-peetz.de> -Date: Thu, 22 Aug 2024 11:25:24 +0200 -Subject: [PATCH] build(cargo.lock): Add - ---- - Cargo.lock | 412 +++++++++++++++++++++++++++++++++++++++++++++++++++++ - 1 file changed, 412 insertions(+) - create mode 100644 Cargo.lock - -diff --git a/Cargo.lock b/Cargo.lock -new file mode 100644 -index 0000000..98b4b7a ---- /dev/null -+++ b/Cargo.lock -@@ -0,0 +1,412 @@ -+# This file is automatically @generated by Cargo. -+# It is not intended for manual editing. -+version = 3 -+ -+[[package]] -+name = "arrayref" -+version = "0.3.8" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "9d151e35f61089500b617991b791fc8bfd237ae50cd5950803758a179b41e67a" -+ -+[[package]] -+name = "arrayvec" -+version = "0.7.6" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -+ -+[[package]] -+name = "autocfg" -+version = "1.3.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" -+ -+[[package]] -+name = "bitflags" -+version = "2.6.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" -+ -+[[package]] -+name = "blake3" -+version = "0.4.1" -+dependencies = [ -+ "blake3 1.5.4", -+ "hex", -+ "pyo3", -+ "rayon", -+] -+ -+[[package]] -+name = "blake3" -+version = "1.5.4" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "d82033247fd8e890df8f740e407ad4d038debb9eb1f40533fffb32e7d17dc6f7" -+dependencies = [ -+ "arrayref", -+ "arrayvec", -+ "cc", -+ "cfg-if", -+ "constant_time_eq", -+ "memmap2", -+ "rayon-core", -+] -+ -+[[package]] -+name = "cc" -+version = "1.1.13" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "72db2f7947ecee9b03b510377e8bb9077afa27176fdbff55c51027e976fdcc48" -+dependencies = [ -+ "shlex", -+] -+ -+[[package]] -+name = "cfg-if" -+version = "1.0.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -+ -+[[package]] -+name = "constant_time_eq" -+version = "0.3.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" -+ -+[[package]] -+name = "crossbeam-deque" -+version = "0.8.5" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" -+dependencies = [ -+ "crossbeam-epoch", -+ "crossbeam-utils", -+] -+ -+[[package]] -+name = "crossbeam-epoch" -+version = "0.9.18" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -+dependencies = [ -+ "crossbeam-utils", -+] -+ -+[[package]] -+name = "crossbeam-utils" -+version = "0.8.20" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" -+ -+[[package]] -+name = "either" -+version = "1.13.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" -+ -+[[package]] -+name = "heck" -+version = "0.4.1" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -+ -+[[package]] -+name = "hex" -+version = "0.4.3" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -+ -+[[package]] -+name = "indoc" -+version = "2.0.5" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" -+ -+[[package]] -+name = "libc" -+version = "0.2.158" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" -+ -+[[package]] -+name = "lock_api" -+version = "0.4.12" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" -+dependencies = [ -+ "autocfg", -+ "scopeguard", -+] -+ -+[[package]] -+name = "memmap2" -+version = "0.9.4" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322" -+dependencies = [ -+ "libc", -+] -+ -+[[package]] -+name = "memoffset" -+version = "0.9.1" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -+dependencies = [ -+ "autocfg", -+] -+ -+[[package]] -+name = "once_cell" -+version = "1.19.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" -+ -+[[package]] -+name = "parking_lot" -+version = "0.12.3" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" -+dependencies = [ -+ "lock_api", -+ "parking_lot_core", -+] -+ -+[[package]] -+name = "parking_lot_core" -+version = "0.9.10" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" -+dependencies = [ -+ "cfg-if", -+ "libc", -+ "redox_syscall", -+ "smallvec", -+ "windows-targets", -+] -+ -+[[package]] -+name = "portable-atomic" -+version = "1.7.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265" -+ -+[[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 = "pyo3" -+version = "0.20.3" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "53bdbb96d49157e65d45cc287af5f32ffadd5f4761438b527b055fb0d4bb8233" -+dependencies = [ -+ "cfg-if", -+ "indoc", -+ "libc", -+ "memoffset", -+ "parking_lot", -+ "portable-atomic", -+ "pyo3-build-config", -+ "pyo3-ffi", -+ "pyo3-macros", -+ "unindent", -+] -+ -+[[package]] -+name = "pyo3-build-config" -+version = "0.20.3" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "deaa5745de3f5231ce10517a1f5dd97d53e5a2fd77aa6b5842292085831d48d7" -+dependencies = [ -+ "once_cell", -+ "target-lexicon", -+] -+ -+[[package]] -+name = "pyo3-ffi" -+version = "0.20.3" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "62b42531d03e08d4ef1f6e85a2ed422eb678b8cd62b762e53891c05faf0d4afa" -+dependencies = [ -+ "libc", -+ "pyo3-build-config", -+] -+ -+[[package]] -+name = "pyo3-macros" -+version = "0.20.3" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "7305c720fa01b8055ec95e484a6eca7a83c841267f0dd5280f0c8b8551d2c158" -+dependencies = [ -+ "proc-macro2", -+ "pyo3-macros-backend", -+ "quote", -+ "syn", -+] -+ -+[[package]] -+name = "pyo3-macros-backend" -+version = "0.20.3" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "7c7e9b68bb9c3149c5b0cade5d07f953d6d125eb4337723c4ccdb665f1f96185" -+dependencies = [ -+ "heck", -+ "proc-macro2", -+ "pyo3-build-config", -+ "quote", -+ "syn", -+] -+ -+[[package]] -+name = "quote" -+version = "1.0.36" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" -+dependencies = [ -+ "proc-macro2", -+] -+ -+[[package]] -+name = "rayon" -+version = "1.10.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" -+dependencies = [ -+ "either", -+ "rayon-core", -+] -+ -+[[package]] -+name = "rayon-core" -+version = "1.12.1" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" -+dependencies = [ -+ "crossbeam-deque", -+ "crossbeam-utils", -+] -+ -+[[package]] -+name = "redox_syscall" -+version = "0.5.3" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" -+dependencies = [ -+ "bitflags", -+] -+ -+[[package]] -+name = "scopeguard" -+version = "1.2.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -+ -+[[package]] -+name = "shlex" -+version = "1.3.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -+ -+[[package]] -+name = "smallvec" -+version = "1.13.2" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" -+ -+[[package]] -+name = "syn" -+version = "2.0.75" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "f6af063034fc1935ede7be0122941bafa9bacb949334d090b77ca98b5817c7d9" -+dependencies = [ -+ "proc-macro2", -+ "quote", -+ "unicode-ident", -+] -+ -+[[package]] -+name = "target-lexicon" -+version = "0.12.16" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" -+ -+[[package]] -+name = "unicode-ident" -+version = "1.0.12" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" -+ -+[[package]] -+name = "unindent" -+version = "0.2.3" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "c7de7d73e1754487cb58364ee906a499937a0dfabd86bcb980fa99ec8c8fa2ce" -+ -+[[package]] -+name = "windows-targets" -+version = "0.52.6" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -+dependencies = [ -+ "windows_aarch64_gnullvm", -+ "windows_aarch64_msvc", -+ "windows_i686_gnu", -+ "windows_i686_gnullvm", -+ "windows_i686_msvc", -+ "windows_x86_64_gnu", -+ "windows_x86_64_gnullvm", -+ "windows_x86_64_msvc", -+] -+ -+[[package]] -+name = "windows_aarch64_gnullvm" -+version = "0.52.6" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -+ -+[[package]] -+name = "windows_aarch64_msvc" -+version = "0.52.6" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -+ -+[[package]] -+name = "windows_i686_gnu" -+version = "0.52.6" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -+ -+[[package]] -+name = "windows_i686_gnullvm" -+version = "0.52.6" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -+ -+[[package]] -+name = "windows_i686_msvc" -+version = "0.52.6" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -+ -+[[package]] -+name = "windows_x86_64_gnu" -+version = "0.52.6" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -+ -+[[package]] -+name = "windows_x86_64_gnullvm" -+version = "0.52.6" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -+ -+[[package]] -+name = "windows_x86_64_msvc" -+version = "0.52.6" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" --- -2.45.2 - diff --git a/package/blake3/blake3.nix b/package/blake3/blake3.nix deleted file mode 100644 index 74a393b..0000000 --- a/package/blake3/blake3.nix +++ /dev/null @@ -1,38 +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>. -{ - python3Packages, - rustPlatform, - fetchFromGitHub, -}: -python3Packages.buildPythonPackage rec { - pname = "blake3"; - version = "0.4.1"; - - src = fetchFromGitHub { - owner = "oconnor663"; - repo = "blake3-py"; - rev = version; - hash = "sha256-Ju40ea8IQMOPg9BiN47BMmr/WU8HptbqqzVI+jNGpA8="; - }; - - patches = [ - ./add_cargo_lock.patch - ]; - - cargoDeps = rustPlatform.fetchCargoTarball { - inherit src patches; - hash = "sha256-GwyGSdmJTgsHWfcS2n2FCFrlwRcuANM8/WteYTTyY6o="; - }; - - format = "pyproject"; - - nativeBuildInputs = with rustPlatform; [cargoSetupHook maturinBuildHook]; -} diff --git a/package/package.nix b/package/package.nix deleted file mode 100644 index a5c33a7..0000000 --- a/package/package.nix +++ /dev/null @@ -1,79 +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, - blake3, - tree-sitter-yts, -}: let - version = "0.1.0"; - - src = ./..; - - buildInputs = [ - (python3.withPackages (ps: [ps.yt-dlp blake3])) - mpv-unwrapped.dev - ffmpeg - ]; -in - rustPlatform.buildRustPackage { - inherit version src buildInputs; - pname = "yt"; - - nativeBuildInputs = [ - makeWrapper - sqlite - ]; - - 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.sqlite"; - 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 blake3 tree-sitter-yts; - }; - - cargoLock = { - lockFile = ../Cargo.lock; - }; - - postBuild = '' - install -m755 ./python_update/raw_update.py -D "$out/bin/raw_update.py" - patchShebangs "$out/bin/raw_update.py" - ''; - - postInstall = '' - wrapProgram $out/bin/yt \ - --prefix PATH : ${lib.makeBinPath buildInputs}:$out/bin - ''; - } diff --git a/python_update/raw_update.py b/python_update/raw_update.py deleted file mode 100755 index 28a2bac..0000000 --- a/python_update/raw_update.py +++ /dev/null @@ -1,179 +0,0 @@ -#!/usr/bin/env python - -# 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 has been take from the `ytcc` updater code (at `8893bc98428cb78d458a9cf3ded03f519d86a46b`). -# Source URL: https://github.com/woefe/ytcc/commit/8893bc98428cb78d458a9cf3ded03f519d86a46b - -from blake3 import blake3 -from dataclasses import dataclass -from functools import partial -from typing import Any, Iterable, Optional, Tuple, TypeVar -import asyncio -import itertools -import json -import logging -import sys - -import yt_dlp - - -@dataclass(frozen=True) -class Playlist: - name: str - url: str - reverse: bool - - -@dataclass(frozen=True) -class Video: - url: str - title: str - description: str - publish_date: float - watch_date: Optional[float] - duration: float - thumbnail_url: Optional[str] - extractor_hash: str - - @property - def watched(self) -> bool: - return self.watch_date is not None - - -logger = logging.getLogger("yt") -logging.basicConfig(encoding="utf-8", level=int(sys.argv[3])) - -_ytdl_logger = logging.getLogger("yt_dlp") -_ytdl_logger.propagate = False -_ytdl_logger.addHandler(logging.NullHandler()) -YTDL_COMMON_OPTS = {"logger": _ytdl_logger} - -T = TypeVar("T") - - -def take(amount: int, iterable: Iterable[T]) -> Iterable[T]: - """Take the first elements of an iterable. - - If the given iterable has less elements than the given amount, the returned iterable has the - same amount of elements as the given iterable. Otherwise the returned iterable has `amount` - elements. - - :param amount: The number of elements to take - :param iterable: The iterable to take elements from - :return: The first elements of the given iterable - """ - for _, elem in zip(range(amount), iterable): - yield elem - - -class Fetcher: - def __init__(self, max_backlog): - self.max_items = max_backlog - self.ydl_opts = { - **YTDL_COMMON_OPTS, - "playliststart": 1, - "playlistend": max_backlog, - "noplaylist": False, - "extractor_args": {"youtubetab": {"approximate_date": [""]}}, - } - - async def get_unprocessed_entries( - self, url: str, hashes: Iterable[str] - ) -> Iterable[Tuple[str, str, Any]]: - result = [] - with yt_dlp.YoutubeDL(self.ydl_opts) as ydl: - logger.info("Checking playlist '%s'...", url) - try: - loop = asyncio.get_event_loop() - info = await loop.run_in_executor( - None, - partial(ydl.extract_info, url, download=False, process=False), - ) - except yt_dlp.DownloadError as download_error: - logger.error( - "Failed to get playlist '%s'. Error was: '%s'", - url, - download_error, - ) - else: - entries = info.get("entries", []) - for entry in take(self.max_items, entries): - logger.debug(json.dumps(entry)) - id = str.encode(yt_dlp.utils.unsmuggle_url(entry["id"])[0]) - ehash = blake3(id).hexdigest() - if ehash not in hashes: - result.append((url, entry)) - return result - - def _process_ie(self, entry): - with yt_dlp.YoutubeDL(self.ydl_opts) as ydl: - processed = ydl.process_ie_result(entry, False) - - return { - "description": processed.get("description"), - "duration": processed.get("duration"), - "upload_date": processed.get("upload_date"), - "thumbnails": processed.get("thumbnails"), - "thumbnail": processed.get("thumbnail"), - "title": processed.get("title"), - "webpage_url": processed.get("webpage_url"), - "id": processed.get("id"), - } - - async def process_entry(self, url: str, entry: Any) -> Optional[Any]: - try: - loop = asyncio.get_event_loop() - processed = await loop.run_in_executor(None, self._process_ie, entry) - except yt_dlp.DownloadError as download_error: - logger.error( - "Failed to get a video of playlist '%s'. Error was: '%s'", - url, - download_error, - ) - return None - else: - print(json.dumps({url: processed})) - - -class Updater: - def __init__(self, max_backlog=20): - self.max_items = max_backlog - self.fetcher = Fetcher(max_backlog) - self.hashes = None - - async def update_url(self, url: str): - logger.info(f"Updating {url}...") - new_entries = await self.fetcher.get_unprocessed_entries(url, self.hashes) - - await asyncio.gather( - *itertools.starmap(self.fetcher.process_entry, new_entries) - ) - - async def do_update(self, urls: Iterable[str]): - await asyncio.gather(*map(self.update_url, urls)) - - def update(self, urls: Iterable[str], hashes: Iterable[str]): - self.hashes = hashes - asyncio.run(self.do_update(urls)) - - -def update(): - max_backlog = int(sys.argv[1]) - subscriptions_number = int(sys.argv[2]) - u = Updater(max_backlog=max_backlog) - u.update( - sys.argv[4 : (4 + subscriptions_number)], sys.argv[(4 + subscriptions_number) :] - ) - - -logger.debug(sys.argv) -update() diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..d13b0eb --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,29 @@ +# 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>. + +# https://github.com/rust-lang/rustfmt/blob/master/Configurations.md#imports_granularity +imports_granularity = "Crate" +group_imports = "StdExternalCrate" + +# https://github.com/rust-lang/rustfmt/blob/master/Configurations.md#format_code_in_doc_comments +format_code_in_doc_comments = true + +style_edition = "2024" +edition = "2024" + +wrap_comments = true +format_strings = true + +error_on_line_overflow = true +error_on_unformatted = true + +format_macro_matchers = true + +reorder_impl_items = true diff --git a/scripts/cprh.sh b/scripts/cprh.sh deleted file mode 100755 index 96c85f9..0000000 --- a/scripts/cprh.sh +++ /dev/null @@ -1,67 +0,0 @@ -#! /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 -# -# 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>. - -die() { - echo "$@" 1>&2 - exit 1 -} - -help() { - cat <<EOF -A copyright header managment tool. - -USAGE: - cprh.sh [OPTIONS] contribute NAME EMAIL FILE.. - -OPTIONS: - --help | -h - Display this help and exit. - -ARGUMENTS: - NAME := [[git config user.name]] - Your name. - - NAME := [[git config user.email]] - Your email address. - - FILE := [[git diff --name-only --cached]] - The file you want to change. This can be given multiple times. -EOF -} - -for arg in "$@"; do - case "$arg" in - "--help" | "-h") - help - exit 0 - ;; - *) - echo "'$1' is not a recognized option. See --help for more!" 1>&2 - exit 1 - ;; - esac -done - -user_name="$1" -[ -z "$user_name" ] && die "No NAME set! See --help for more" - -user_email="$2" -[ -z "$user_email" ] && die "No EMAIL set! See --help for more" -shift 2 - -styleOne="" -styleTwo="" -[ "$COMMENT_STYLE" ] && styleOne="--style" && styleTwo="$COMMENT_STYLE" - -# The styleTwo must be unquoted to avoid adding empty args to reuse -# shellcheck disable=2086 -reuse annotate --copyright "$user_name <$user_email>" --copyright-prefix string-c --template default --multi-line $styleOne $styleTwo diff --git a/scripts/mkdb.sh b/scripts/mkdb.sh index 9ce5dd8..6674841 100755 --- a/scripts/mkdb.sh +++ b/scripts/mkdb.sh @@ -11,11 +11,19 @@ # If not, see <https://www.gnu.org/licenses/gpl-3.0.txt>. root="$(dirname "$0")/.." -db="$root/target/database.sqlite" +db="${DATABASE_URL#sqlite://}" [ -f "$db" ] && rm "$db" [ -d "$root/target" ] || mkdir "$root/target" -sqlite3 "$db" <"$root/yt/src/storage/video_database/schema.sql" +fd . "$root/crates/yt/src/storage/migrate/sql" | while read -r sql_file; do + echo "Applying sql migration file: $(basename "$sql_file").." + { + # NOTE(@bpeetz): The wrapping in a transaction is needed to simulate the rust code. <2025-05-07> + echo "BEGIN TRANSACTION;" + cat "$sql_file" + echo "COMMIT TRANSACTION;" + } | sqlite3 "$db" +done # vim: ft=sh diff --git a/yt/src/comments/comment.rs b/yt/src/comments/comment.rs deleted file mode 100644 index c998cdb..0000000 --- a/yt/src/comments/comment.rs +++ /dev/null @@ -1,64 +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 yt_dlp::wrapper::info_json::Comment; - -#[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/yt/src/download/download_options.rs b/yt/src/download/download_options.rs deleted file mode 100644 index 1dc0bf2..0000000 --- a/yt/src/download/download_options.rs +++ /dev/null @@ -1,119 +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_json::{json, Value}; - -use crate::{app::App, storage::video_database::YtDlpOptions}; - -// { -// "ratelimit": conf.ratelimit if conf.ratelimit > 0 else None, -// "retries": conf.retries, -// "merge_output_format": conf.merge_output_format, -// "restrictfilenames": conf.restrict_filenames, -// "ignoreerrors": False, -// "postprocessors": [{"key": "FFmpegMetadata"}], -// "logger": _ytdl_logger -// } - -#[must_use] -pub fn download_opts(app: &App, additional_opts: &YtDlpOptions) -> serde_json::Map<String, Value> { - match json!({ - "extract_flat": false, - "extractor_args": { - "youtube": { - "comment_sort": [ - "top" - ], - "max_comments": [ - "150", - "all", - "100" - ] - } - }, - "ffmpeg_location": env!("FFMPEG_LOCATION"), - "format": "bestvideo[height<=?1080]+bestaudio/best", - "fragment_retries": 10, - "getcomments": true, - "ignoreerrors": false, - "retries": 10, - - "writeinfojson": true, - "writeannotations": true, - "writesubtitles": true, - "writeautomaticsub": true, - - "outtmpl": { - "default": app.config.paths.download_dir.join("%(channel)s/%(title)s.%(ext)s"), - "chapter": "%(title)s - %(section_number)03d %(section_title)s [%(id)s].%(ext)s" - }, - "compat_opts": {}, - "forceprint": {}, - "print_to_file": {}, - "windowsfilenames": false, - "restrictfilenames": false, - "trim_file_names": false, - "postprocessors": [ - { - "api": "https://sponsor.ajay.app", - "categories": [ - "interaction", - "intro", - "music_offtopic", - "sponsor", - "outro", - "poi_highlight", - "preview", - "selfpromo", - "filler", - "chapter" - ], - "key": "SponsorBlock", - "when": "after_filter" - }, - { - "force_keyframes": false, - "key": "ModifyChapters", - "remove_chapters_patterns": [], - "remove_ranges": [], - "remove_sponsor_segments": [ - "sponsor" - ], - "sponsorblock_chapter_title": "[SponsorBlock]: %(category_names)l" - }, - { - "add_chapters": true, - "add_infojson": null, - "add_metadata": false, - "key": "FFmpegMetadata" - }, - { - "key": "FFmpegConcat", - "only_multi_video": true, - "when": "playlist" - } - ] - }) { - Value::Object(mut obj) => { - obj.insert( - "subtitleslangs".to_owned(), - Value::Array( - additional_opts - .subtitle_langs - .split(',') - .map(|val| Value::String(val.to_owned())) - .collect::<Vec<_>>(), - ), - ); - obj - } - _ => unreachable!("This is an object"), - } -} diff --git a/yt/src/select/cmds.rs b/yt/src/select/cmds.rs deleted file mode 100644 index 0d06bd5..0000000 --- a/yt/src/select/cmds.rs +++ /dev/null @@ -1,151 +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 crate::{ - app::App, - cli::{SelectCommand, SharedSelectionCommandArgs}, - download::download_options::download_opts, - storage::video_database::{ - self, - getters::get_video_by_hash, - setters::{add_video, set_video_options, set_video_status}, - VideoOptions, VideoStatus, - }, - update::video_entry_to_video, - videos::display::format_video::FormatVideo, -}; - -use anyhow::{bail, Context, Result}; -use futures::future::join_all; -use yt_dlp::wrapper::info_json::InfoType; - -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 } => { - for url in urls { - async fn add_entry( - app: &App, - entry: yt_dlp::wrapper::info_json::InfoJson, - ) -> Result<()> { - let video = video_entry_to_video(entry, None)?; - add_video(app, video.clone()).await?; - - println!( - "{}", - (&video.to_formatted_video(app).await?.colorize()).to_line_display() - ); - - Ok(()) - } - - let opts = download_opts( - app, - &video_database::YtDlpOptions { - subtitle_langs: String::new(), - }, - ); - let entry = yt_dlp::extract_info(&opts, &url, false, true) - .await - .with_context(|| format!("Failed to fetch entry for url: '{url}'"))?; - - match entry._type { - Some(InfoType::Video) => { - add_entry(app, entry).await?; - } - Some(InfoType::Playlist) => { - if let Some(mut entries) = entry.entries { - if !entries.is_empty() { - // Pre-warm the cache - add_entry(app, entries.remove(0)).await?; - - let futures: Vec<_> = entries - .into_iter() - .map(|entry| add_entry(app, entry)) - .collect(); - - join_all(futures) - .await - .into_iter() - .collect::<Result<()>>()?; - } - } else { - bail!("Your playlist does not seem to have any entries!") - } - } - other => bail!( - "Your URL should point to a video or a playlist, but points to a '{:#?}'", - other - ), - } - } - } - SelectCommand::Watch { shared } => { - let hash = shared.hash.clone().realize(app).await?; - - let video = get_video_by_hash(app, &hash).await?; - if video.cache_path.is_some() { - handle_status_change(app, shared, line_number, VideoStatus::Cached).await?; - } else { - handle_status_change(app, shared, line_number, VideoStatus::Watch).await?; - } - } - - SelectCommand::Url { shared } => { - let mut firefox = std::process::Command::new("firefox"); - firefox.args(["-P", "timesinks.youtube"]); - firefox.arg(shared.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); - - set_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<i64> { - if let Some(pri) = priority { - Some(pri) - } else { - line_number - } -} diff --git a/yt/src/select/mod.rs b/yt/src/select/mod.rs deleted file mode 100644 index ddc8a5e..0000000 --- a/yt/src/select/mod.rs +++ /dev/null @@ -1,176 +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 std::{ - env::{self}, - fs, - io::{BufRead, BufReader, BufWriter, Write}, - string::String, -}; - -use crate::{ - app::App, - cli::CliArgs, - constants::HELP_STR, - storage::video_database::{getters::get_videos, VideoStatus}, - unreachable::Unreachable, - videos::display::format_video::FormatVideo, -}; - -use anyhow::{bail, Context, Result}; -use clap::Parser; -use cmds::handle_select_cmd; -use futures::future::join_all; -use selection_file::process_line; -use tempfile::Builder; -use tokio::process::Command; - -pub mod cmds; -pub mod selection_file; - -pub async fn select(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 = if done { - get_videos(app, VideoStatus::ALL, None).await? - } else { - get_videos( - app, - &[ - VideoStatus::Pick, - // - VideoStatus::Watch, - VideoStatus::Cached, - ], - None, - ) - .await? - }; - - // Warmup 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) = matching_videos.first() { - drop(vid.to_formatted_video(app).await?); - } - - let mut edit_file = BufWriter::new(&temp_file); - - join_all( - matching_videos - .into_iter() - .map(|vid| async { vid.to_formatted_video_owned(app).await }) - .collect::<Vec<_>>(), - ) - .await - .into_iter() - .try_for_each(|line| -> Result<()> { - let formatted_line = (&line?).to_select_file_display(); - - edit_file - .write_all(formatted_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")?; - }; - - { - let editor = env::var("EDITOR").unwrap_or("nvim".to_owned()); - - let mut nvim = Command::new(editor); - nvim.arg(temp_file.path()); - let status = nvim.status().await.context("Falied to run nvim")?; - if !status.success() { - bail!("nvim exited with error status: {}", status) - } - } - - let read_file = temp_file.reopen()?; - fs::copy(temp_file.path(), &app.config.paths.last_selection_path) - .context("Failed to persist selection file")?; - - let reader = BufReader::new(&read_file); - - let mut line_number = 0; - 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") - }; - - 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(()) -} - -// // 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 with out the extra exec <2024-08-20> -// async fn get_help() -> Result<String> { -// let binary_name = current_exe()?; -// let cmd = Command::new(binary_name) -// .args(&["select", "--help"]) -// .output() -// .await?; -// -// assert_eq!(cmd.status.code(), Some(0)); -// -// let output = String::from_utf8(cmd.stdout).expect("Our help output was not utf8?"); -// -// let out = output -// .lines() -// .map(|line| format!("# {}\n", line)) -// .collect::<String>(); -// -// debug!("Returning help: '{}'", &out); -// -// Ok(out) -// } diff --git a/yt/src/select/selection_file/duration.rs b/yt/src/select/selection_file/duration.rs deleted file mode 100644 index ab3a18b..0000000 --- a/yt/src/select/selection_file/duration.rs +++ /dev/null @@ -1,122 +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 std::str::FromStr; - -use anyhow::{Context, Result}; - -use crate::unreachable::Unreachable; - -#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct Duration { - time: u32, -} - -impl FromStr for Duration { - type Err = anyhow::Error; - - fn from_str(s: &str) -> Result<Self, Self::Err> { - fn parse_num(str: &str, suffix: char) -> Result<u32> { - str.strip_suffix(suffix) - .with_context(|| format!("Failed to strip suffix '{suffix}' of number: '{str}'"))? - .parse::<u32>() - .with_context(|| format!("Failed to parse '{suffix}'")) - } - - if s == "[No duration]" { - return Ok(Self { time: 0 }); - } - - let buf: Vec<_> = s.split(' ').collect(); - - let hours; - let minutes; - let seconds; - - assert_eq!(buf.len(), 2, "Other lengths should not happen"); - - if buf[0].ends_with('h') { - hours = parse_num(buf[0], 'h')?; - minutes = parse_num(buf[1], 'm')?; - seconds = 0; - } else if buf[0].ends_with('m') { - hours = 0; - minutes = parse_num(buf[0], 'm')?; - seconds = parse_num(buf[1], 's')?; - } else { - unreachable!( - "The first part always ends with 'h' or 'm', but was: {:#?}", - buf - ) - } - - Ok(Self { - time: (hours * 60 * 60) + (minutes * 60) + seconds, - }) - } -} - -impl From<Option<f64>> for Duration { - fn from(value: Option<f64>) -> Self { - Self { - #[allow(clippy::cast_possible_truncation)] - time: u32::try_from(value.unwrap_or(0.0).ceil() as i128) - .unreachable("This should not exceed `u32::MAX`"), - } - } -} - -impl std::fmt::Display for Duration { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> { - const SECOND: u32 = 1; - const MINUTE: u32 = 60 * SECOND; - const HOUR: u32 = 60 * MINUTE; - - let base_hour = self.time - (self.time % HOUR); - let base_min = (self.time % HOUR) - ((self.time % HOUR) % MINUTE); - let base_sec = (self.time % HOUR) % MINUTE; - - let h = base_hour / HOUR; - let m = base_min / MINUTE; - let s = base_sec / SECOND; - - if self.time == 0 { - write!(f, "[No duration]") - } else if h > 0 { - write!(f, "{h}h {m}m") - } else { - write!(f, "{m}m {s}s") - } - } -} -#[cfg(test)] -mod test { - use std::str::FromStr; - - use super::Duration; - - #[test] - fn test_display_duration_1h() { - let dur = Duration { time: 60 * 60 }; - assert_eq!("1h 0m".to_owned(), dur.to_string()); - } - #[test] - fn test_display_duration_30min() { - let dur = Duration { time: 60 * 30 }; - assert_eq!("30m 0s".to_owned(), dur.to_string()); - } - #[test] - fn test_display_duration_roundtrip() { - let dur = Duration { time: 0 }; - let dur_str = dur.to_string(); - - assert_eq!(Duration { time: 0 }, Duration::from_str(&dur_str).unwrap()); - } -} diff --git a/yt/src/status/mod.rs b/yt/src/status/mod.rs deleted file mode 100644 index 2f7db25..0000000 --- a/yt/src/status/mod.rs +++ /dev/null @@ -1,107 +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 anyhow::{Context, Result}; -use bytes::Bytes; - -use crate::{ - app::App, - download::Downloader, - storage::{ - subscriptions::get, - video_database::{getters::get_videos, VideoStatus}, - }, -}; - -macro_rules! get { - ($videos:expr, $status:ident) => { - $videos - .iter() - .filter(|vid| vid.status == VideoStatus::$status) - .count() - }; - (@changing $videos:expr, $status:ident) => { - $videos - .iter() - .filter(|vid| vid.status == VideoStatus::$status && vid.status_change) - .count() - }; -} - -pub async fn show(app: &App) -> Result<()> { - let all_videos = get_videos( - app, - &[ - VideoStatus::Pick, - // - VideoStatus::Watch, - VideoStatus::Cached, - VideoStatus::Watched, - // - VideoStatus::Drop, - VideoStatus::Dropped, - ], - None, - ) - .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 drop_videos_len = get!(all_videos, Drop); - let dropped_videos_len = get!(all_videos, Dropped); - - // changing - let picked_videos_changing = get!(@changing all_videos, Pick); - - let watch_videos_changing = get!(@changing all_videos, Watch); - let cached_videos_changing = get!(@changing all_videos, Cached); - let watched_videos_changing = get!(@changing all_videos, Watched); - - let drop_videos_changing = get!(@changing all_videos, Drop); - let dropped_videos_changing = get!(@changing all_videos, Dropped); - - let subscriptions = get(app).await?; - let subscriptions_len = subscriptions.0.len(); - - 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} ({picked_videos_changing} changing) - -Watch Videos: {watch_videos_len} ({watch_videos_changing} changing) -Cached Videos: {cached_videos_len} ({cached_videos_changing} changing) -Watched Videos: {watched_videos_len} ({watched_videos_changing} changing) - -Drop Videos: {drop_videos_len} ({drop_videos_changing} changing) -Dropped Videos: {dropped_videos_len} ({dropped_videos_changing} changing) - - - 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/yt/src/storage/video_database/getters.rs b/yt/src/storage/video_database/getters.rs deleted file mode 100644 index d8f9a3f..0000000 --- a/yt/src/storage/video_database/getters.rs +++ /dev/null @@ -1,360 +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>. - -//! These functions interact with the storage db in a read-only way. They are added on-demaned (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::{bail, Context, Result}; -use blake3::Hash; -use log::debug; -use sqlx::{query, QueryBuilder, Row, Sqlite}; -use url::Url; -use yt_dlp::wrapper::info_json::InfoJson; - -use crate::{ - app::App, - storage::{ - subscriptions::Subscription, - video_database::{extractor_hash::ExtractorHash, Video}, - }, - unreachable::Unreachable, -}; - -use super::{MpvOptions, VideoOptions, VideoStatus, YtDlpOptions}; - -macro_rules! video_from_record { - ($record:expr) => { - let thumbnail_url = if let Some(url) = &$record.thumbnail_url { - Some(Url::parse(&url).expect("Parsing this as url should always work")) - } else { - None - }; - - Ok(Video { - cache_path: $record.cache_path.as_ref().map(|val| PathBuf::from(val)), - description: $record.description.clone(), - duration: $record.duration, - extractor_hash: ExtractorHash::from_hash( - $record - .extractor_hash - .parse() - .expect("The db hash should be a valid blake3 hash"), - ), - last_status_change: $record.last_status_change, - parent_subscription_name: $record.parent_subscription_name.clone(), - publish_date: $record.publish_date, - status: VideoStatus::from_db_integer($record.status), - thumbnail_url, - title: $record.title.clone(), - url: Url::parse(&$record.url).expect("Parsing this as url should always work"), - priority: $record.priority, - status_change: if $record.status_change == 1 { - true - } else { - assert_eq!($record.status_change, 0); - false - }, - }) - }; -} - -/// Get the lines to display at the selection file -/// [`changing` = true]: Means that we include *only* videos, that have the `status_changing` flag set -/// [`changing` = None]: Means that we include *both* videos, that have the `status_changing` flag set and not set -/// -/// # Panics -/// Only, if assertions fail. -pub async fn get_videos( - app: &App, - allowed_states: &[VideoStatus], - changing: Option<bool>, -) -> Result<Vec<Video>> { - let mut qb: QueryBuilder<'_, Sqlite> = QueryBuilder::new( - "\ - SELECT * - FROM videos - WHERE status IN ", - ); - - qb.push("("); - allowed_states - .iter() - .enumerate() - .for_each(|(index, state)| { - qb.push("'"); - qb.push(state.as_db_integer()); - qb.push("'"); - - if index != allowed_states.len() - 1 { - qb.push(","); - } - }); - qb.push(")"); - - if let Some(val) = changing { - if val { - qb.push(" AND status_change = 1"); - } else { - qb.push(" AND status_change = 0"); - } - } - - qb.push("\n ORDER BY priority DESC, publish_date DESC;"); - - debug!("Will run: \"{}\"", qb.sql()); - - let videos = qb.build().fetch_all(&app.database).await.with_context(|| { - format!( - "Failed to query videos with states: '{}'", - allowed_states.iter().fold(String::new(), |mut acc, state| { - acc.push(' '); - acc.push_str(state.as_str()); - acc - }), - ) - })?; - - let real_videos: Vec<Video> = videos - .iter() - .map(|base| -> Result<Video> { - Ok(Video { - cache_path: base - .get::<Option<String>, &str>("cache_path") - .as_ref() - .map(PathBuf::from), - description: base.get::<Option<String>, &str>("description").clone(), - duration: base.get("duration"), - extractor_hash: ExtractorHash::from_hash( - base.get::<String, &str>("extractor_hash") - .parse() - .unreachable("The db hash should always be a valid blake3 hash"), - ), - last_status_change: base.get("last_status_change"), - parent_subscription_name: base - .get::<Option<String>, &str>("parent_subscription_name") - .clone(), - publish_date: base.get("publish_date"), - status: VideoStatus::from_db_integer(base.get("status")), - thumbnail_url: base - .get::<Option<String>, &str>("thumbnail_url") - .as_ref() - .map(|url| { - Url::parse(url).unreachable( - "Parsing this as url should always work. \ - As it was an URL when we put it in.", - ) - }), - title: base.get::<String, &str>("title").clone(), - url: Url::parse(base.get("url")).unreachable( - "Parsing this as url should always work. \ - As it was an URL when we put it in.", - ), - priority: base.get("priority"), - status_change: { - let val = base.get::<i64, &str>("status_change"); - if val == 1 { - true - } else { - assert_eq!(val, 0, "Can only be 1 or 0"); - false - } - }, - }) - }) - .collect::<Result<Vec<Video>>>()?; - - Ok(real_videos) -} - -pub async fn get_video_info_json(video: &Video) -> Result<Option<InfoJson>> { - if let Some(mut path) = video.cache_path.clone() { - if !path.set_extension("info.json") { - bail!( - "Failed to change path extension to 'info.json': {}", - path.display() - ); - } - let info_json_string = File::open(path)?; - let info_json: InfoJson = serde_json::from_reader(&info_json_string)?; - - Ok(Some(info_json)) - } else { - Ok(None) - } -} - -pub async fn get_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?; - - video_from_record! {raw_video} -} - -/// # Panics -/// Only if assertions fail. -pub async fn get_currently_playing_video(app: &App) -> Result<Option<Video>> { - let mut videos: Vec<Video> = get_changing_videos(app, VideoStatus::Cached).await?; - - if videos.is_empty() { - Ok(None) - } else { - assert_eq!( - videos.len(), - 1, - "Only one video can change from cached to watched at once!" - ); - - Ok(Some(videos.remove(0))) - } -} - -pub async fn get_changing_videos(app: &App, old_state: VideoStatus) -> Result<Vec<Video>> { - let status = old_state.as_db_integer(); - - let matching = query!( - r#" - SELECT * - FROM videos - WHERE status_change = 1 AND status = ?; - "#, - status - ) - .fetch_all(&app.database) - .await?; - - let real_videos: Vec<Video> = matching - .iter() - .map(|base| -> Result<Video> { - video_from_record! {base} - }) - .collect::<Result<Vec<Video>>>()?; - - Ok(real_videos) -} - -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 get_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/yt/src/storage/video_database/mod.rs b/yt/src/storage/video_database/mod.rs deleted file mode 100644 index aff57b9..0000000 --- a/yt/src/storage/video_database/mod.rs +++ /dev/null @@ -1,182 +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 std::{fmt::Write, path::PathBuf}; - -use url::Url; - -use crate::{app::App, storage::video_database::extractor_hash::ExtractorHash}; - -pub mod downloader; -pub mod extractor_hash; -pub mod getters; -pub mod setters; - -#[derive(Debug, Clone)] -pub struct Video { - pub cache_path: Option<PathBuf>, - pub description: Option<String>, - pub duration: Option<f64>, - pub extractor_hash: ExtractorHash, - pub last_status_change: i64, - /// The associated subscription this video was fetched from (null, when the video was `add`ed) - pub parent_subscription_name: Option<String>, - pub priority: i64, - pub publish_date: Option<i64>, - pub status: VideoStatus, - /// The video is currently changing its state (for example from being `SELECT` to being `CACHE`) - pub status_change: bool, - pub thumbnail_url: Option<Url>, - pub title: String, - pub url: Url, -} - -#[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> -/// / \ -/// <Watch> <Drop> -> Dropped // yt select -/// | -/// Cache // yt cache -/// | -/// Watched // yt watch -#[derive(Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy)] -pub enum VideoStatus { - #[default] - Pick, - - /// The video has been select to be watched - Watch, - /// The video has been cached and is ready to be watched - Cached, - /// The video has been watched - Watched, - - /// The video has been select to be dropped - Drop, - /// The video has been dropped - Dropped, -} - -impl VideoStatus { - pub const ALL: &'static [Self; 6] = &[ - Self::Pick, - // - VideoStatus::Watch, - VideoStatus::Cached, - VideoStatus::Watched, - // - VideoStatus::Drop, - VideoStatus::Dropped, - ]; - - #[must_use] - pub 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 { - VideoStatus::Pick => "pick ", - - VideoStatus::Watch | VideoStatus::Cached => "watch ", - VideoStatus::Watched => "watched", - - VideoStatus::Drop | VideoStatus::Dropped => "drop ", - } - } - - #[must_use] - pub 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 { - VideoStatus::Pick => 0, - - VideoStatus::Watch => 1, - VideoStatus::Cached => 2, - VideoStatus::Watched => 3, - - VideoStatus::Drop => 4, - VideoStatus::Dropped => 5, - } - } - #[must_use] - pub fn from_db_integer(num: i64) -> Self { - match num { - 0 => Self::Pick, - - 1 => Self::Watch, - 2 => Self::Cached, - 3 => Self::Watched, - - 4 => Self::Drop, - 5 => Self::Dropped, - other => unreachable!( - "The database returned a enum discriminator, unknown to us: '{}'", - other - ), - } - } - - #[must_use] - pub fn as_str(&self) -> &'static str { - match self { - VideoStatus::Pick => "Pick", - - VideoStatus::Watch => "Watch", - VideoStatus::Cached => "Cache", - VideoStatus::Watched => "Watched", - - VideoStatus::Drop => "Drop", - VideoStatus::Dropped => "Dropped", - } - } -} diff --git a/yt/src/storage/video_database/setters.rs b/yt/src/storage/video_database/setters.rs deleted file mode 100644 index 4531fd1..0000000 --- a/yt/src/storage/video_database/setters.rs +++ /dev/null @@ -1,273 +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>. - -//! These functions change the database. They are added on a demand basis. - -use anyhow::Result; -use chrono::Utc; -use log::{debug, info}; -use sqlx::query; -use tokio::fs; - -use crate::{app::App, storage::video_database::extractor_hash::ExtractorHash}; - -use super::{Video, VideoOptions, VideoStatus}; - -/// 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 set_video_status( - app: &App, - video_hash: &ExtractorHash, - new_status: VideoStatus, - new_priority: Option<i64>, -) -> Result<()> { - let video_hash = video_hash.hash().to_string(); - - let old = query!( - r#" - SELECT status, priority, cache_path - FROM videos - WHERE extractor_hash = ? - "#, - video_hash - ) - .fetch_one(&app.database) - .await?; - - let cache_path = if (VideoStatus::from_db_integer(old.status) == VideoStatus::Cached) - && (new_status != VideoStatus::Cached) - { - None - } else { - old.cache_path.as_deref() - }; - - let new_status = new_status.as_db_integer(); - - if let Some(new_priority) = new_priority { - if old.status == new_status && old.priority == new_priority { - return Ok(()); - } - - let now = Utc::now().timestamp(); - - debug!( - "Running status change: {:#?} -> {:#?}...", - VideoStatus::from_db_integer(old.status), - VideoStatus::from_db_integer(new_status), - ); - - query!( - r#" - UPDATE videos - SET status = ?, last_status_change = ?, priority = ?, cache_path = ? - WHERE extractor_hash = ?; - "#, - new_status, - now, - new_priority, - cache_path, - video_hash - ) - .execute(&app.database) - .await?; - } else { - if old.status == new_status { - return Ok(()); - } - - let now = Utc::now().timestamp(); - - debug!( - "Running status change: {:#?} -> {:#?}...", - VideoStatus::from_db_integer(old.status), - VideoStatus::from_db_integer(new_status), - ); - - query!( - r#" - UPDATE videos - SET status = ?, last_status_change = ?, cache_path = ? - WHERE extractor_hash = ?; - "#, - new_status, - now, - cache_path, - 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 set_video_watched(app: &App, video: &Video) -> Result<()> { - let video_hash = video.extractor_hash.hash().to_string(); - let new_status = VideoStatus::Watched.as_db_integer(); - - info!("Will set video watched: '{}'", video.title); - - let old = query!( - r#" - SELECT status, priority - FROM videos - WHERE extractor_hash = ? - "#, - video_hash - ) - .fetch_one(&app.database) - .await?; - - assert_ne!( - old.status, new_status, - "The video should not be marked as watched already." - ); - assert_eq!( - old.status, - VideoStatus::Cached.as_db_integer(), - "The video should have been marked cached" - ); - - let now = Utc::now().timestamp(); - - if let Some(path) = &video.cache_path { - if let Ok(true) = path.try_exists() { - fs::remove_file(path).await?; - } - } - - query!( - r#" - UPDATE videos - SET status = ?, last_status_change = ?, cache_path = NULL - WHERE extractor_hash = ?; - "#, - new_status, - now, - video_hash - ) - .execute(&app.database) - .await?; - - Ok(()) -} - -pub async fn set_state_change( - app: &App, - video_extractor_hash: &ExtractorHash, - changing: bool, -) -> Result<()> { - let state_change = u32::from(changing); - let video_extractor_hash = video_extractor_hash.hash().to_string(); - - query!( - r#" - UPDATE videos - SET status_change = ? - WHERE extractor_hash = ?; - "#, - state_change, - 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(()) -} - -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 status = video.status.as_db_integer(); - let status_change = u32::from(video.status_change); - 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; - - query!( - r#" - BEGIN; - INSERT INTO videos ( - parent_subscription_name, - status, - status_change, - last_status_change, - title, - url, - description, - duration, - publish_date, - thumbnail_url, - extractor_hash) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); - - INSERT INTO video_options ( - extractor_hash, - subtitle_langs, - playback_speed) - VALUES (?, ?, ?); - COMMIT; - "#, - parent_subscription_name, - status, - status_change, - video.last_status_change, - video.title, - url, - video.description, - video.duration, - video.publish_date, - thumbnail_url, - extractor_hash, - extractor_hash, - default_subtitle_langs, - default_mpv_playback_speed - ) - .execute(&app.database) - .await?; - - Ok(()) -} diff --git a/yt/src/update/mod.rs b/yt/src/update/mod.rs deleted file mode 100644 index e3ab54e..0000000 --- a/yt/src/update/mod.rs +++ /dev/null @@ -1,261 +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 std::{collections::HashMap, process::Stdio, str::FromStr, string::ToString}; - -use anyhow::{Context, Ok, Result}; -use chrono::{DateTime, Utc}; -use log::{error, info, warn}; -use tokio::{ - io::{AsyncBufReadExt, BufReader}, - process::Command, -}; -use url::Url; -use yt_dlp::{unsmuggle_url, wrapper::info_json::InfoJson}; - -use crate::{ - app::App, - storage::{ - subscriptions::{get, Subscription}, - video_database::{ - extractor_hash::ExtractorHash, getters::get_all_hashes, setters::add_video, Video, - VideoStatus, - }, - }, - unreachable::Unreachable, - videos::display::format_video::FormatVideo, -}; - -pub async fn update( - app: &App, - max_backlog: u32, - subs_to_update: Vec<String>, - verbosity: u8, -) -> Result<()> { - let subscriptions = get(app).await?; - let mut back_subs: HashMap<Url, Subscription> = HashMap::new(); - let logging = verbosity > 0; - let log_level = match verbosity { - // 0 => 50, // logging.CRITICAL - 0 => 40, // logging.ERROR - 1 => 30, // logging.WARNING - 2 => 20, // logging.INFO - 3.. => 10, // logging.DEBUG - }; - info!("Passing log_level {} to the update script", log_level); - - let mut urls: Vec<String> = vec![]; - for (name, sub) in subscriptions.0 { - if subs_to_update.contains(&name) || subs_to_update.is_empty() { - urls.push(sub.url.to_string()); - back_subs.insert(sub.url.clone(), sub); - } else { - info!( - "Not updating subscription '{}' as it was not specified", - name - ); - } - } - - // 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 mut child = Command::new("raw_update.py") - .arg(max_backlog.to_string()) - .arg(urls.len().to_string()) - .arg(log_level.to_string()) - .args(&urls) - .args(hashes.iter().map(ToString::to_string).collect::<Vec<_>>()) - .stdout(Stdio::piped()) - .stderr(if logging { - Stdio::inherit() - } else { - Stdio::null() - }) - .stdin(Stdio::null()) - .spawn() - .context("Failed to call python3 update_raw")?; - - let mut out = BufReader::new( - child - .stdout - .take() - .unreachable("Should be able to take child stdout"), - ) - .lines(); - - while let Some(line) = out.next_line().await? { - // use tokio::{fs::File, io::AsyncWriteExt}; - // let mut output = File::create("output.json").await?; - // output.write(line.as_bytes()).await?; - // output.flush().await?; - // output.sync_all().await?; - // drop(output); - - let output_json: HashMap<Url, InfoJson> = serde_json::from_str(&line) - .unreachable("The json is generated by our own script. It should be valid"); - - for (url, value) in output_json { - let sub = back_subs.get(&url).unreachable("This was stored before"); - process_subscription(app, sub, value, &hashes) - .await - .with_context(|| format!("Failed to process subscription: '{}'", sub.name))?; - } - } - - let out = child.wait().await?; - if !out.success() { - error!( - "The update_raw.py invokation failed (exit code: {}).", - out.code() - .map_or("<No exit code>".to_owned(), |f| f.to_string()) - ); - } - - Ok(()) -} - -#[allow(clippy::too_many_lines)] -pub fn video_entry_to_video(entry: InfoJson, sub: Option<&Subscription>) -> Result<Video> { - macro_rules! unwrap_option { - ($option:expr) => { - match $option { - Some(x) => x, - None => anyhow::bail!(concat!( - "Expected a value, but '", - stringify!($option), - "' is None!" - )), - } - }; - } - 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.upload_date { - 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(6) - .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!", - unwrap_option!(&entry.title) - ); - None - }; - - let thumbnail_url = match (&entry.thumbnails, &entry.thumbnail) { - (None, None) => None, - (None, Some(thumbnail)) => Some(thumbnail.to_owned()), - - // TODO: The algorithm is not exactly the best <2024-05-28> - (Some(thumbnails), None) => thumbnails.first().map(|thumbnail| thumbnail.url.clone()), - (Some(_), Some(thumnail)) => Some(thumnail.to_owned()), - }; - - let url = { - let smug_url: Url = unwrap_option!(entry.webpage_url.clone()); - unsmuggle_url(&smug_url)? - }; - - let extractor_hash = blake3::hash(unwrap_option!(entry.id).as_bytes()); - - let subscription_name = if let Some(sub) = sub { - Some(sub.name.clone()) - } else if let Some(uploader) = entry.uploader { - if entry.webpage_url_domain == Some("youtube.com".to_owned()) { - Some(format!("{uploader} - Videos")) - } else { - Some(uploader.clone()) - } - } else { - None - }; - - let video = Video { - cache_path: None, - description: entry.description.clone(), - duration: entry.duration, - extractor_hash: ExtractorHash::from_hash(extractor_hash), - last_status_change: Utc::now().timestamp(), - parent_subscription_name: subscription_name, - priority: 0, - publish_date, - status: VideoStatus::Pick, - status_change: false, - thumbnail_url, - title: unwrap_option!(entry.title.clone()), - url, - }; - Ok(video) -} - -async fn process_subscription( - app: &App, - sub: &Subscription, - entry: InfoJson, - hashes: &[blake3::Hash], -) -> Result<()> { - let video = - video_entry_to_video(entry, Some(sub)).context("Failed to parse search entry as Video")?; - - if hashes.contains(video.extractor_hash.hash()) { - // We already stored the video information - unreachable!("The python update script should have never provided us a duplicated video"); - } else { - add_video(app, video.clone()) - .await - .with_context(|| format!("Failed to add video to database: '{}'", video.title))?; - println!( - "{}", - (&video - .to_formatted_video(app) - .await - .with_context(|| format!("Failed to format video: '{}'", video.title))? - .colorize()) - .to_line_display() - ); - Ok(()) - } -} diff --git a/yt/src/videos/display/format_video.rs b/yt/src/videos/display/format_video.rs deleted file mode 100644 index 18c2e15..0000000 --- a/yt/src/videos/display/format_video.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> -// 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; - -pub trait FormatVideo { - type Output; - - fn cache_path(&self) -> Self::Output; - fn description(&self) -> Self::Output; - fn duration(&self) -> Self::Output; - fn extractor_hash(&self) -> Self::Output; - fn last_status_change(&self) -> Self::Output; - fn parent_subscription_name(&self) -> Self::Output; - fn priority(&self) -> Self::Output; - fn publish_date(&self) -> Self::Output; - fn status(&self) -> Self::Output; - fn status_change(&self) -> Self::Output; - fn thumbnail_url(&self) -> Self::Output; - fn title(&self) -> Self::Output; - fn url(&self) -> Self::Output; - fn video_options(&self) -> Self::Output; - - #[allow(clippy::type_complexity)] - fn to_parts( - &self, - ) -> ( - Self::Output, - Self::Output, - Self::Output, - Self::Output, - Self::Output, - Self::Output, - Self::Output, - Self::Output, - Self::Output, - Self::Output, - Self::Output, - Self::Output, - Self::Output, - Self::Output, - ) { - let cache_path = self.cache_path(); - let description = self.description(); - let duration = self.duration(); - let extractor_hash = self.extractor_hash(); - let last_status_change = self.last_status_change(); - let parent_subscription_name = self.parent_subscription_name(); - let priority = self.priority(); - let publish_date = self.publish_date(); - let status = self.status(); - let status_change = self.status_change(); - let thumbnail_url = self.thumbnail_url(); - let title = self.title(); - let url = self.url(); - let video_options = self.video_options(); - - ( - cache_path, - description, - duration, - extractor_hash, - last_status_change, - parent_subscription_name, - priority, - publish_date, - status, - status_change, - thumbnail_url, - title, - url, - video_options, - ) - } - - fn to_info_display(&self) -> String - where - <Self as FormatVideo>::Output: Display, - { - let ( - cache_path, - description, - duration, - extractor_hash, - last_status_change, - parent_subscription_name, - priority, - publish_date, - status, - status_change, - thumbnail_url, - title, - url, - video_options, - ) = self.to_parts(); - - let status_change = if status_change.to_string().as_str() == "false" { - "currently not changing" - } else if status_change.to_string().as_str() == "true" { - "currently changing" - } else { - unreachable!("This is an formatted boolean"); - }; - - let string = format!( - "\ -{title} ({extractor_hash}) -| -> {cache_path} -| -> {duration} -| -> {parent_subscription_name} -| -> priority: {priority} -| -> {publish_date} -| -> status: {status} since {last_status_change} -| -> {status_change} -| -> {thumbnail_url} -| -> {url} -| -> options: {} -{description}\n", - video_options.to_string().trim() - ); - string - } - - fn to_line_display(&self) -> String - where - Self::Output: Display, - { - let f = format!( - "{} {} {} {} {} {}", - self.status(), - self.extractor_hash(), - self.title(), - self.publish_date(), - self.parent_subscription_name(), - self.duration() - ); - - f - } - - fn to_select_file_display(&self) -> String - where - Self::Output: Display, - { - let f = format!( - r#"{}{} {} "{}" "{}" "{}" "{}" "{}"{}"#, - self.status(), - self.video_options(), - self.extractor_hash(), - self.title(), - self.publish_date(), - self.parent_subscription_name(), - self.duration(), - self.url(), - '\n' - ); - - f - } -} diff --git a/yt/src/videos/display/mod.rs b/yt/src/videos/display/mod.rs deleted file mode 100644 index 4e5ee50..0000000 --- a/yt/src/videos/display/mod.rs +++ /dev/null @@ -1,316 +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 std::path::PathBuf; - -use chrono::DateTime; -use format_video::FormatVideo; -use owo_colors::OwoColorize; -use url::Url; - -use crate::{ - app::App, - select::selection_file::duration::Duration, - storage::video_database::{getters::get_video_opts, Video}, -}; - -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() - } - }; -} - -/// This is identical to a [`FormattedVideo`], but has colorized fields. -#[derive(Debug)] -pub struct ColorizedFormattedVideo(FormattedVideo); - -impl FormattedVideo { - #[must_use] - pub fn colorize(self) -> ColorizedFormattedVideo { - let Self { - cache_path, - description, - duration, - extractor_hash, - last_status_change, - parent_subscription_name, - priority, - publish_date, - status, - status_change, - thumbnail_url, - title, - url, - video_options, - } = self; - - ColorizedFormattedVideo(Self { - cache_path: cache_path.blue().bold().to_string(), - description, - duration: duration.cyan().bold().to_string(), - extractor_hash: extractor_hash.bright_purple().italic().to_string(), - last_status_change: last_status_change.bright_cyan().to_string(), - parent_subscription_name: parent_subscription_name.bright_magenta().to_string(), - priority, - publish_date: publish_date.bright_white().bold().to_string(), - status: status.red().bold().to_string(), - status_change, - thumbnail_url, - title: title.green().bold().to_string(), - url: url.italic().to_string(), - video_options: video_options.bright_green().to_string(), - }) - } -} - -/// This is a version of [`Video`] that has all the fields of the original [`Video`] structure -/// turned to [`String`]s to facilitate displaying it. -/// -/// This structure provides a way to display a [`Video`] in a coherent way, as it enforces to -/// always use the same colors for one field. -#[derive(Debug)] -pub struct FormattedVideo { - cache_path: String, - description: String, - duration: String, - extractor_hash: String, - last_status_change: String, - parent_subscription_name: String, - priority: String, - publish_date: String, - status: String, - status_change: String, - thumbnail_url: String, - title: String, - url: String, - /// This string contains the video options (speed, `subtitle_languages`, etc.). - /// It already starts with an extra whitespace, when these are not empty. - video_options: String, -} - -impl Video { - pub async fn to_formatted_video_owned(self, app: &App) -> Result<FormattedVideo> { - Self::to_formatted_video(&self, app).await - } - - pub async fn to_formatted_video(&self, app: &App) -> Result<FormattedVideo> { - fn date_from_stamp(stamp: i64) -> String { - DateTime::from_timestamp(stamp, 0) - .expect("The timestamps should always be valid") - .format("%Y-%m-%d") - .to_string() - } - - let cache_path: String = get!( - self, - cache_path, - "Cache Path", - (|value: &PathBuf| value.to_string_lossy().to_string()) - ); - let description = get!( - self, - description, - "Description", - (|value: &str| value.to_owned()) - ); - let duration = Duration::from(self.duration); - let extractor_hash = self - .extractor_hash - .into_short_hash(app) - .await - .with_context(|| { - format!( - "Failed to format extractor hash, whilst formatting video: '{}'", - self.title - ) - })?; - let last_status_change = date_from_stamp(self.last_status_change); - let parent_subscription_name = get!( - self, - parent_subscription_name, - "author", - (|sub: &str| sub.replace('"', "'")) - ); - let priority = self.priority; - let publish_date = get!( - self, - publish_date, - "release date", - (|date: &i64| date_from_stamp(*date)) - ); - // 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_command(); - let status_change = self.status_change; - let thumbnail_url = get!( - self, - thumbnail_url, - "thumbnail URL", - (|url: &Url| url.to_string()) - ); - let title = self.title.replace(['"', '„', '”', '“'], "'"); - let url = self.url.as_str().replace('"', "\\\""); - - 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(FormattedVideo { - cache_path, - description, - duration: duration.to_string(), - extractor_hash: extractor_hash.to_string(), - last_status_change, - parent_subscription_name, - priority: priority.to_string(), - publish_date, - status: status.to_string(), - status_change: status_change.to_string(), - thumbnail_url, - title, - url, - video_options, - }) - } -} - -impl<'a> FormatVideo for &'a FormattedVideo { - type Output = &'a str; - - fn cache_path(&self) -> Self::Output { - &self.cache_path - } - - fn description(&self) -> Self::Output { - &self.description - } - - fn duration(&self) -> Self::Output { - &self.duration - } - - fn extractor_hash(&self) -> Self::Output { - &self.extractor_hash - } - - fn last_status_change(&self) -> Self::Output { - &self.last_status_change - } - - fn parent_subscription_name(&self) -> Self::Output { - &self.parent_subscription_name - } - - fn priority(&self) -> Self::Output { - &self.priority - } - - fn publish_date(&self) -> Self::Output { - &self.publish_date - } - - fn status(&self) -> Self::Output { - &self.status - } - - fn status_change(&self) -> Self::Output { - &self.status_change - } - - fn thumbnail_url(&self) -> Self::Output { - &self.thumbnail_url - } - - fn title(&self) -> Self::Output { - &self.title - } - - fn url(&self) -> Self::Output { - &self.url - } - - fn video_options(&self) -> Self::Output { - &self.video_options - } -} -impl<'a> FormatVideo for &'a ColorizedFormattedVideo { - type Output = &'a str; - - fn cache_path(&self) -> Self::Output { - &self.0.cache_path - } - - fn description(&self) -> Self::Output { - &self.0.description - } - - fn duration(&self) -> Self::Output { - &self.0.duration - } - - fn extractor_hash(&self) -> Self::Output { - &self.0.extractor_hash - } - - fn last_status_change(&self) -> Self::Output { - &self.0.last_status_change - } - - fn parent_subscription_name(&self) -> Self::Output { - &self.0.parent_subscription_name - } - - fn priority(&self) -> Self::Output { - &self.0.priority - } - - fn publish_date(&self) -> Self::Output { - &self.0.publish_date - } - - fn status(&self) -> Self::Output { - &self.0.status - } - - fn status_change(&self) -> Self::Output { - &self.0.status_change - } - - fn thumbnail_url(&self) -> Self::Output { - &self.0.thumbnail_url - } - - fn title(&self) -> Self::Output { - &self.0.title - } - - fn url(&self) -> Self::Output { - &self.0.url - } - - fn video_options(&self) -> Self::Output { - &self.0.video_options - } -} diff --git a/yt/src/videos/mod.rs b/yt/src/videos/mod.rs deleted file mode 100644 index 9704f73..0000000 --- a/yt/src/videos/mod.rs +++ /dev/null @@ -1,66 +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 anyhow::Result; -use display::{format_video::FormatVideo, FormattedVideo}; -use futures::{stream::FuturesUnordered, TryStreamExt}; -use nucleo_matcher::{ - pattern::{CaseMatching, Normalization, Pattern}, - Matcher, -}; - -pub mod display; - -use crate::{ - app::App, - storage::video_database::{getters::get_videos, VideoStatus}, -}; - -pub async fn query(app: &App, limit: Option<usize>, search_query: Option<String>) -> Result<()> { - let all_videos = get_videos(app, VideoStatus::ALL, None).await?; - - // turn one video to a color display, to pre-warm the hash shrinking cache - if let Some(val) = all_videos.first() { - val.to_formatted_video(app).await?; - } - - let limit = limit.unwrap_or(all_videos.len()); - - let all_video_strings: Vec<String> = all_videos - .into_iter() - .take(limit) - .map(|vid| vid.to_formatted_video_owned(app)) - .collect::<FuturesUnordered<_>>() - .try_collect::<Vec<FormattedVideo>>() - .await? - .into_iter() - .map(|vid| (&vid.colorize()).to_line_display()) - .collect(); - - if let Some(query) = search_query { - let mut matcher = Matcher::new(nucleo_matcher::Config::DEFAULT.match_paths()); - - let pattern_matches = Pattern::parse( - &query.replace(' ', "\\ "), - CaseMatching::Ignore, - Normalization::Smart, - ) - .match_list(all_video_strings, &mut matcher); - - pattern_matches - .iter() - .rev() - .for_each(|(val, key)| println!("{val} ({key})")); - } else { - println!("{}", all_video_strings.join("\n")); - } - - Ok(()) -} diff --git a/yt/src/watch/events/handlers/mod.rs b/yt/src/watch/events/handlers/mod.rs deleted file mode 100644 index 715896d..0000000 --- a/yt/src/watch/events/handlers/mod.rs +++ /dev/null @@ -1,194 +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 std::{env::current_exe, mem}; - -use crate::{app::App, comments, description, storage::video_database::setters::set_state_change}; - -use super::MpvEventHandler; - -use anyhow::{bail, Context, Result}; -use libmpv2::{ - events::{EndFileEvent, PlaylistEntryId}, - Mpv, -}; -use log::info; -use tokio::process::Command; - -impl MpvEventHandler { - // EndFile {{{ - pub async fn handle_end_file_eof( - &mut self, - app: &App, - mpv: &Mpv, - end_file_event: EndFileEvent, - ) -> Result<()> { - info!("Mpv reached eof of current video. Marking it inactive."); - - self.mark_video_inactive(app, mpv, end_file_event.playlist_entry_id) - .await?; - - Ok(()) - } - pub async fn handle_end_file_stop( - &mut self, - app: &App, - mpv: &Mpv, - end_file_event: EndFileEvent, - ) -> Result<()> { - // 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); Marking it inactive"); - - self.mark_video_inactive(app, mpv, end_file_event.playlist_entry_id) - .await?; - - Ok(()) - } - pub async fn handle_end_file_quit( - &mut self, - app: &App, - mpv: &Mpv, - _end_file_event: EndFileEvent, - ) -> Result<()> { - info!("Mpv quit. Exiting playback"); - - // draining the playlist is okay, as mpv is done playing - let mut handler = mem::take(&mut self.playlist_handler); - let videos = handler.playlist_ids(mpv)?; - for hash in videos.values() { - self.mark_video_watched(app, hash).await?; - set_state_change(app, hash, false).await?; - } - - Ok(()) - } - // }}} - - // StartFile {{{ - pub async fn handle_start_file( - &mut self, - app: &App, - mpv: &Mpv, - entry_id: PlaylistEntryId, - ) -> Result<()> { - self.possibly_add_new_videos(app, mpv, false).await?; - - // We don't need to check, whether other videos are still active, as they should - // have been marked inactive in the `Stop` handler. - self.mark_video_active(app, mpv, entry_id).await?; - let hash = self.get_cvideo_hash(mpv, 0)?; - self.apply_options(app, mpv, &hash).await?; - - Ok(()) - } - // }}} - - // ClientMessage {{{ - async fn run_self_in_external_command(app: &App, args: &[&str]) -> Result<()> { - let binary = - 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")?, - "--db-path", - app.config - .paths - .database_path - .to_str() - .context("Failed to parse the database_path as a utf8-string")?, - ], - args, - ] - .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"]) - .status() - .await?; - - if !status.success() { - bail!("focusing the next output failed!"); - } - - Ok(()) - } - - pub async fn handle_client_message_yt_description_external(app: &App) -> Result<()> { - Self::run_self_in_external_command(app, &["description"]).await?; - Ok(()) - } - pub async fn handle_client_message_yt_description_local(app: &App, mpv: &Mpv) -> Result<()> { - let description: String = description::get(app) - .await? - .replace(['"', '\''], "") - .chars() - .take(app.config.watch.local_displays_length) - .collect(); - - Self::message(mpv, &description, "6000")?; - Ok(()) - } - - pub async fn handle_client_message_yt_comments_external(app: &App) -> Result<()> { - Self::run_self_in_external_command(app, &["comments"]).await?; - Ok(()) - } - pub async fn handle_client_message_yt_comments_local(app: &App, mpv: &Mpv) -> Result<()> { - let comments: String = comments::get(app) - .await? - .render(false) - .replace(['"', '\''], "") - .chars() - .take(app.config.watch.local_displays_length) - .collect(); - - Self::message(mpv, &comments, "6000")?; - Ok(()) - } - - /// # Panics - /// Only if internal assertions fail. - pub fn handle_client_message_yt_mark_watch_later(&mut self, mpv: &Mpv) -> Result<()> { - mpv.execute("write-watch-later-config", &[])?; - - let hash = self.remove_cvideo_from_playlist(mpv)?; - assert!( - self.watch_later_block_list.insert(hash), - "A video should not be blocked *and* in the playlist" - ); - - Self::message(mpv, "Marked the video to be watched later", "3000")?; - - Ok(()) - } - // }}} -} diff --git a/yt/src/watch/events/mod.rs b/yt/src/watch/events/mod.rs deleted file mode 100644 index b63b33b..0000000 --- a/yt/src/watch/events/mod.rs +++ /dev/null @@ -1,322 +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 std::collections::{HashMap, HashSet}; - -use anyhow::{Context, Result}; -use libmpv2::{ - events::{Event, PlaylistEntryId}, - EndFileReason, Mpv, -}; -use log::{debug, info}; - -use crate::{ - app::App, - storage::video_database::{ - extractor_hash::ExtractorHash, - getters::{get_video_by_hash, get_video_mpv_opts, get_videos}, - setters::{set_state_change, set_video_watched}, - VideoStatus, - }, - unreachable::Unreachable, -}; - -use playlist_handler::PlaylistHandler; - -mod handlers; -mod playlist_handler; - -#[derive(Debug, Clone, Copy)] -pub enum IdleCheckOutput { - /// There are no videos already downloaded 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_watched: usize }, - - /// There are videos cached and ready to be inserted into the playback queue. - Available { newly_available: Option<usize> }, -} - -#[derive(Debug)] -pub struct MpvEventHandler { - watch_later_block_list: HashSet<ExtractorHash>, - playlist_handler: PlaylistHandler, -} - -impl MpvEventHandler { - #[must_use] - pub fn from_playlist(playlist_cache: HashMap<String, ExtractorHash>) -> Self { - let playlist_handler = PlaylistHandler::from_cache(playlist_cache); - Self { - playlist_handler, - watch_later_block_list: HashSet::new(), - } - } - - /// Checks, whether new videos are ready to be played - pub async fn possibly_add_new_videos( - &mut self, - app: &App, - mpv: &Mpv, - force_message: bool, - ) -> Result<usize> { - let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?; - - // There is nothing to watch - if play_things.is_empty() { - if force_message { - Self::message(mpv, "No new videos available to add", "3000")?; - } - return Ok(0); - } - - let mut blocked_videos = 0; - let current_playlist = self.playlist_handler.playlist_ids(mpv)?; - let play_things = play_things - .into_iter() - .filter(|val| !current_playlist.values().any(|a| a == &val.extractor_hash)) - .filter(|val| { - if self.watch_later_block_list.contains(&val.extractor_hash) { - blocked_videos += 1; - false - } else { - true - } - }) - .collect::<Vec<_>>(); - - info!( - "{} videos are cached and will be added to the list to be played ({} are blocked)", - play_things.len(), - blocked_videos - ); - - let num = play_things.len(); - self.playlist_handler.reserve(play_things.len()); - for play_thing in play_things { - debug!("Adding '{}' to playlist.", play_thing.title); - - let orig_cache_path = play_thing.cache_path.unreachable("Is cached and thus some"); - let cache_path = orig_cache_path.to_str().with_context(|| { - format!( - "Failed to parse video cache_path as vaild utf8: '{}'", - orig_cache_path.display() - ) - })?; - let fmt_cache_path = format!("\"{cache_path}\""); - - let args = &[&fmt_cache_path, "append-play"]; - - mpv.execute("loadfile", args)?; - self.playlist_handler - .add(cache_path.to_owned(), play_thing.extractor_hash); - } - - if force_message || num > 0 { - Self::message( - mpv, - format!("Added {num} videos ({blocked_videos} are marked as watch later)").as_str(), - "3000", - )?; - } - Ok(num) - } - - fn message(mpv: &Mpv, message: &str, time: &str) -> Result<()> { - mpv.execute("show-text", &[format!("\"{message}\"").as_str(), time])?; - Ok(()) - } - - /// Get the hash of the currently playing video. - /// You can specify an offset, which is added to the ``playlist_position`` to get, for example, the - /// previous video (-1) or the next video (+1). - /// Beware that setting an offset can cause an property error if it's out of bound. - fn get_cvideo_hash(&mut self, mpv: &Mpv, offset: i64) -> Result<ExtractorHash> { - let playlist_entry_id = { - let playlist_position = { - let raw = mpv.get_property::<i64>("playlist-pos")?; - if raw == -1 { - unreachable!("Tried to get the currently playing video hash, but failed to access the mpv 'playlist-pos' property! This is a bug, as this function should only be called, when a current video exists. Current state: '{:#?}'", self); - } else { - usize::try_from(raw + offset).with_context(|| format!("Failed to calculate playlist position because of usize overflow: '{raw} + {offset}'"))? - } - }; - - let raw = - mpv.get_property::<i64>(format!("playlist/{playlist_position}/id").as_str())?; - PlaylistEntryId::new(raw) - }; - - // debug!("Trying to get playlist entry: '{}'", playlist_entry_id); - - let video_hash = self - .playlist_handler - .playlist_ids(mpv)? - .get(&playlist_entry_id) - .expect("The stored playling index should always be in the playlist") - .to_owned(); - - Ok(video_hash) - } - async fn mark_video_watched(&self, app: &App, hash: &ExtractorHash) -> Result<()> { - let video = get_video_by_hash(app, hash).await?; - debug!("MPV handler will mark video '{}' watched.", video.title); - set_video_watched(app, &video).await?; - Ok(()) - } - - async fn mark_video_inactive( - &mut self, - app: &App, - mpv: &Mpv, - playlist_index: PlaylistEntryId, - ) -> Result<()> { - let current_playlist = self.playlist_handler.playlist_ids(mpv)?; - let video_hash = current_playlist - .get(&playlist_index) - .expect("The video index should always be correctly tracked"); - - set_state_change(app, video_hash, false).await?; - Ok(()) - } - async fn mark_video_active( - &mut self, - app: &App, - mpv: &Mpv, - playlist_index: PlaylistEntryId, - ) -> Result<()> { - let current_playlist = self.playlist_handler.playlist_ids(mpv)?; - let video_hash = current_playlist - .get(&playlist_index) - .expect("The video index should always be correctly tracked"); - - set_state_change(app, video_hash, true).await?; - Ok(()) - } - - /// Apply the options set with e.g. `watch --speed=<speed>` - async fn apply_options(&self, app: &App, mpv: &Mpv, hash: &ExtractorHash) -> Result<()> { - let options = get_video_mpv_opts(app, hash).await?; - - mpv.set_property("speed", options.playback_speed)?; - Ok(()) - } - - /// This also returns the hash of the current video - fn remove_cvideo_from_playlist(&mut self, mpv: &Mpv) -> Result<ExtractorHash> { - let hash = self.get_cvideo_hash(mpv, 0)?; - mpv.execute("playlist-remove", &["current"])?; - Ok(hash) - } - - /// Check if the playback queue is empty - pub async fn check_idle(&mut self, app: &App, mpv: &Mpv) -> Result<IdleCheckOutput> { - if mpv.get_property::<bool>("idle-active")? { - // The playback is currently not running, but we might still have more videos lined up - // to be inserted into the queue. - - let number_of_new_videos = self.possibly_add_new_videos(app, mpv, false).await?; - - if number_of_new_videos == 0 { - let watch_videos = get_videos(app, &[VideoStatus::Watch], None).await?.len(); - - if watch_videos == 0 { - // There are no more videos left. We should exit now. - Ok(IdleCheckOutput::NoMoreAvailable) - } else { - // There are still videos that *could* get downloaded. Wait for them. - Ok(IdleCheckOutput::NoCached { - marked_watched: watch_videos, - }) - } - } else { - Ok(IdleCheckOutput::Available { - newly_available: Some(number_of_new_videos), - }) - } - } else { - // The playback is running. Obviously, something is available. - Ok(IdleCheckOutput::Available { - newly_available: None, - }) - } - } - - /// This will return [`true`], if the event handling should be stopped - pub async fn handle_mpv_event( - &mut self, - app: &App, - mpv: &Mpv, - event: Event<'_>, - ) -> Result<bool> { - match event { - Event::EndFile(r) => match r.reason { - EndFileReason::Eof => { - self.handle_end_file_eof(app, mpv, r).await?; - } - EndFileReason::Stop => { - self.handle_end_file_stop(app, mpv, r).await?; - } - EndFileReason::Quit => { - self.handle_end_file_quit(app, mpv, r).await?; - return Ok(true); - } - EndFileReason::Error => { - unreachable!("This will be raised as a separate error") - } - EndFileReason::Redirect => { - todo!("We probably need to handle this somehow"); - } - }, - Event::StartFile(entry_id) => { - self.handle_start_file(app, mpv, entry_id).await?; - } - Event::ClientMessage(a) => { - debug!("Got Client Message event: '{}'", a.join(" ")); - - match a.as_slice() { - &["yt-comments-external"] => { - Self::handle_client_message_yt_comments_external(app).await?; - } - &["yt-comments-local"] => { - Self::handle_client_message_yt_comments_local(app, mpv).await?; - } - &["yt-description-external"] => { - Self::handle_client_message_yt_description_external(app).await?; - } - &["yt-description-local"] => { - Self::handle_client_message_yt_description_local(app, mpv).await?; - } - &["yt-mark-watch-later"] => { - self.handle_client_message_yt_mark_watch_later(mpv)?; - } - &["yt-mark-done-and-go-next"] => { - let cvideo_hash = self.remove_cvideo_from_playlist(mpv)?; - self.mark_video_watched(app, &cvideo_hash).await?; - - Self::message(mpv, "Marked the video watched", "3000")?; - } - &["yt-check-new-videos"] => { - self.possibly_add_new_videos(app, mpv, true).await?; - } - other => { - debug!("Unknown message: {}", other.join(" ")); - } - } - } - _ => {} - } - - Ok(false) - } -} diff --git a/yt/src/watch/events/playlist_handler.rs b/yt/src/watch/events/playlist_handler.rs deleted file mode 100644 index 232232d..0000000 --- a/yt/src/watch/events/playlist_handler.rs +++ /dev/null @@ -1,97 +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 std::collections::HashMap; - -use anyhow::Result; -use libmpv2::{events::PlaylistEntryId, mpv_node::MpvNode, Mpv}; - -use crate::storage::video_database::extractor_hash::ExtractorHash; - -#[derive(Debug, Default)] -pub(crate) struct PlaylistHandler { - /// A map of the original file paths to the videos extractor hashes. - /// Used to get the extractor hash from a video returned by mpv - playlist_cache: HashMap<String, ExtractorHash>, - - /// A map of the `playlist_entry_id` field to their corresponding extractor hashes. - playlist_ids: HashMap<PlaylistEntryId, ExtractorHash>, -} -impl PlaylistHandler { - pub(crate) fn from_cache(cache: HashMap<String, ExtractorHash>) -> Self { - Self { - playlist_cache: cache, - playlist_ids: HashMap::new(), - } - } - - pub(crate) fn reserve(&mut self, len: usize) { - self.playlist_cache.reserve(len); - } - pub(crate) fn add(&mut self, cache_path: String, extractor_hash: ExtractorHash) { - assert_eq!( - self.playlist_cache.insert(cache_path, extractor_hash), - None, - "Only new video should ever be added" - ); - } - - pub(crate) fn playlist_ids( - &mut self, - mpv: &Mpv, - ) -> Result<&HashMap<PlaylistEntryId, ExtractorHash>> { - let mpv_playlist: Vec<(String, PlaylistEntryId)> = match mpv.get_property("playlist")? { - MpvNode::ArrayIter(array) => array - .map(|val| match val { - MpvNode::MapIter(map) => { - struct BuildPlaylistEntry { - filename: Option<String>, - id: Option<PlaylistEntryId>, - } - let mut entry = BuildPlaylistEntry { - filename: None, - id: None, - }; - - map.for_each(|(key, value)| match key.as_str() { - "filename" => { - entry.filename = Some(value.str().expect("work").to_owned()); - } - "id" => { - entry.id = Some(PlaylistEntryId::new(value.i64().expect("Works"))); - } - _ => (), - }); - (entry.filename.expect("is some"), entry.id.expect("is some")) - } - _ => unreachable!(), - }) - .collect(), - _ => unreachable!(), - }; - - let mut playlist: HashMap<PlaylistEntryId, ExtractorHash> = - HashMap::with_capacity(mpv_playlist.len()); - for (path, key) in mpv_playlist { - let hash = self - .playlist_cache - .get(&path) - .expect("All path should also be stored in the cache") - .to_owned(); - playlist.insert(key, hash); - } - - for (id, hash) in playlist { - self.playlist_ids.entry(id).or_insert(hash); - } - - Ok(&self.playlist_ids) - } -} diff --git a/yt/src/watch/mod.rs b/yt/src/watch/mod.rs deleted file mode 100644 index 6e7c372..0000000 --- a/yt/src/watch/mod.rs +++ /dev/null @@ -1,155 +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 std::{collections::HashMap, time::Duration}; - -use anyhow::{Context, Result}; -use events::{IdleCheckOutput, MpvEventHandler}; -use libmpv2::{events::EventContext, Mpv}; -use log::{debug, info, warn}; -use tokio::time; - -use crate::{ - app::App, - cache::maintain, - storage::video_database::{extractor_hash::ExtractorHash, getters::get_videos, VideoStatus}, - unreachable::Unreachable, -}; - -pub mod events; - -#[allow(clippy::too_many_lines)] -pub async fn watch(app: &App) -> Result<()> { - maintain(app, false).await?; - - // 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")?; - Ok(()) - })?; - - let config_path = &app.config.paths.mpv_config_path; - if config_path.try_exists()? { - info!("Found mpv.conf at '{}'!", config_path.display()); - mpv.execute( - "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.execute( - "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 mut ev_ctx = EventContext::new(mpv.ctx); - ev_ctx.disable_deprecated_events()?; - - let play_things = get_videos(app, &[VideoStatus::Cached], Some(false)).await?; - info!( - "{} videos are cached and ready to be played", - play_things.len() - ); - - let mut playlist_cache: HashMap<String, ExtractorHash> = - HashMap::with_capacity(play_things.len()); - - for play_thing in play_things { - debug!("Adding '{}' to playlist.", play_thing.title); - - let orig_cache_path = play_thing.cache_path.unreachable("Is cached and thus some"); - let cache_path = orig_cache_path.to_str().with_context(|| { - format!( - "Failed to parse the cache_path of a video as utf8: '{}'", - orig_cache_path.display() - ) - })?; - let fmt_cache_path = format!("\"{cache_path}\""); - - let args = &[&fmt_cache_path, "append-play"]; - - mpv.execute("loadfile", args)?; - - playlist_cache.insert(cache_path.to_owned(), play_thing.extractor_hash); - } - - let mut mpv_event_handler = MpvEventHandler::from_playlist(playlist_cache); - let mut have_warned = (false, 0); - 'watchloop: loop { - 'waitloop: while let Ok(value) = mpv_event_handler.check_idle(app, &mpv).await { - match value { - IdleCheckOutput::NoMoreAvailable => { - break 'watchloop; - } - IdleCheckOutput::NoCached { marked_watched } => { - // try again next time. - if have_warned.0 { - if have_warned.1 != marked_watched { - warn!("Now {} videos are marked as watched.", marked_watched); - have_warned.1 = marked_watched; - } - } else { - warn!("There is nothing to watch yet, but still {} videos marked as to be watched. \ - Will idle, until they become available", marked_watched); - have_warned = (true, marked_watched); - } - time::sleep(Duration::from_secs(10)).await; - } - IdleCheckOutput::Available { newly_available: _ } => { - have_warned.0 = false; - // Something just became available! - break 'waitloop; - } - } - } - - if let Some(ev) = ev_ctx.wait_event(600.) { - match ev { - Ok(event) => { - debug!("Mpv event triggered: {:#?}", event); - if mpv_event_handler.handle_mpv_event(app, &mpv, event).await? { - break; - } - } - Err(e) => debug!("Mpv Event errored: {}", e), - } - } - } - - Ok(()) -} |