diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..49b86fa7 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,11 @@ +[build] +# Use sccache for faster rebuilds (install with: cargo install sccache) +# rustc-wrapper = ".cargo/sccache-wrapper.sh" + +[target.x86_64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold"] + +[target.aarch64-unknown-linux-gnu] +linker = "clang" +rustflags = ["-C", "link-arg=-fuse-ld=mold"] diff --git a/.cargo/sccache-wrapper.sh b/.cargo/sccache-wrapper.sh new file mode 100755 index 00000000..1cd6e3a9 --- /dev/null +++ b/.cargo/sccache-wrapper.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec sccache "$@" diff --git a/.gitignore b/.gitignore index 74d73b8a..6b4c0bc4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,13 @@ node_modules docs/book config.toml +!.cargo/config.toml data.db data.db.* +server/assets/dioxus +test/e2e.db +/test-results +/tests/e2e/test-dbs +/.manifest.json +/mlm_web_dioxus/.manifest.json +/mlm_web_dioxus/dev.db diff --git a/Cargo.lock b/Cargo.lock index 1b5ca2f1..ddbd5928 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -19,18 +10,46 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser 0.35.0", + "html5ever 0.35.0", + "maplit", + "tendril", + "url", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "ascii-canvas" @@ -48,7 +67,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" dependencies = [ "askama_derive", - "itoa 1.0.15", + "itoa 1.0.17", "percent-encoding", "serde", "serde_json", @@ -64,14 +83,20 @@ dependencies = [ "basic-toml", "memchr", "proc-macro2", - "pulldown-cmark 0.13.0", + "pulldown-cmark 0.13.1", "quote", - "rustc-hash", + "rustc-hash 2.1.1", "serde", "serde_derive", - "syn 2.0.115", + "syn 2.0.117", ] +[[package]] +name = "askama_escape" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df27b8d5ddb458c5fb1bbc1ce172d4a38c614a97d550b0ac89003897fb01de4" + [[package]] name = "askama_parser" version = "0.14.0" @@ -129,9 +154,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.39" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" dependencies = [ "compression-codecs", "compression-core", @@ -141,9 +166,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.3" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" dependencies = [ "async-task", "concurrent-queue", @@ -270,6 +295,28 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-task" version = "4.7.1" @@ -284,7 +331,23 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", +] + +[[package]] +name = "async-tungstenite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee88b4c88ac8c9ea446ad43498955750a4bbe64c4392f21ccfe5d952865e318f" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tungstenite 0.27.0", ] [[package]] @@ -310,33 +373,36 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.8.4" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "axum-macros", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", "hyper-util", - "itoa 1.0.15", + "itoa 1.0.17", "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite 0.28.0", "tower", "tower-layer", "tower-service", @@ -345,18 +411,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -365,27 +430,28 @@ dependencies = [ [[package]] name = "axum-extra" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" dependencies = [ "axum", "axum-core", "bytes", "form_urlencoded", "futures-util", - "http 1.3.1", + "headers", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", "rustversion", - "serde", + "serde_core", "serde_html_form", "serde_path_to_error", - "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -396,23 +462,14 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] -name = "backtrace" -version = "0.3.75" +name = "base16" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] +checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8" [[package]] name = "base64" @@ -478,9 +535,12 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] [[package]] name = "block" @@ -512,9 +572,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.18.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db76d6187cd04dff33004d8e6c9cc4e05cd330500379d2394209271b4aeee" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "bytecount" @@ -524,9 +584,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.23.1" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c76a5792e44e4abe34d3abf15636779261d45a7450612059293d1d2cfc63422" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -536,17 +596,20 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] [[package]] name = "camino" -version = "1.1.10" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0da45bc31171d8d6960122e222a67740df867c1dd53b4d51caa297084c185cab" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -573,18 +636,36 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.27" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ + "find-msvc-tools", "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" -version = "1.0.1" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -592,6 +673,56 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64 0.22.1", + "encoding_rs", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -601,7 +732,7 @@ dependencies = [ "bitflags 1.3.2", "block", "cocoa-foundation", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "foreign-types 0.5.0", "libc", @@ -616,17 +747,27 @@ checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" dependencies = [ "bitflags 1.3.2", "block", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "libc", "objc", ] +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "compression-codecs" -version = "0.4.36" +version = "0.4.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" dependencies = [ "compression-core", "flate2", @@ -648,6 +789,83 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-serialize" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7154afa56de2f290e3c82c2c6dc4f5b282b6870903f56ef3509aba95866edc" +dependencies = [ + "const-serialize-macro 0.7.2", +] + +[[package]] +name = "const-serialize" +version = "0.8.0-alpha.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e42cd5aabba86f128b3763da1fec1491c0f728ce99245062cd49b6f9e6d235b" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize-macro 0.8.0-alpha.0", + "serde", +] + +[[package]] +name = "const-serialize-macro" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f160aad86b4343e8d4e261fee9965c3005b2fd6bc117d172ab65948779e4acf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "const-serialize-macro" +version = "0.8.0-alpha.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42571ed01eb46d2e1adcf99c8ca576f081e46f2623d13500eba70d1d99a4c439" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "const-str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0664d2867b4a32697dfe655557f5c3b187e9b605b38612a748e5ec99811d160" + +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "content_disposition" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc14a88e1463ddd193906285abe5c360c7e8564e05ccc5d501755f7fbc9ca9c" +dependencies = [ + "charset", +] + [[package]] name = "conv" version = "0.3.3" @@ -663,6 +881,24 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaaa0ecca5b51987b9423ccdc971514dd8b0bb7b4060b983d3664dad3f1f89f" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.1" @@ -702,6 +938,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -715,7 +961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types 0.5.0", "libc", @@ -728,7 +974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "libc", ] @@ -792,9 +1038,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", @@ -825,7 +1071,20 @@ checksum = "b7c66d1cd8ed61bf80b38432613a7a2f09401ab8d0501110655f8b341484a3e3" dependencies = [ "cssparser-macros", "dtoa-short", - "itoa 1.0.15", + "itoa 1.0.17", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 1.0.17", "phf 0.11.3", "smallvec", ] @@ -837,7 +1096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -852,8 +1111,18 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -867,7 +1136,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.115", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -876,40 +1158,71 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] -name = "deranged" -version = "0.5.5" +name = "darling_macro" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "powerfmt", - "serde_core", + "darling_core 0.21.3", + "quote", + "syn 2.0.117", ] [[package]] -name = "derive_builder" -version = "0.20.2" +name = "dashmap" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" dependencies = [ - "derive_builder_macro", + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", ] [[package]] -name = "derive_builder_core" -version = "0.20.2" +name = "data-encoding" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -919,7 +1232,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -928,11 +1241,34 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", - "syn 2.0.115", + "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -945,6 +1281,600 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dioxus" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" +dependencies = [ + "dioxus-asset-resolver", + "dioxus-cli-config", + "dioxus-config-macro", + "dioxus-config-macros", + "dioxus-core", + "dioxus-core-macro", + "dioxus-devtools", + "dioxus-document", + "dioxus-fullstack", + "dioxus-fullstack-macro", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-liveview", + "dioxus-logger", + "dioxus-router", + "dioxus-server", + "dioxus-signals", + "dioxus-ssr", + "dioxus-stores", + "dioxus-web", + "manganis", + "serde", + "subsecond", + "warnings", +] + +[[package]] +name = "dioxus-asset-resolver" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0161af1d3cfc8ff31503ff1b7ee0068c97771fc38d0cc6566e23483142ddf4f" +dependencies = [ + "dioxus-cli-config", + "http 1.4.0", + "infer", + "jni", + "js-sys", + "ndk", + "ndk-context", + "ndk-sys", + "percent-encoding", + "thiserror 2.0.18", + "tokio", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-cli-config" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "dioxus-config-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f040ec7c41aa5428283f56bb0670afba9631bfe3ffd885f4814807f12c8c9d91" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "dioxus-config-macros" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10c41b47b55a433b61f7c12327c85ba650572bacbcc42c342ba2e87a57975264" + +[[package]] +name = "dioxus-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b389b0e3cc01c7da292ad9b884b088835fdd1671d45fbd2f737506152b22eef0" +dependencies = [ + "anyhow", + "const_format", + "dioxus-core-types", + "futures-channel", + "futures-util", + "generational-box", + "longest-increasing-subsequence", + "rustc-hash 2.1.1", + "rustversion", + "serde", + "slab", + "slotmap", + "subsecond", + "tracing", +] + +[[package]] +name = "dioxus-core-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" +dependencies = [ + "convert_case 0.8.0", + "dioxus-rsx", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-core-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" + +[[package]] +name = "dioxus-devtools" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf89488bad8fb0f18b9086ee2db01f95f709801c10c68be42691a36378a0f2d" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools-types", + "dioxus-signals", + "futures-channel", + "futures-util", + "serde", + "serde_json", + "subsecond", + "thiserror 2.0.18", + "tracing", + "tungstenite 0.27.0", +] + +[[package]] +name = "dioxus-devtools-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e7381d9d7d0a0f66b9d5082d584853c3d53be21d34007073daca98ddf26fc4d" +dependencies = [ + "dioxus-core", + "serde", + "subsecond-types", +] + +[[package]] +name = "dioxus-document" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0aeeff26d9d06441f59fd8d7f4f76098ba30ca9728e047c94486161185ceb" +dependencies = [ + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-html", + "futures-channel", + "futures-util", + "generational-box", + "lazy-js-bundle", + "serde", + "serde_json", + "tracing", +] + +[[package]] +name = "dioxus-fullstack" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db1f8b70338072ec408b48d09c96559cf071f87847465d8161294197504c498" +dependencies = [ + "anyhow", + "async-stream", + "async-tungstenite", + "axum", + "axum-core", + "axum-extra", + "base64 0.22.1", + "bytes", + "ciborium", + "const-str", + "const_format", + "content_disposition", + "derive_more 2.1.1", + "dioxus-asset-resolver", + "dioxus-cli-config", + "dioxus-core", + "dioxus-fullstack-core", + "dioxus-fullstack-macro", + "dioxus-hooks", + "dioxus-html", + "dioxus-signals", + "form_urlencoded", + "futures", + "futures-channel", + "futures-util", + "gloo-net", + "headers", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "inventory", + "js-sys", + "mime", + "pin-project", + "reqwest", + "rustversion", + "send_wrapper", + "serde", + "serde_json", + "serde_qs", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-tungstenite 0.27.0", + "tokio-util", + "tower", + "tower-http", + "tower-layer", + "tracing", + "tungstenite 0.27.0", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "xxhash-rust", +] + +[[package]] +name = "dioxus-fullstack-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda8b152e85121243741b9d5f2a3d8cb3c47a7b2299e902f98b6a7719915b0a2" +dependencies = [ + "anyhow", + "axum-core", + "base64 0.22.1", + "ciborium", + "dioxus-core", + "dioxus-document", + "dioxus-history", + "dioxus-hooks", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "http 1.4.0", + "inventory", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "dioxus-fullstack-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "255104d4a4f278f1a8482fa30536c91d22260c561c954b753e72987df8d65b2e" +dependencies = [ + "const_format", + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "xxhash-rust", +] + +[[package]] +name = "dioxus-history" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d00ba43bfe6e5ca226fef6128f240ca970bea73cac0462416188026360ccdcf" +dependencies = [ + "dioxus-core", + "tracing", +] + +[[package]] +name = "dioxus-hooks" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab2da4f038c33cb38caa37ffc3f5d6dfbc018f05da35b238210a533bb075823" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "rustversion", + "slab", + "tracing", +] + +[[package]] +name = "dioxus-html" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded5fa6d2e677b7442a93f4228bf3c0ad2597a8bd3292cae50c869d015f3a99" +dependencies = [ + "async-trait", + "bytes", + "dioxus-core", + "dioxus-core-macro", + "dioxus-core-types", + "dioxus-hooks", + "dioxus-html-internal-macro", + "enumset", + "euclid", + "futures-channel", + "futures-util", + "generational-box", + "keyboard-types", + "lazy-js-bundle", + "rustversion", + "serde", + "serde_json", + "serde_repr", + "tracing", +] + +[[package]] +name = "dioxus-html-internal-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-interpreter-js" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f" +dependencies = [ + "dioxus-core", + "dioxus-core-types", + "dioxus-html", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "sledgehammer_bindgen", + "sledgehammer_utils", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "dioxus-liveview" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3f7a1cfe6f8e9f2e303607c8ae564d11932fd80714c8a8c97e3860d55538997" +dependencies = [ + "axum", + "dioxus-cli-config", + "dioxus-core", + "dioxus-devtools", + "dioxus-document", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "futures-channel", + "futures-util", + "generational-box", + "rustc-hash 2.1.1", + "serde", + "serde_json", + "slab", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + +[[package]] +name = "dioxus-logger" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1eeab114cb009d9e6b85ea10639a18cfc54bb342f3b837770b004c4daeb89c2" +dependencies = [ + "dioxus-cli-config", + "tracing", + "tracing-subscriber", + "tracing-wasm", +] + +[[package]] +name = "dioxus-router" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d5b31f9e27231389bf5a117b7074d22d8c58358b484a2558e56fbab20e64ca4" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-macro", + "dioxus-fullstack-core", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-router-macro", + "dioxus-signals", + "percent-encoding", + "rustversion", + "tracing", + "url", +] + +[[package]] +name = "dioxus-router-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "838b9b441a95da62b39cae4defd240b5ebb0ec9f2daea1126099e00a838dc86f" +dependencies = [ + "base16", + "digest", + "proc-macro2", + "quote", + "sha2", + "slab", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-rsx" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53128858f0ccca9de54292a4d48409fda1df75fd5012c6243f664042f0225d68" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-server" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8adb2d4e0f0f3a157bda6af2d90f22bac40070e509a66e3ea58abf3b35f904c" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "chrono", + "ciborium", + "dashmap", + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-macro", + "dioxus-devtools", + "dioxus-document", + "dioxus-fullstack-core", + "dioxus-history", + "dioxus-hooks", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-logger", + "dioxus-router", + "dioxus-signals", + "dioxus-ssr", + "enumset", + "futures", + "futures-channel", + "futures-util", + "generational-box", + "http 1.4.0", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "inventory", + "lru", + "parking_lot", + "pin-project", + "rustc-hash 2.1.1", + "serde", + "serde_json", + "serde_qs", + "subsecond", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.27.0", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-futures", + "url", + "walkdir", +] + +[[package]] +name = "dioxus-signals" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f48020bc23bc9766e7cce986c0fd6de9af0b8cbfd432652ec6b1094439c1ec6" +dependencies = [ + "dioxus-core", + "futures-channel", + "futures-util", + "generational-box", + "parking_lot", + "rustc-hash 2.1.1", + "tracing", + "warnings", +] + +[[package]] +name = "dioxus-ssr" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44cf9294a21fcd1098e02ad7a3ba61b99be8310ad3395fecf8210387c83f26b9" +dependencies = [ + "askama_escape", + "dioxus-core", + "dioxus-core-types", + "rustc-hash 2.1.1", +] + +[[package]] +name = "dioxus-stores" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77aaa9ac56d781bb506cf3c0d23bea96b768064b89fe50d3b4d4659cc6bd8058" +dependencies = [ + "dioxus-core", + "dioxus-signals", + "dioxus-stores-macro", + "generational-box", +] + +[[package]] +name = "dioxus-stores-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" +dependencies = [ + "convert_case 0.8.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dioxus-web" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b33fe739fed4e8143dac222a9153593f8e2451662ce8fc4c9d167a9d6ec0923" +dependencies = [ + "dioxus-cli-config", + "dioxus-core", + "dioxus-core-types", + "dioxus-devtools", + "dioxus-document", + "dioxus-fullstack-core", + "dioxus-history", + "dioxus-html", + "dioxus-interpreter-js", + "dioxus-signals", + "futures-channel", + "futures-util", + "generational-box", + "gloo-timers", + "js-sys", + "lazy-js-bundle", + "rustc-hash 2.1.1", + "send_wrapper", + "serde", + "serde-wasm-bindgen", + "serde_json", + "tracing", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "dirs" version = "6.0.0" @@ -972,7 +1902,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.0", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -995,23 +1925,23 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] name = "document-features" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] [[package]] name = "dtoa" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] name = "dtoa-short" @@ -1022,6 +1952,12 @@ dependencies = [ "dtoa", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ego-tree" version = "0.6.3" @@ -1042,23 +1978,23 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "embed-resource" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6d81016d6c977deefb2ef8d8290da019e27cc26167e102185da528e6c0ab38" +checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e" dependencies = [ "cc", "memchr", "rustc_version", - "toml 0.9.2", + "toml 0.9.12+spec-1.1.0", "vswhom", "winreg", ] [[package]] name = "ena" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" dependencies = [ "log", ] @@ -1072,6 +2008,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enumset" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1080,12 +2037,12 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1097,6 +2054,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "euclid" +version = "0.22.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +dependencies = [ + "num-traits", + "serde", +] + [[package]] name = "event-listener" version = "2.5.3" @@ -1146,13 +2113,19 @@ dependencies = [ [[package]] name = "file-id" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bc904b9bbefcadbd8e3a9fb0d464a9b979de6324c03b3c663e8994f46a5be36" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1175,6 +2148,18 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -1202,7 +2187,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -1238,9 +2223,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1253,9 +2238,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1263,15 +2248,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1280,9 +2265,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -1299,32 +2284,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1334,7 +2319,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1347,6 +2331,16 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generational-box" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4ed190b9de8e734d47a70be59b1e7588b9e8e0d0036e332f4c014e8aed1bc5" +dependencies = [ + "parking_lot", + "tracing", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1359,9 +2353,9 @@ dependencies = [ [[package]] name = "getopts" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ "unicode-width", ] @@ -1379,9 +2373,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -1392,29 +2386,57 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasip2", "wasm-bindgen", ] [[package]] -name = "gimli" -version = "0.31.1" +name = "getrandom" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] [[package]] name = "glob" -version = "0.3.2" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gloo-net" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http 1.4.0", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror 1.0.69", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] name = "gloo-timers" @@ -1428,6 +2450,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "h2" version = "0.4.10" @@ -1439,7 +2474,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.3.1", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1448,10 +2483,71 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.16.1" +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http 1.4.0", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http 1.4.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" @@ -1482,7 +2578,18 @@ dependencies = [ "log", "mac", "markup5ever 0.14.1", - "match_token", + "match_token 0.1.0", +] + +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever 0.35.0", + "match_token 0.35.0", ] [[package]] @@ -1504,18 +2611,17 @@ checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ "bytes", "fnv", - "itoa 1.0.15", + "itoa 1.0.17", ] [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", - "itoa 1.0.15", + "itoa 1.0.17", ] [[package]] @@ -1536,7 +2642,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.3.1", + "http 1.4.0", ] [[package]] @@ -1547,7 +2653,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "pin-project-lite", ] @@ -1612,7 +2718,7 @@ dependencies = [ "http-body 0.4.6", "httparse", "httpdate", - "itoa 1.0.15", + "itoa 1.0.17", "pin-project-lite", "socket2 0.5.10", "tokio", @@ -1632,11 +2738,11 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "httparse", "httpdate", - "itoa 1.0.15", + "itoa 1.0.17", "pin-project-lite", "pin-utils", "smallvec", @@ -1650,7 +2756,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.3.1", + "http 1.4.0", "hyper 1.8.1", "hyper-util", "rustls", @@ -1679,16 +2785,15 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "hyper 1.8.1", "ipnet", @@ -1698,16 +2803,41 @@ dependencies = [ "socket2 0.5.10", "system-configuration", "tokio", + "tower-layer", "tower-service", "tracing", "windows-registry", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -1718,9 +2848,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -1731,11 +2861,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -1746,42 +2875,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -1789,6 +2914,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1823,7 +2954,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", ] [[package]] @@ -1833,14 +2975,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" [[package]] -name = "io-uring" -version = "0.7.10" +name = "inventory" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" +checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227" dependencies = [ - "bitflags 2.9.1", - "cfg-if", - "libc", + "rustversion", ] [[package]] @@ -1851,9 +2991,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -1913,20 +3053,52 @@ checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" dependencies = [ "once_cell", "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.0", + "serde", +] + [[package]] name = "kv-log-macro" version = "1.0.7" @@ -1950,7 +3122,7 @@ dependencies = [ "petgraph", "pico-args", "regex", - "regex-syntax 0.8.5", + "regex-syntax", "string_cache", "term", "tiny-keccak", @@ -1964,7 +3136,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" dependencies = [ - "regex-automata 0.4.13", + "regex-automata", ] [[package]] @@ -1981,12 +3153,24 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "lazy-js-bundle" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7b88b715ab1496c6e6b8f5e927be961c4235196121b6ae59bcb51077a21dd36" + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "levenshtein" version = "1.0.5" @@ -1995,45 +3179,53 @@ checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libloading" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] [[package]] name = "libredox" -version = "0.1.8" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "360e552c93fa0e8152ab463bc4c4837fce76a225df11dfaeea66c313de5e61f7" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" dependencies = [ - "bitflags 2.9.1", "libc", ] [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "litrs" -version = "0.4.1" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] @@ -2046,18 +3238,53 @@ dependencies = [ "value-bag", ] +[[package]] +name = "longest-increasing-subsequence" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + +[[package]] +name = "lru" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lucide-dioxus" +version = "2.575.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "697ee3753d4ba59db3268e16b16c37888752a1207c01ba8c695d39c2beaa06a9" +dependencies = [ + "dioxus", +] + [[package]] name = "mac" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2067,6 +3294,52 @@ dependencies = [ "libc", ] +[[package]] +name = "manganis" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cce7d688848bf9d034168513b9a2ffbfe5f61df2ff14ae15e6cfc866efdd344" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "manganis-core", + "manganis-macro", +] + +[[package]] +name = "manganis-core" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ce917b978268fe8a7db49e216343ec7c8f471f7e686feb70940d67293f19d4" +dependencies = [ + "const-serialize 0.7.2", + "const-serialize 0.8.0-alpha.0", + "dioxus-cli-config", + "dioxus-core-types", + "serde", + "winnow", +] + +[[package]] +name = "manganis-macro" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad513e990f7c0bca86aa68659a7a3dc4c705572ed4c22fd6af32ccf261334cc2" +dependencies = [ + "dunce", + "macro-string", + "manganis-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "markup5ever" version = "0.11.0" @@ -2095,6 +3368,17 @@ dependencies = [ "tendril", ] +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + [[package]] name = "match_token" version = "0.1.0" @@ -2103,16 +3387,27 @@ checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "matchers" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "regex-automata 0.1.10", + "regex-automata", ] [[package]] @@ -2135,9 +3430,27 @@ checksum = "0ec453904a69e1e27cbfdf87a1759aa1f1351593b3730f6da3ac35438b9b4d7e" [[package]] name = "memchr" -version = "2.7.5" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "memmap2" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] [[package]] name = "mime" @@ -2167,13 +3480,13 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2192,6 +3505,7 @@ dependencies = [ "mlm_meta", "mlm_parse", "mlm_web_askama", + "mlm_web_dioxus", "native_db", "once_cell", "open", @@ -2246,7 +3560,7 @@ dependencies = [ "serde_derive", "serde_json", "sublime_fuzzy", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -2302,7 +3616,7 @@ dependencies = [ "serde-nested-json", "serde_derive", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tokio", "tracing", @@ -2332,8 +3646,10 @@ dependencies = [ name = "mlm_parse" version = "0.1.0" dependencies = [ + "ammonia", "anyhow", "htmlentity", + "maplit", "once_cell", "regex", "unidecode", @@ -2361,7 +3677,7 @@ dependencies = [ "serde", "serde_json", "sublime_fuzzy", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", "tokio", "tokio-stream", @@ -2373,11 +3689,59 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "mlm_web_dioxus" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "dioxus", + "dioxus-fullstack", + "figment", + "itertools 0.14.0", + "lucide-dioxus", + "mlm_core", + "mlm_db", + "mlm_mam", + "mlm_parse", + "native_db", + "qbit", + "serde", + "serde_json", + "sublime_fuzzy", + "time", + "tokio", + "tokio-stream", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http 1.4.0", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", @@ -2412,7 +3776,7 @@ source = "git+https://github.com/StirlingMouse/native_db.git?branch=0.8.x#cddaaf dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -2427,18 +3791,48 @@ dependencies = [ "serde", "skeptic", "thiserror 1.0.69", - "zerocopy", + "zerocopy", +] + +[[package]] +name = "native_model_macro" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f385f3d57adaea8d8868e65a0bc821bcb8ba2228bbf87a1c3c6144ac48f3791" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", ] [[package]] -name = "native_model_macro" -version = "0.4.20" +name = "ndk-context" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f385f3d57adaea8d8868e65a0bc821bcb8ba2228bbf87a1c3c6144ac48f3791" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.115", + "jni-sys", ] [[package]] @@ -2464,19 +3858,27 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "overload", - "winapi", + "windows-sys 0.61.2", ] [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] [[package]] name = "num_cpus" @@ -2488,6 +3890,28 @@ dependencies = [ "libc", ] +[[package]] +name = "num_enum" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num_threads" version = "0.1.7" @@ -2526,15 +3950,6 @@ dependencies = [ "objc", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -2543,9 +3958,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "open" -version = "5.3.2" +version = "5.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" dependencies = [ "is-wsl", "libc", @@ -2554,11 +3969,11 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.73" +version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.11.0", "cfg-if", "foreign-types 0.3.2", "libc", @@ -2575,29 +3990,29 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.5.1+3.5.1" +version = "300.5.5+3.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "735230c832b28c000e3bc117119e6466a663ec73506bc0a9907ea4187508e42a" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" dependencies = [ "cc", ] [[package]] name = "openssl-sys" -version = "0.9.109" +version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", @@ -2612,12 +4027,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "padlock" version = "0.2.0" @@ -2632,9 +4041,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -2642,15 +4051,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] @@ -2679,7 +4088,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -2812,7 +4221,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -2839,7 +4248,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher 1.0.1", + "siphasher 1.0.2", ] [[package]] @@ -2848,11 +4257,31 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -2862,9 +4291,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "piper" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" dependencies = [ "atomic-waker", "fastrand", @@ -2893,9 +4322,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -2921,6 +4350,25 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + [[package]] name = "proc-macro-hack" version = "0.5.20+deprecated" @@ -2944,7 +4392,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", "version_check", "yansi", ] @@ -2971,18 +4419,18 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.11.0", "memchr", "unicase", ] [[package]] name = "pulldown-cmark" -version = "0.13.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.11.0", "memchr", "unicase", ] @@ -2990,7 +4438,7 @@ dependencies = [ [[package]] name = "qbit" version = "0.2.0" -source = "git+https://github.com/StirlingMouse/qbittorrent-webui-api.git#ce47d16ee2f5a0c69fa0b9c476338299f03b2214" +source = "git+https://github.com/StirlingMouse/qbittorrent-webui-api.git#1038c6000e749b6b0d55fe2108618e82beeaebfe" dependencies = [ "bytes", "derive_builder", @@ -3004,9 +4452,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.0" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "serde", @@ -3023,10 +4471,10 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "socket2 0.5.10", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -3039,15 +4487,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", - "getrandom 0.3.3", + "getrandom 0.3.4", "lru-slab", "rand 0.9.2", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -3062,9 +4510,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -3162,7 +4610,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] @@ -3171,7 +4619,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -3192,11 +4640,17 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "rayon" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", @@ -3204,9 +4658,9 @@ dependencies = [ [[package]] name = "rayon-core" -version = "1.12.1" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", @@ -3232,11 +4686,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.13" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.11.0", ] [[package]] @@ -3245,65 +4699,50 @@ version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] [[package]] name = "redox_users" -version = "0.5.0" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.13", - "regex-syntax 0.8.5", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax 0.6.29", + "regex-automata", + "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.5", + "regex-syntax", ] [[package]] name = "regex-syntax" -version = "0.6.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" - -[[package]] -name = "regex-syntax" -version = "0.8.5" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -3320,7 +4759,7 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", @@ -3351,15 +4790,16 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", "webpki-roots", ] [[package]] name = "reqwest_cookie_store" -version = "0.8.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b36498c7452f11b1833900f31fbb01fc46be20992a50269c88cf59d79f54e9" +checksum = "2314c325724fea278d44c13a525ebf60074e33c05f13b4345c076eb65b2446b3" dependencies = [ "bytes", "cookie_store", @@ -3375,17 +4815,17 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] [[package]] -name = "rustc-demangle" -version = "0.1.27" +name = "rustc-hash" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" @@ -3404,11 +4844,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -3431,9 +4871,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -3458,9 +4898,9 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -3478,11 +4918,11 @@ source = "git+https://github.com/StirlingMouse/sanitize-filename.git#2861a903d9e [[package]] name = "schannel" -version = "0.1.27" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3524,12 +4964,12 @@ dependencies = [ [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.9.1", - "core-foundation", + "bitflags 2.11.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -3537,9 +4977,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.14.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -3553,7 +4993,7 @@ checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" dependencies = [ "bitflags 1.3.2", "cssparser 0.27.2", - "derive_more", + "derive_more 0.99.20", "fxhash", "log", "matches", @@ -3571,26 +5011,36 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd568a4c9bb598e291a08244a5c1f5a8a6650bee243b5b0f8dbb3d9cc1d87fe8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.11.0", "cssparser 0.34.0", - "derive_more", + "derive_more 0.99.20", "fxhash", "log", "new_debug_unreachable", "phf 0.11.3", "phf_codegen 0.11.3", "precomputed-hash", - "servo_arc 0.4.1", + "servo_arc 0.4.3", "smallvec", ] [[package]] name = "semver" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" dependencies = [ "serde", + "serde_core", +] + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +dependencies = [ + "futures-core", ] [[package]] @@ -3613,6 +5063,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -3630,43 +5091,55 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] name = "serde_html_form" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d2de91cf02bbc07cde38891769ccd5d4f073d22a40683aa4bc7a95781aaa2c4" +checksum = "b2f2d7ff8a2140333718bb329f5c40fc5f0865b84c426183ce14c97d2ab8154f" dependencies = [ "form_urlencoded", "indexmap", - "itoa 1.0.15", + "itoa 1.0.17", "ryu", - "serde", + "serde_core", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "itoa 1.0.15", + "itoa 1.0.17", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] name = "serde_path_to_error" -version = "0.1.17" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa 1.0.17", + "serde", + "serde_core", +] + +[[package]] +name = "serde_qs" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352" dependencies = [ - "itoa 1.0.15", + "percent-encoding", "serde", + "thiserror 2.0.18", ] [[package]] @@ -3687,7 +5160,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -3701,11 +5174,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -3715,7 +5188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ "form_urlencoded", - "itoa 1.0.15", + "itoa 1.0.17", "ryu", "serde", ] @@ -3732,9 +5205,9 @@ dependencies = [ [[package]] name = "servo_arc" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "204ea332803bd95a0b60388590d59cf6468ec9becf626e2451f1d26a1d972de4" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ "stable_deref_trait", ] @@ -3750,6 +5223,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3767,10 +5251,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.5" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -3794,9 +5279,9 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "siphasher" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "skeptic" @@ -3815,9 +5300,48 @@ dependencies = [ [[package]] name = "slab" -version = "0.4.10" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "sledgehammer_bindgen" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e83e178d176459c92bc129cfd0958afac3ced925471b889b3a75546cfc4133" +dependencies = [ + "sledgehammer_bindgen_macro", + "wasm-bindgen", +] + +[[package]] +name = "sledgehammer_bindgen_macro" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb251b407f50028476a600541542b605bb864d35d9ee1de4f6cab45d88475e6d" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sledgehammer_utils" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdd4b83524961983cea3c55383b3910fd2f24fd13a188f5b091d2d504a61ae" +dependencies = [ + "rustc-hash 1.1.0", +] + +[[package]] +name = "slotmap" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] [[package]] name = "smallvec" @@ -3837,19 +5361,25 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "string_cache" @@ -3888,6 +5418,34 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7986063f7c0ab374407e586d7048a3d5aac94f103f751088bf398e07cd5400" +[[package]] +name = "subsecond" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" +dependencies = [ + "js-sys", + "libc", + "libloading", + "memfd", + "memmap2", + "serde", + "subsecond-types", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "subsecond-types" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e72f747606fc19fe81d6c59e491af93ed7dcbcb6aad9d1d18b05129914ec298" +dependencies = [ + "serde", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3907,9 +5465,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.115" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3933,17 +5491,17 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] name = "system-configuration" -version = "0.6.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.9.1", - "core-foundation", + "bitflags 2.11.0", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3959,12 +5517,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.24.0" +version = "3.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4009,11 +5567,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -4024,18 +5582,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -4049,32 +5607,32 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa 1.0.15", + "itoa 1.0.17", "libc", "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -4091,9 +5649,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -4116,32 +5674,29 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2 0.6.0", + "socket2 0.6.2", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -4176,15 +5731,41 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.27.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", +] + [[package]] name = "tokio-util" -version = "0.7.15" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -4198,19 +5779,19 @@ dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", - "toml_edit", + "toml_edit 0.22.27", ] [[package]] name = "toml" -version = "0.9.2" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap", - "serde", - "serde_spanned 1.0.0", - "toml_datetime 0.7.0", + "serde_core", + "serde_spanned 1.0.4", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", @@ -4227,32 +5808,44 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.0" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ + "indexmap", "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow", ] [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", "winnow", ] [[package]] name = "toml_parser" -version = "1.0.1" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ "winnow", ] @@ -4265,15 +5858,15 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.2" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -4287,15 +5880,15 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.11.0", "bytes", "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "http-range-header", @@ -4327,9 +5920,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -4339,37 +5932,47 @@ dependencies = [ [[package]] name = "tracing-appender" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror 1.0.69", + "thiserror 2.0.18", "time", "tracing-subscriber", ] [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", ] +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -4393,14 +5996,14 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", "once_cell", - "regex", + "regex-automata", "sharded-slab", "smallvec", "thread_local", @@ -4410,6 +6013,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tracing-wasm" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4575c663a174420fa2d78f4108ff68f65bf2fbb7dd89f33749b6e826b3626e07" +dependencies = [ + "tracing", + "tracing-subscriber", + "wasm-bindgen", +] + [[package]] name = "tray-item" version = "0.10.0" @@ -4432,11 +6046,45 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http 1.4.0", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "uncased" @@ -4449,21 +6097,27 @@ dependencies = [ [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" @@ -4485,9 +6139,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -4515,13 +6169,13 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.17.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.4.1", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -4588,6 +6242,28 @@ dependencies = [ "try-lock", ] +[[package]] +name = "warnings" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64f68998838dab65727c9b30465595c6f7c953313559371ca8bf31759b3680ad" +dependencies = [ + "pin-project", + "tracing", + "warnings-macro", +] + +[[package]] +name = "warnings-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59195a1db0e95b920366d949ba5e0d3fc0e70b67c09be15ce5abb790106b0571" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" @@ -4601,19 +6277,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" dependencies = [ "cfg-if", "once_cell", @@ -4624,9 +6309,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" dependencies = [ "cfg-if", "futures-util", @@ -4638,9 +6323,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4648,31 +6333,78 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" dependencies = [ "js-sys", "wasm-bindgen", @@ -4688,6 +6420,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -4715,11 +6459,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4729,10 +6473,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows-link" -version = "0.1.3" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "windows-link" @@ -4742,31 +6515,40 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-registry" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3bab093bdd303a1240bb99b8aba8ea8a69ee19d34c9e2ef9594e708a4878820" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", + "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" dependencies = [ - "windows-link 0.1.3", + "windows-targets 0.42.2", ] [[package]] @@ -4793,7 +6575,7 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.2", + "windows-targets 0.53.5", ] [[package]] @@ -4802,7 +6584,22 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] @@ -4823,20 +6620,27 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.2" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4845,9 +6649,15 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] name = "windows_aarch64_msvc" @@ -4857,9 +6667,15 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] name = "windows_i686_gnu" @@ -4869,9 +6685,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -4881,9 +6697,15 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] name = "windows_i686_msvc" @@ -4893,9 +6715,15 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" [[package]] name = "windows_x86_64_gnu" @@ -4905,9 +6733,15 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" [[package]] name = "windows_x86_64_gnullvm" @@ -4917,9 +6751,15 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" [[package]] name = "windows_x86_64_msvc" @@ -4929,15 +6769,15 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.11" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -4959,19 +6799,104 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c46f2d48eb39642e0f57a74637e041c69af704c6a8afaab03558201f64d8870" [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ - "bitflags 2.9.1", + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", ] [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xxhash-rust" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" [[package]] name = "yansi" @@ -4981,11 +6906,10 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -4993,34 +6917,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.26" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.26" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] [[package]] @@ -5040,21 +6964,21 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -5063,9 +6987,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -5074,11 +6998,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.115", + "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 5df95d3a..e0bdb3b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,14 +8,38 @@ members = [ "mlm_meta", "mlm_core", "mlm_web_askama", + "mlm_web_dioxus", ] # Faster dev builds for WASM [profile.dev.package."*"] -opt-level = 1 +opt-level = 0 +debug = 0 + +# Workspace crates should be opt-level = 0 for faster builds +[profile.dev.package.mlm] +opt-level = 0 +[profile.dev.package.mlm_core] +opt-level = 0 +[profile.dev.package.mlm_db] +opt-level = 0 +[profile.dev.package.mlm_mam] +opt-level = 0 +[profile.dev.package.mlm_meta] +opt-level = 0 +[profile.dev.package.mlm_parse] +opt-level = 0 +[profile.dev.package.mlm_web_askama] +opt-level = 0 + +# Optimize only the WASM package slightly in dev mode for usable execution speed +# Set to 0 for fastest rebuilds, 1 if execution is too slow +[profile.dev.package.mlm_web_dioxus] +opt-level = 0 # Keep debug builds fast for the server [profile.dev] opt-level = 0 -codegen-units = 256 +codegen-units = 32 debug = 1 +split-debuginfo = "unpacked" diff --git a/Dockerfile b/Dockerfile index 172f9cca..cd599b58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,16 +5,28 @@ FROM rust:1.91 AS build -RUN cargo new --lib app/mlm_db -RUN cargo new --lib app/mlm_mam -RUN cargo new --lib app/mlm_parse -RUN cargo new --bin app/server +RUN < +# Run `just` or `just --list` to see available commands. + +# Variables +dioxus_dir := "mlm_web_dioxus" + +# Default: show help +default: + @just --list + +# ============================================================================ +# Development (Hot Reloading) +# ============================================================================ + +# Run Dioxus fullstack dev server with hot patching. +# Loads MLM config and enables server functions during development. +dx-dev: + cd {{dioxus_dir}} && dx serve --fullstack --web --addr 127.0.0.1 + +# Run Dioxus fullstack dev server bound for LAN access +dx-dev-lan: + cd {{dioxus_dir}} && dx serve --fullstack --web --addr 0.0.0.0 + +# Alias for dx-dev +dev: dx-dev + +# ============================================================================ +# Building +# ============================================================================ + +# Build WASM bundle (debug) - fullstack mode for SSR hydration +dx-build: + cd {{dioxus_dir}} && dx build --fullstack + +# Clean Dioxus crate build artifacts +dx-clean: + cargo clean -p mlm_web_dioxus + +# Build WASM bundle (release) +dx-build-release: + cd {{dioxus_dir}} && dx build --fullstack --release + +# Build the complete server (debug) +build: + cargo build + +# Build the complete server (release) +build-release: + cargo build --release + +# ============================================================================ +# Running +# ============================================================================ + +# Run the main server binary +run: + cargo run --bin mlm + +# Build WASM (debug, skip unchanged static assets) then run server - fastest full app loop +serve: + cd {{dioxus_dir}} && dx build --fullstack --skip-assets + cargo run --bin mlm + +# Build WASM (debug, full asset copy) then run server +serve-full: + cd {{dioxus_dir}} && dx build --fullstack + cargo run --bin mlm + +# Build WASM (release) then run server (release) +serve-release: dx-build-release build-release + cargo run --bin mlm --release + +# ============================================================================ +# Quality +# ============================================================================ + +# Run clippy +lint: + cargo clippy -- -D warnings + +# Run format check +fmt-check: + cargo fmt --check + +# Run format +fmt: + cargo fmt + +# Run clippy and format check +check: lint fmt-check + +# Run tests +test: + cargo test + +# Run e2e Playwright tests (requires a prior `just serve` to have built the WASM) +e2e: + pnpm exec playwright test + +# Build server + test-db fixture binary, then run e2e tests +e2e-build: + cd {{dioxus_dir}} && dx build --fullstack --skip-assets + cargo build --bin mlm --bin create_test_db + pnpm exec playwright test + +# ============================================================================ +# Cleanup +# ============================================================================ + +# Clean build artifacts +clean: + cargo clean + rm -rf target/dx diff --git a/mlm_core/src/audiobookshelf.rs b/mlm_core/src/audiobookshelf.rs index 82de20ad..449f7154 100644 --- a/mlm_core/src/audiobookshelf.rs +++ b/mlm_core/src/audiobookshelf.rs @@ -3,6 +3,7 @@ use std::{collections::BTreeSet, path::PathBuf, sync::Arc}; use anyhow::Result; use axum::http::HeaderMap; use mlm_db::{DatabaseExt as _, Flags, Torrent, TorrentMeta, ids, impls::format_serie}; +use mlm_parse::clean_html; use native_db::Database; use reqwest::{Url, header::AUTHORIZATION}; use serde::{Deserialize, Serialize}; @@ -628,7 +629,7 @@ impl Abs { }) .collect(), narrators: meta.narrators.iter().map(|name| name.as_str()).collect(), - description: Some(&meta.description), + description: Some(&clean_html(&meta.description)), isbn: meta.ids.get(ids::ISBN).map(|s| s.as_str()), asin: meta.ids.get(ids::ASIN).map(|s| s.as_str()), genres: meta diff --git a/mlm_core/src/autograbber.rs b/mlm_core/src/autograbber.rs index 3f18591f..016f6e36 100644 --- a/mlm_core/src/autograbber.rs +++ b/mlm_core/src/autograbber.rs @@ -767,13 +767,20 @@ pub fn queue_torrent_meta_update( let id = torrent.id.clone(); let diff = torrent.meta.diff(&meta); - debug!( - "Updating meta for torrent {}, diff:\n{}", - id, - diff.iter() - .map(|field| format!(" {}: {} → {}", field.field, field.from, field.to)) - .join("\n") - ); + if diff.is_empty() { + debug!( + "Updating meta for torrent {}, old:\n{:?}\nnew:\n{:?}", + id, torrent.meta, meta + ); + } else { + debug!( + "Updating meta for torrent {}, diff:\n{}", + id, + diff.iter() + .map(|field| format!(" {}: {} -> {}", field.field, field.from, field.to)) + .join("\n") + ); + } torrent.meta = meta.clone(); torrent.title_search = normalize_title(&meta.title); rw.upsert(torrent.clone())?; diff --git a/mlm_db/src/impls/meta.rs b/mlm_db/src/impls/meta.rs index e77693aa..a199ee9b 100644 --- a/mlm_db/src/impls/meta.rs +++ b/mlm_db/src/impls/meta.rs @@ -109,6 +109,13 @@ impl TorrentMeta { to: other.categories.iter().map(ToString::to_string).join(", "), }); } + if self.tags != other.tags { + diff.push(TorrentMetaDiff { + field: TorrentMetaField::Tags, + from: self.tags.join(", "), + to: other.tags.join(", "), + }); + } if self.language != other.language { diff.push(TorrentMetaDiff { field: TorrentMetaField::Language, diff --git a/mlm_mam/src/api.rs b/mlm_mam/src/api.rs index 47719ed1..6d5b7938 100644 --- a/mlm_mam/src/api.rs +++ b/mlm_mam/src/api.rs @@ -3,6 +3,13 @@ use std::{ time::{Duration, SystemTime}, }; +fn mam_base_url() -> String { + std::env::var("MLM_MAM_BASE_URL").unwrap_or_else(|_| "https://www.myanonamouse.net".to_string()) +} +fn mam_cdn_base_url() -> String { + std::env::var("MLM_MAM_BASE_URL").unwrap_or_else(|_| "https://cdn.myanonamouse.net".to_string()) +} + use anyhow::{Error, Result, bail}; use bytes::Bytes; use cookie::Cookie; @@ -73,7 +80,7 @@ pub struct MaM<'a> { impl<'a> MaM<'a> { pub async fn new(mam_id: &str, db: Arc>) -> Result> { let jar: CookieStoreRwLock = Default::default(); - let url = "https://www.myanonamouse.net/json".parse::().unwrap(); + let url = format!("{}/json", mam_base_url()).parse::().unwrap(); let stored_mam_id = db .r_transaction() @@ -129,7 +136,7 @@ impl<'a> MaM<'a> { pub async fn check_mam_id(&self) -> Result<()> { let resp = self .client - .get("https://www.myanonamouse.net/json/checkCookie.php") + .get(format!("{}/json/checkCookie.php", mam_base_url())) .send() .await?; @@ -158,7 +165,10 @@ impl<'a> MaM<'a> { } let resp: UserResponse = self .client - .get("https://www.myanonamouse.net/jsonLoad.php?snatch_summary=true") + .get(format!( + "{}/jsonLoad.php?snatch_summary=true", + mam_base_url() + )) .send() .await? .error_for_status() @@ -187,9 +197,7 @@ impl<'a> MaM<'a> { pub async fn get_torrent_file(&self, dl_hash: &str) -> Result { let resp = self .client - .get(format!( - "https://www.myanonamouse.net/tor/download.php/{dl_hash}" - )) + .get(format!("{}/tor/download.php/{dl_hash}", mam_base_url())) .send() .await? .error_for_status() @@ -246,7 +254,7 @@ impl<'a> MaM<'a> { debug!("search: {}", serde_json::to_string_pretty(query)?); let resp = self .client - .post("https://www.myanonamouse.net/tor/js/loadSearchJSONbasic.php") + .post(format!("{}/tor/js/loadSearchJSONbasic.php", mam_base_url())) .json(query) .send() .await? @@ -282,7 +290,10 @@ impl<'a> MaM<'a> { let user = self.user_info().await?; let resp = self .client - .get("https://cdn.myanonamouse.net/json/loadUserDetailsTorrents.php") + .get(format!( + "{}/json/loadUserDetailsTorrents.php", + mam_cdn_base_url() + )) .query(&( ("uid", user.uid.to_string()), ("iteration", page.to_string()), @@ -307,9 +318,7 @@ impl<'a> MaM<'a> { let timestamp = UtcDateTime::now().unix_timestamp() * 1000; let resp: BonusBuyResult = self .client - .get(format!( - "https://www.myanonamouse.net/json/bonusBuy.php/{timestamp}" - )) + .get(format!("{}/json/bonusBuy.php/{timestamp}", mam_base_url())) .query(&[ ("spendtype", "personalFL"), ("torrentid", mam_id.to_string().as_str()), diff --git a/mlm_parse/Cargo.toml b/mlm_parse/Cargo.toml index bc16e0af..3faa481e 100644 --- a/mlm_parse/Cargo.toml +++ b/mlm_parse/Cargo.toml @@ -4,8 +4,10 @@ version = "0.1.0" edition = "2024" [dependencies] +ammonia = { version = "4.1" } anyhow = "1.0.100" htmlentity = "1.3.2" once_cell = "1.21.3" +maplit = { version = "1" } regex = "1.12.2" unidecode = "0.3.0" diff --git a/mlm_parse/src/html.rs b/mlm_parse/src/html.rs new file mode 100644 index 00000000..e87ec233 --- /dev/null +++ b/mlm_parse/src/html.rs @@ -0,0 +1,21 @@ +use maplit::hashset; +use once_cell::sync::Lazy; + +pub static AMMONIA: Lazy> = Lazy::new(|| { + let mut builder = ammonia::Builder::default(); + builder + .url_schemes(hashset!["http", "https"]) + .add_generic_attributes(hashset!["style"]) + .filter_style_properties(hashset![ + "font-style", + "font-weight", + "text-align", + "text-decoration" + ]) + .attribute_filter(|element, attribute, value| match (element, attribute) { + ("a", "href") => None, + ("img", "src") => value.starts_with("http").then_some(value.into()), + _ => Some(value.into()), + }); + builder +}); diff --git a/mlm_parse/src/lib.rs b/mlm_parse/src/lib.rs index 4bbfa81e..508d4924 100644 --- a/mlm_parse/src/lib.rs +++ b/mlm_parse/src/lib.rs @@ -1,13 +1,21 @@ +mod html; + use anyhow::Result; use htmlentity::entity::{self, ICodedDataTrait as _}; use once_cell::sync::Lazy; use regex::{Captures, Match, Regex}; use unidecode::unidecode; +use crate::html::AMMONIA; + pub fn clean_value(value: &str) -> Result { entity::decode(value.as_bytes()).to_string() } +pub fn clean_html(value: &str) -> String { + AMMONIA.clean(value).to_string() +} + pub fn normalize_title(value: &str) -> String { let title = unidecode(value).to_lowercase().replace(" & ", " and "); let title = SEARCH_TITLE_CLEANUP.replace_all(&title, ""); diff --git a/mlm_web_askama/src/lib.rs b/mlm_web_askama/src/lib.rs index 228e0b1c..d7d8d76b 100644 --- a/mlm_web_askama/src/lib.rs +++ b/mlm_web_askama/src/lib.rs @@ -9,9 +9,10 @@ use askama::{Template, filters::HtmlSafe}; use axum::{ Router, body::Body, + extract::OriginalUri, http::{HeaderValue, Request, StatusCode}, middleware::{self, Next}, - response::{Html, IntoResponse, Response}, + response::{Html, IntoResponse, Redirect, Response}, routing::{get, post}, }; use itertools::Itertools; @@ -22,7 +23,6 @@ use mlm_db::{ use mlm_mam::{api::MaM, meta::MetaError, search::MaMTorrent, serde::DATE_FORMAT}; use once_cell::sync::Lazy; use pages::{ - config::{config_page, config_page_post}, duplicate::{duplicate_page, duplicate_torrents_page_post}, errors::{errors_page, errors_page_post}, events::event_page, @@ -44,7 +44,7 @@ use time::{ use tokio::sync::watch::error::SendError; use tower::ServiceBuilder; #[allow(unused)] -use tower_http::services::{ServeDir, ServeFile}; +pub use tower_http::services::{ServeDir, ServeFile}; use crate::{ api::{ @@ -133,11 +133,8 @@ pub fn router(context: Context) -> Router { "/duplicate", post(duplicate_torrents_page_post).with_state(context.clone()), ) - .route("/config", get(config_page).with_state(context.clone())) - .route( - "/config", - post(config_page_post).with_state(context.clone()), - ) + .route("/config", get(config_redirect)) + .route("/config", post(config_redirect)) .route( "/api/search", get(search_api).with_state(Arc::new(context.mam())), @@ -165,9 +162,31 @@ pub fn router(context: Context) -> Router { .service(ServeFile::new("server/assets/favicon_dev.png")), ); + #[cfg(debug_assertions)] + let app = app.nest_service( + "/favicon.ico", + ServiceBuilder::new() + .layer(middleware::from_fn(set_static_cache_control)) + .service(ServeFile::new("server/assets/favicon_dev.png")), + ); + + #[cfg(not(debug_assertions))] + let app = app.nest_service( + "/favicon.ico", + ServiceBuilder::new() + .layer(middleware::from_fn(set_static_cache_control)) + .service(ServeFile::new("server/assets/favicon.png")), + ); + app } +async fn config_redirect(uri: OriginalUri) -> Redirect { + let current = uri.path_and_query().map_or("/config", |pq| pq.as_str()); + let target = current.replacen("/config", "/dioxus/config", 1); + Redirect::to(&target) +} + pub trait Page { fn build_date(&self) -> &'static str { env!("DATE") @@ -232,6 +251,7 @@ async fn set_static_cache_control(request: Request, next: Next) -> Respons /// [ {% for v in values %}{{ v | json }}{% if !loop.last %}, {% endif %}{% endfor %} ] /// {% endif %} /// ``` +#[allow(dead_code)] #[derive(Template)] #[template(ext = "html", in_doc = true)] struct YamlItems<'a, V: Serialize> { @@ -241,6 +261,7 @@ struct YamlItems<'a, V: Serialize> { impl<'a, V: Serialize> HtmlSafe for YamlItems<'a, V> {} +#[allow(dead_code)] fn yaml_items<'a, V: Serialize>(values: &'a [V]) -> YamlItems<'a, V> { YamlItems { values, @@ -248,6 +269,7 @@ fn yaml_items<'a, V: Serialize>(values: &'a [V]) -> YamlItems<'a, V> { } } +#[allow(dead_code)] fn yaml_nums<'a, V: Serialize>(values: &'a [V]) -> YamlItems<'a, V> { YamlItems { values, @@ -255,6 +277,7 @@ fn yaml_nums<'a, V: Serialize>(values: &'a [V]) -> YamlItems<'a, V> { } } +#[allow(dead_code)] fn date(date: &Date) -> String { date.format(&DATE_FORMAT).unwrap_or_default() } @@ -324,6 +347,7 @@ impl<'a, T: Key> SeriesTmpl<'a, T> { impl<'a, T: Key> HtmlSafe for SeriesTmpl<'a, T> {} +#[allow(dead_code)] #[derive(Template)] #[template(path = "partials/filter.html")] struct FilterTemplate<'a> { @@ -331,6 +355,7 @@ struct FilterTemplate<'a> { } impl<'a> HtmlSafe for FilterTemplate<'a> {} +#[allow(dead_code)] fn filter<'a>(filter: &'a TorrentFilter) -> FilterTemplate<'a> { FilterTemplate { filter } } diff --git a/mlm_web_askama/src/pages/config.rs b/mlm_web_askama/src/pages/config.rs index b170bed3..81e4736b 100644 --- a/mlm_web_askama/src/pages/config.rs +++ b/mlm_web_askama/src/pages/config.rs @@ -1,3 +1,7 @@ +// TODO: Remove this temporary allow once the Askama config page is fully retired +// after the Dioxus port has stabilized. +#![allow(dead_code)] + use std::{ops::Deref, sync::Arc}; use anyhow::Result; diff --git a/mlm_web_dioxus/Cargo.toml b/mlm_web_dioxus/Cargo.toml new file mode 100644 index 00000000..39817d58 --- /dev/null +++ b/mlm_web_dioxus/Cargo.toml @@ -0,0 +1,72 @@ +[package] +name = "mlm_web_dioxus" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["rlib", "cdylib"] + +[[bin]] +name = "mlm_web_dioxus" +path = "src/main.rs" + +[dependencies] +dioxus = { version = "0.7", features = ["fullstack", "router"] } +# Explicit dep needed for `dioxus_fullstack::FullstackContext` path references; +# transitive deps cannot be named directly without a Cargo.toml entry. +dioxus-fullstack = "0.7" +lucide-dioxus = { version = "2.562.0", features = [ + "account", + "multimedia", + "text", +] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +anyhow = "1.0" +tracing = "0.1" +wasm-bindgen = "0.2" +web-sys = { version = "0.3", features = ["EventSource", "MessageEvent"] } +urlencoding = "2.1" +time = { version = "0.3.41", features = [ + "formatting", + "local-offset", + "macros", + "serde", +] } + +# Server-side dependencies +axum = { version = "0.8", optional = true } +tokio = { version = "1.45", features = ["macros", "net", "rt-multi-thread", "sync", "time"], optional = true } +tower-http = { version = "0.6", features = ["fs"], optional = true } +tokio-stream = { version = "0.1", optional = true } +mlm_core = { path = "../mlm_core", optional = true } +mlm_db = { path = "../mlm_db", optional = true } +mlm_mam = { path = "../mlm_mam", optional = true } +mlm_parse = { path = "../mlm_parse", optional = true } +itertools = { version = "0.14", optional = true } +native_db = { git = "https://github.com/StirlingMouse/native_db.git", branch = "0.8.x", optional = true } +sublime_fuzzy = { version = "0.7", optional = true } +qbit = { git = "https://github.com/StirlingMouse/qbittorrent-webui-api.git", optional = true } +figment = { version = "0.10", features = ["toml", "env"], optional = true } +tracing-subscriber = { version = "0.3", optional = true } + +[features] +server = [ + "dioxus/server", + "dioxus/cli-config", + "dep:axum", + "dep:tokio", + "dep:tower-http", + "dep:tokio-stream", + "dep:mlm_core", + "dep:mlm_db", + "dep:mlm_mam", + "dep:mlm_parse", + "dep:itertools", + "dep:native_db", + "dep:sublime_fuzzy", + "dep:qbit", + "dep:figment", + "dep:tracing-subscriber", +] +web = ["dioxus/web"] diff --git a/mlm_web_dioxus/Dioxus.toml b/mlm_web_dioxus/Dioxus.toml new file mode 100644 index 00000000..279a828d --- /dev/null +++ b/mlm_web_dioxus/Dioxus.toml @@ -0,0 +1,13 @@ +[application] +name = "mlm_web_dioxus" +asset_dir = "../server/assets" + +[web.app] +title = "MLM" + +[web.watcher] +index_on_404 = true + +[bundle] +identifier = "com.mlm.dioxus" +publisher = "mlm" diff --git a/mlm_web_dioxus/assets/style.css b/mlm_web_dioxus/assets/style.css new file mode 100644 index 00000000..7c6f4f97 --- /dev/null +++ b/mlm_web_dioxus/assets/style.css @@ -0,0 +1,902 @@ +body { + font-family: Arial, sans-serif; + --color-1: #2a2438; + --color-2: #352f44; + --color-3: #5c5470; + --color-4: #dbd8e3; + + --background: var(--color-1); + --above: var(--color-2); + --text-faint: var(--color-3); + --text: var(--color-4); + --accent: hsl(331.8, 91.3%, 45%); + --accent-above: hsl(331.8, 91.3%, 55%); + --warn: #e08067; + + background: var(--background); + color: var(--text); +} + +#main > nav, +.links.links { + display: flex; + gap: 4px; +} + +a { + color: currentColor; + text-decoration-color: transparent; + + &:hover { + text-decoration-color: currentColor; + } + &:focus-visible { + text-decoration-color: currentColor; + } +} + + +ul { + margin-top: 0; + padding-left: 1em; +} + +nav > a { + padding: 4px; + background: var(--above); +} + +.row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + + h1 { + flex: 1; + } +} + +.actions { + display: none; + gap: 8px; +} + +a.btn, +button { + appearance: none; + padding: 4px 8px; + border: none; + border-radius: 2px; + font-size: inherit; + color: var(--text); + background: var(--above); + cursor: pointer; + + &[data-prompt] { + color: var(--warn); + } + + &.link { + display: inline; + padding: 0; + background: transparent; + text-align: left; + + &&:hover { + background: transparent; + text-decoration: underline; + } + + &:has(+ .link)::after { + content: ", "; + display: inline-block; + padding-right: 4px; + } + } + + &.icon, + &:has(> img:only-child) { + padding: 4px; + width: 28px; + height: 28px; + background: transparent; + + img { + width: 20px; + height: 20px; + } + } + + &&:hover { + background: var(--color-3); + } + &&:focus-visible { + background: var(--color-3); + } + + &.danger { + color: var(--warn); + border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); + } +} + +form { + + textarea { + appearance: none; + padding: 4px 8px; + border: none; + border-radius: 2px; + font-size: inherit; + color: var(--text); + background: var(--above); + width: max(220px, 20vw); + + &:focus { + background: var(--color-3); + outline: 2px solid var(--accent); + } + } + input[type=text] { + appearance: none; + padding: 4px 8px; + border: none; + border-radius: 2px; + font-size: inherit; + color: var(--text); + background: var(--above); + width: max(220px, 20vw); + + &:focus { + background: var(--color-3); + outline: 2px solid var(--accent); + } + } + input[type=number] { + appearance: none; + padding: 4px 8px; + border: none; + border-radius: 2px; + font-size: inherit; + color: var(--text); + background: var(--above); + width: 40px; + + &:focus { + background: var(--color-3); + outline: 2px solid var(--accent); + } + } + + button[is="clear-button"] { + position: absolute; + display: none; + margin-top: 4px; + margin-left: -4px; + transform: translateX(-100%); + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + font-size: inherit; + color: var(--text); + cursor: pointer; + + &::after { + content: "⨯"; + transform: translateY(-2px); + } + + &:hover { + background: var(--color-3); + } + &:focus-visible { + background: var(--color-3); + } + } + + &.page { + display: flex; + flex-direction: column; + gap: 16px; + + label { + display: flex; + + span { + display: inline-block; + width: 140px; + } + } + } +} + +select { + appearance: base-select; + border: none; + padding: 2px 4px; + background: var(--above); + color: var(--text); + border-radius: 2px; +} + +summary { + cursor: pointer; + user-select: none; +} + +.details-summary { + display: block; + padding: 0.3em 0.6em; + margin-bottom: 0.4em; + font-weight: bold; + border-left: 3px solid var(--color-3); + cursor: pointer; + user-select: none; +} + +details[open] > .details-summary { + border-left-color: var(--accent); +} + +.table_options { + display: flex; + gap: 16px; +} + +.option_group { + display: flex; + gap: 4px; + + & > div { + display: flex; + gap: 4px; + flex-wrap: wrap; + } + + label { + padding: 2px 4px; + background: var(--above); + border-radius: 2px; + &:has(:checked) { + background: var(--accent); + } + } + + input { + display: none; + } +} + +.column_selector { + position: relative; +} + +.column_selector_dropdown { + position: relative; +} + +.column_selector_backdrop { + position: fixed; + inset: 0; + z-index: 39; + background: transparent; +} + +.column_selector_trigger { + position: relative; + z-index: 41; + padding: 2px 8px; + border: none; + border-radius: 2px; + color: var(--text); + background: var(--above); + white-space: nowrap; +} + +.column_selector_trigger:hover, +.column_selector_trigger:focus-visible { + background: var(--color-3); +} + +.column_selector_menu { + position: absolute; + z-index: 40; + top: calc(100% + 8px); + left: 0; + display: grid; + gap: 4px; + min-width: 230px; + max-height: min(55vh, 340px); + overflow: auto; + padding: 8px; + border: 1px solid var(--color-3); + border-radius: 6px; + background: var(--background); + box-shadow: 0 8px 20px color-mix(in srgb, black 32%, transparent); + transform-origin: top left; + animation: column-menu-in 150ms ease-out; +} + +.column_selector_option { + display: grid; + grid-template-columns: min-content 1fr; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 4px; +} + +.column_selector_option input { + display: block; +} + +.column_selector_option:hover { + background: var(--color-3); +} + +@keyframes column-menu-in { + from { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.pagination { + position: sticky; + bottom: 0; + display: grid; + grid-template-columns: min-content min-content auto min-content min-content; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px; + border-top: 1px solid currentColor; + background: var(--background); + + > div { + display: flex; + gap: 4px; + } + a { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 4px; + background: var(--above); + border-radius: 50%; + + &:hover { + text-decoration: none; + background: var(--color-3); + } + &:focus-visible { + text-decoration: none; + background: var(--color-3); + } + } + .active { + background: var(--accent); + + &:hover { + background: var(--accent-above); + } + &:focus-visible { + background: var(--accent-above); + } + } + .disabled { + color: var(--color-3); + background: var(--above) !important; + } +} + +.table { + display: grid; + --alternate: var(--above); + overflow-wrap: break-word; + + & > .header, & > div { + display: block; + padding: 4px; + } + + & > .header { + position: sticky; + top: 0; + font-weight: bold; + border-bottom: 1px solid currentColor; + background: var(--background); + } +} + +.table2 { + --alternate: var(--above); + overflow-wrap: break-word; + + &.MaMTorrentsTable { + margin: 0 -8px; + } + + &.MaMTorrentsTable > div { + grid-template-columns: 72px 54px 1fr 32px 84px 130px 64px; + + & > div { + padding: 8px 4px; + } + & > div:nth-child(1n+4) { + text-align: center; + } + & > div:first-of-type { + padding-left: 12px; + } + & > div:last-of-type { + text-align: right; + padding-right: 12px; + } + } + + & > div { + display: grid; + + &&&:first-of-type { + align-items: end; + background: var(--background); + } + &:nth-child(even) { + background: var(--alternate); + } + + & > .header, & > div { + display: block; + padding: 4px; + } + + & > .header { + position: sticky; + top: 0; + font-weight: bold; + border-bottom: 1px solid currentColor; + background: var(--background); + } + } + + &:not(.nohover) > div:hover { + background: var(--color-3); + } +} + +.TorrentsTable > .torrents-grid-row { + grid-template-columns: var(--torrents-grid); +} + +.TorrentsTable.is-refreshing { + position: relative; +} + +.TorrentsTable.is-refreshing > .torrents-grid-row { + animation: stale-table-fade 500ms ease 500ms forwards; +} + +.stale-refresh-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + opacity: 0; + animation: stale-overlay-reveal 1ms linear 1000ms forwards; +} + +.stale-refresh-spinner { + width: 36px; + height: 36px; + border: 3px solid var(--color-3); + border-top-color: var(--accent); + border-radius: 50%; + animation: stale-spinner-spin 900ms linear infinite; +} + +@keyframes stale-table-fade { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes stale-overlay-reveal { + to { + opacity: 1; + } +} + +@keyframes stale-spinner-spin { + to { + transform: rotate(360deg); + } +} + +.list_item { + display: grid; + grid-template-columns: auto 1fr; + margin: 24px; + gap: 16px; + + img { + width: 64px; + } + h3 { + margin: 0; + } + p { + margin: 0.5em 0; + } + .author { + margin-top: 0; + font-style: italic; + } +} + +.torrent { + font-weight: bold; +} + +.faint { + opacity: 0.8; +} +.missing { + color: var(--warn); +} +.warn { + color: var(--warn); +} + +.configbox { + font-family: monospace; + + h3 { + margin-bottom: 0; + } + h4 { + margin-bottom: 0; + } + .string { + color: #b5bd68; + } + .num { + color: #de935f; + } +} + +.infoboxes { + display: flex; + flex-wrap: wrap; + gap: 16px; + max-width: min(932px, 100%); + + .infobox { + width: min(300px, 100%); + } +} + +.item { + display: inline-block; + margin: 2px 0; + padding: 2px 4px; + border-radius: 4px; + background-color: #aa86b72e; + + & + & { + margin-left: 4px; + } +} + +.loading-indicator { + display: inline-block; + padding: 4px 8px; + margin-bottom: 8px; + font-style: italic; + color: var(--text-faint); + background: var(--above); + border-radius: 2px; +} + +.torrent-detail-grid { + display: grid; + grid-template-columns: 1fr 2fr; + grid-template-areas: + "side main" + "side description" + "below below"; + gap: 1em; +} + +.torrent-side { + grid-area: side; +} + +.abs-cover { + width: 100%; + aspect-ratio: 1 / 1; + margin-bottom: 0.8em; + display: flex; + align-items: center; + justify-content: center; + background: var(--above); + border: 1px solid var(--color-3); + border-radius: 4px; + overflow: hidden; +} + +.abs-cover img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center; + display: block; +} + +.torrent-main { + grid-area: main; +} + +.torrent-description { + grid-area: description; +} + +.torrent-below { + grid-area: below; +} + +.metadata-table { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5em; +} + +.metadata-table dt { + font-weight: bold; +} + +.metadata-table dd { + margin: 0; +} + +.pill { + display: inline-block; + padding: 0.2em 0.5em; + margin: 0.2em; + background: var(--above); + border-radius: 4px; +} + +.torrent-detail-page .btn { + display: inline-block; + border: 1px solid var(--color-3); + text-decoration: none; +} + +.torrent-detail-page .option_group { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + align-items: center; +} + +.torrent-detail-page .option_group label { + display: flex; + align-items: center; + gap: 0.3em; +} + +.torrent-detail-page .option_group input { + display: inline; +} + +.torrent-actions-widget { + margin-top: 1em; +} + +.torrent-actions-row { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + margin-top: 0.5em; +} + +.dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 100; +} + +.dialog-box { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 101; + background: var(--color-2); + border: 1px solid var(--color-3); + border-radius: 6px; + padding: 1.5em; + min-width: 400px; + max-width: min(700px, 90vw); + max-height: 85vh; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1em; +} + +.dialog-field { + display: flex; + align-items: center; + gap: 0.75em; +} + +.dialog-preview { + flex: 1; + min-height: 4em; +} + +.dialog-actions { + display: flex; + gap: 0.5em; + justify-content: flex-end; +} + +.match-diff-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9em; + + th { + text-align: left; + padding: 0.3em 0.5em; + border-bottom: 1px solid var(--color-3); + font-weight: bold; + } + + td { + padding: 0.3em 0.5em; + vertical-align: top; + } + + tr:nth-child(even) td { + background: var(--color-1); + } + + .diff-from { + color: var(--text-faint); + text-decoration: line-through; + } + + .diff-to { + color: var(--text); + } +} + +@media (max-width: 768px) { + .torrent-detail-grid { + grid-template-columns: 1fr; + grid-template-areas: + "main" + "side" + "description" + "below"; + } +} + +.search-page .search-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.search-page .search-controls input[type="text"], +.search-page .search-controls input[type="number"] { + width: max(220px, 20vw); +} + +.search-page .Torrents { + display: grid; + gap: 8px; +} + +.search-page .TorrentRow { + display: grid; + grid-template-columns: 56px 56px 1fr 80px 130px 80px; + grid-template-areas: "category icons main files uploaded stats"; + gap: 8px; + padding: 8px; + border-radius: 4px; + background: var(--above); +} + +.search-page .TorrentRow .category, +.search-page .TorrentRow .icons, +.search-page .TorrentRow .files, +.search-page .TorrentRow .uploaded, +.search-page .TorrentRow .stats { + display: flex; + flex-direction: column; + gap: 4px; +} + +.search-page .TorrentRow .stats { + align-items: flex-end; +} + +.search-page .TorrentRow .icon-row { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.search-page .TorrentRow .icon-row img { + width: 14px; + height: 14px; +} + +.search-page .TorrentRow .media-icon { + width: 36px; + height: 36px; + object-fit: contain; +} + +.search-page .TorrentRow .CategoryPills { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 4px; +} + +.search-page .TorrentRow .CategoryPill { + display: inline-block; + padding: 2px 6px; + border-radius: 12px; + background: color-mix(in srgb, var(--color-3) 40%, transparent); +} + +.search-page .TorrentRow .CategoryPill.old { + background: color-mix(in srgb, var(--accent) 65%, transparent); +} + +.search-page .TorrentRow .filter-link { + padding: 0; + border: none; + background: transparent; + color: inherit; + text-decoration: underline; + text-decoration-color: transparent; +} + +.search-page .TorrentRow .filter-link:hover { + text-decoration-color: currentColor; + background: transparent; +} + +@media (max-width: 960px) { + .search-page .TorrentRow { + grid-template-columns: 56px 1fr 80px; + grid-template-areas: + "category main main" + "icons main main" + "files uploaded stats"; + } +} diff --git a/mlm_web_dioxus/src/app.rs b/mlm_web_dioxus/src/app.rs new file mode 100644 index 00000000..b962ee35 --- /dev/null +++ b/mlm_web_dioxus/src/app.rs @@ -0,0 +1,116 @@ +use crate::config::ConfigPage; +use crate::duplicate::DuplicatePage; +use crate::errors::ErrorsPage; +use crate::events::EventsPage; +use crate::home::HomePage; +use crate::list::ListPage; +use crate::lists::ListsPage; +use crate::replaced::ReplacedPage; +use crate::search::SearchPage; +use crate::selected::SelectedPage; +use crate::torrent_detail::TorrentDetailPage; +use crate::torrent_edit::TorrentEditPage; +use crate::torrents::TorrentsPage; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +const GLOBAL_STYLE_CSS: &str = include_str!("../../server/assets/style.css"); + +#[derive(Clone, Routable, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[rustfmt::skip] +pub enum Route { + #[layout(App)] + #[route("/")] + HomePage {}, + + #[route("/dioxus/events")] + EventsPage {}, + + #[route("/dioxus/events/:..segments")] + EventsWithQuery { segments: Vec }, + + #[route("/dioxus/errors")] + ErrorsPage {}, + + #[route("/dioxus/selected")] + SelectedPage {}, + + #[route("/dioxus/replaced")] + ReplacedPage {}, + + #[route("/dioxus/duplicate")] + DuplicatePage {}, + + #[route("/dioxus/torrents")] + TorrentsPage {}, + + #[route("/dioxus/torrents/:id")] + TorrentDetailPage { id: String }, + + #[route("/dioxus/torrents/:id/edit")] + TorrentEditPage { id: String }, + + #[route("/dioxus/torrents/:..segments")] + TorrentsWithQuery { segments: Vec }, + + #[route("/dioxus/search")] + SearchPage {}, + + #[route("/dioxus/lists")] + ListsPage {}, + + #[route("/dioxus/lists/:id")] + ListPage { id: String }, + + #[route("/dioxus/config")] + ConfigPage {}, +} + +pub fn root() -> Element { + rsx! { + Router:: {} + } +} + +#[component] +pub fn App() -> Element { + use_hook(crate::sse::setup_sse); + + rsx! { + document::Title { "MLM - Dioxus" } + document::Meta { name: "viewport", content: "width=device-width, initial-scale=1" } + document::Link { rel: "icon", r#type: "image/png", href: "/assets/favicon.png" } + document::Style { "{GLOBAL_STYLE_CSS}" } + + nav { "aria-label": "Main navigation", + Link { to: Route::HomePage {}, "Home" } + Link { to: Route::TorrentsPage {}, "Torrents" } + Link { to: Route::EventsPage {}, "Events" } + Link { to: Route::SearchPage {}, "Search" } + Link { to: Route::ListsPage {}, "Goodreads Lists" } + Link { to: Route::ErrorsPage {}, "Errors" } + Link { to: Route::SelectedPage {}, "Selected Torrents" } + Link { to: Route::ReplacedPage {}, "Replaced Torrents" } + Link { to: Route::DuplicatePage {}, "Duplicate Torrents" } + Link { to: Route::ConfigPage {}, "Config" } + } + main { Outlet:: {} } + } +} + +// Dioxus's router requires a distinct path segment to differentiate routes, but these +// pages read their filter state directly from the URL query string. These catch-all +// variants absorb any trailing path segments so that query-string-only navigations +// (e.g. `?kind=audiobook`) still land on the right page component. + +#[component] +fn EventsWithQuery(segments: Vec) -> Element { + let _ = segments; + rsx! { EventsPage {} } +} + +#[component] +fn TorrentsWithQuery(segments: Vec) -> Element { + let _ = segments; + rsx! { TorrentsPage {} } +} diff --git a/mlm_web_dioxus/src/components/action_button.rs b/mlm_web_dioxus/src/components/action_button.rs new file mode 100644 index 00000000..f4cb5947 --- /dev/null +++ b/mlm_web_dioxus/src/components/action_button.rs @@ -0,0 +1,42 @@ +use dioxus::prelude::*; + +#[derive(Props, Clone, PartialEq)] +pub struct ActionButtonProps { + pub label: String, + pub onclick: EventHandler<()>, + #[props(default = false)] + pub disabled: bool, + #[props(default = None)] + pub class: Option, + #[props(default = None)] + pub style: Option, + #[props(default = None)] + pub loading_label: Option, + #[props(default = false)] + pub loading: bool, +} + +#[component] +pub fn ActionButton(props: ActionButtonProps) -> Element { + let class = props.class.clone().unwrap_or_else(|| "btn".to_string()); + let style = props.style.clone().unwrap_or_default(); + let label = if props.loading { + props + .loading_label + .clone() + .unwrap_or_else(|| "...".to_string()) + } else { + props.label.clone() + }; + + rsx! { + button { + r#type: "button", + class: "{class}", + style: "{style}", + disabled: props.disabled || props.loading, + onclick: move |_| props.onclick.call(()), + "{label}" + } + } +} diff --git a/mlm_web_dioxus/src/components/details.rs b/mlm_web_dioxus/src/components/details.rs new file mode 100644 index 00000000..1d4f25c9 --- /dev/null +++ b/mlm_web_dioxus/src/components/details.rs @@ -0,0 +1,11 @@ +use dioxus::prelude::*; + +#[component] +pub fn Details(label: String, open: Option, children: Element) -> Element { + rsx! { + details { open: open.unwrap_or(false), + summary { class: "details-summary", "{label}" } + {children} + } + } +} diff --git a/mlm_web_dioxus/src/components/download_buttons.rs b/mlm_web_dioxus/src/components/download_buttons.rs new file mode 100644 index 00000000..647a683f --- /dev/null +++ b/mlm_web_dioxus/src/components/download_buttons.rs @@ -0,0 +1,204 @@ +use crate::torrent_detail::select_torrent_action; +use dioxus::prelude::*; + +/// Display mode for download buttons +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum DownloadButtonMode { + /// Full text labels (default) + #[default] + Full, + /// Compact icon labels (↓ and W) + Compact, +} + +/// Reusable download buttons component. +/// +/// Shows appropriate download options based on torrent status: +/// - VIP torrents: "Download as VIP" +/// - Personal Freeleech: "Download as Personal Freeleech" +/// - Global Freeleech: "Download as Global Freeleech" +/// - Regular: "Download with Wedge" + "Download with Ratio" (or compact versions) +/// +/// The component manages its own loading state internally. +#[derive(Props, Clone, PartialEq)] +pub struct DownloadButtonsProps { + /// The MaM ID of the torrent + pub mam_id: u64, + /// Whether this is a VIP torrent + pub is_vip: bool, + /// Whether this is global freeleech + pub is_free: bool, + /// Whether this is personal freeleech + pub is_personal_freeleech: bool, + /// Whether wedge download is available + pub can_wedge: bool, + /// External disabled state (e.g., when another operation is in progress) + pub disabled: bool, + /// Display mode: Full labels or compact icons + #[props(default)] + pub mode: DownloadButtonMode, + /// Callback for status messages (message, is_error) + pub on_status: EventHandler<(String, bool)>, + /// Callback when download is triggered successfully + pub on_refresh: EventHandler<()>, +} + +#[component] +pub fn DownloadButtons(props: DownloadButtonsProps) -> Element { + let mut loading = use_signal(|| false); + let mam_id = props.mam_id; + let on_status = props.on_status; + let on_refresh = props.on_refresh; + + let handle_download = Callback::new(move |(wedge, success_msg): (bool, String)| { + loading.set(true); + on_status.call((String::new(), false)); + spawn(async move { + match select_torrent_action(mam_id, wedge).await { + Ok(_) => { + on_status.call((success_msg, false)); + on_refresh.call(()); + } + Err(e) => { + on_status.call((format!("Selection failed: {e}"), true)); + } + } + loading.set(false); + }); + }); + + let is_disabled = *loading.read() || props.disabled; + let auto_wedge = + props.can_wedge && !props.is_vip && !props.is_personal_freeleech && !props.is_free; + + let freeleech_label = if props.is_vip { + Some("Download as VIP") + } else if props.is_personal_freeleech { + Some("Download as Personal Freeleech") + } else if props.is_free { + Some("Download as Global Freeleech") + } else { + None + }; + + rsx! { + if let Some(label) = freeleech_label { + button { + class: if props.mode == DownloadButtonMode::Compact { "icon" } else { "btn" }, + disabled: is_disabled, + onclick: move |_| { + handle_download.call((false, "Torrent queued for download".to_string())); + }, + if *loading.read() { + if props.mode == DownloadButtonMode::Compact { + img { + src: "/assets/icons/down.png", + alt: "Downloading", + title: "Downloading", + style: "filter:saturate(0)", + } + } else { + "..." + } + } else if props.mode == DownloadButtonMode::Compact { + img { + src: "/assets/icons/down.png", + alt: "Download", + title: "Download", + } + } else { + {label} + } + } + } else { + // Regular download options + if props.mode == DownloadButtonMode::Compact { + button { + class: "icon", + style: if auto_wedge { "filter:hue-rotate(180deg)" }, + disabled: is_disabled, + onclick: move |_| { + handle_download.call(( + auto_wedge, + if auto_wedge { + "Torrent queued with wedge".to_string() + } else { + "Torrent queued for download".to_string() + }, + )); + }, + if *loading.read() { + img { + src: "/assets/icons/down.png", + alt: "Downloading", + title: "Downloading", + style: "filter:saturate(0)", + } + } else { + img { + src: "/assets/icons/down.png", + alt: if auto_wedge { "Download with Wedge" } else { "Download" }, + title: if auto_wedge { "Download with Wedge" } else { "Download" }, + } + } + } + } else { + // Full mode: text buttons + if props.can_wedge { + button { + class: "btn", + disabled: is_disabled, + onclick: move |_| { + handle_download.call((true, "Torrent queued with wedge".to_string())); + }, + if *loading.read() { "..." } else { "Download with Wedge" } + } + } + button { + class: "btn", + disabled: is_disabled, + onclick: move |_| { + handle_download.call((false, "Torrent queued for download".to_string())); + }, + if *loading.read() { "..." } else { "Download with Ratio" } + } + } + } + } +} + +/// Simplified download buttons for torrents that are already known to be regular (non-freeleech). +/// Shows download + optional wedge buttons. +#[derive(Props, Clone, PartialEq)] +pub struct SimpleDownloadButtonsProps { + /// The MaM ID of the torrent + pub mam_id: u64, + /// Whether wedge download is available + pub can_wedge: bool, + /// External disabled state + pub disabled: bool, + /// Display mode: Full labels or compact icons + #[props(default)] + pub mode: DownloadButtonMode, + /// Callback for status messages (message, is_error) + pub on_status: EventHandler<(String, bool)>, + /// Callback when download is triggered successfully + pub on_refresh: EventHandler<()>, +} + +#[component] +pub fn SimpleDownloadButtons(props: SimpleDownloadButtonsProps) -> Element { + rsx! { + DownloadButtons { + mam_id: props.mam_id, + is_vip: false, + is_free: false, + is_personal_freeleech: false, + can_wedge: props.can_wedge, + disabled: props.disabled, + mode: props.mode, + on_status: props.on_status, + on_refresh: props.on_refresh, + } + } +} diff --git a/mlm_web_dioxus/src/components/filter_controls.rs b/mlm_web_dioxus/src/components/filter_controls.rs new file mode 100644 index 00000000..6a45583d --- /dev/null +++ b/mlm_web_dioxus/src/components/filter_controls.rs @@ -0,0 +1,115 @@ +use dioxus::prelude::*; + +#[derive(Clone, PartialEq)] +pub struct ColumnToggleOption { + pub label: &'static str, + pub checked: bool, + pub on_toggle: EventHandler, +} + +#[component] +pub fn ColumnSelector(options: Vec) -> Element { + let mut is_open = use_signal(|| false); + let selected_count = options.iter().filter(|option| option.checked).count(); + let total_count = options.len(); + + rsx! { + div { class: "option_group query column_selector", + div { class: "column_selector_dropdown", + if *is_open.read() { + div { + class: "column_selector_backdrop", + onclick: move |_| is_open.set(false), + } + } + button { + r#type: "button", + class: "column_selector_trigger", + "aria-expanded": if *is_open.read() { "true" } else { "false" }, + onclick: move |_| { + let next = !*is_open.read(); + is_open.set(next); + }, + "Columns ({selected_count}/{total_count})" + } + if *is_open.read() { + div { class: "column_selector_menu", + for option in options { + label { class: "column_selector_option", + input { + r#type: "checkbox", + checked: option.checked, + onchange: move |ev| option.on_toggle.call(ev.checked()), + } + span { "{option.label}" } + } + } + } + } + } + } + } +} + +#[component] +pub fn PageSizeSelector( + page_size: usize, + options: Vec, + show_all_option: bool, + on_change: EventHandler, +) -> Element { + rsx! { + div { class: "option_group query", + "Page size: " + select { + value: "{page_size}", + onchange: move |ev| { + if let Ok(v) = ev.value().parse::() { + on_change.call(v); + } + }, + for option in options { + option { value: "{option}", "{option}" } + } + if show_all_option { + option { value: "0", "all" } + } + } + } + } +} + +#[derive(Clone, PartialEq)] +pub struct ActiveFilterChip { + pub label: String, + pub on_remove: EventHandler<()>, +} + +#[component] +pub fn ActiveFilters( + chips: Vec, + on_clear_all: Option>, +) -> Element { + if chips.is_empty() { + return rsx! { "" }; + } + + rsx! { + div { class: "option_group query", + for chip in chips { + span { class: "item", + "{chip.label}" + button { + r#type: "button", + "aria-label": "Remove {chip.label} filter", + onclick: move |_| chip.on_remove.call(()), + " ×" + } + } + } + if let Some(on_clear_all) = on_clear_all { + button { r#type: "button", onclick: move |_| on_clear_all.call(()), "Clear filters" } + } + } + } +} diff --git a/mlm_web_dioxus/src/components/filter_link.rs b/mlm_web_dioxus/src/components/filter_link.rs new file mode 100644 index 00000000..26de22f2 --- /dev/null +++ b/mlm_web_dioxus/src/components/filter_link.rs @@ -0,0 +1,82 @@ +use dioxus::prelude::*; +use serde::Serialize; + +use super::query_params::{ + build_location_href, build_query_string, encode_query_enum, parse_location_query_pairs, +}; + +pub fn filter_href( + field: F, + value: String, + reset_from: bool, + current_params: Option>, +) -> String { + if let Some(name) = encode_query_enum(field) { + let mut params = current_params.unwrap_or_else(parse_location_query_pairs); + params.retain(|(key, _)| key != &name && !(reset_from && key == "from")); + params.push((name, value)); + let query_string = build_query_string(¶ms); + build_location_href(&query_string) + } else { + build_location_href("") + } +} + +#[component] +pub fn TorrentTitleLink( + detail_id: String, + field: F, + value: String, + children: Element, + #[props(default = false)] reset_from: bool, + #[props(default = None)] current_params: Option>, +) -> Element { + let title_filter_href = filter_href(field, value, reset_from, current_params); + + rsx! { + a { + class: "link", + href: "/dioxus/torrents/{detail_id}", + onclick: move |ev: MouseEvent| { + if ev.modifiers().alt() { + ev.prevent_default(); + #[cfg(feature = "web")] + { + if let Some(window) = web_sys::window() { + let _ = window.location().set_href(&title_filter_href); + } + } + #[cfg(not(feature = "web"))] + { + let _ = &title_filter_href; + } + } + }, + {children} + } + } +} + +#[component] +pub fn FilterLink( + field: F, + value: String, + children: Element, + #[props(default = false)] reset_from: bool, + #[props(default = None)] title: Option, + /// Pre-parsed query pairs from the parent. When provided, avoids a redundant + /// `parse_location_query_pairs()` call on every render (e.g. inside row loops). + #[props(default = None)] + current_params: Option>, +) -> Element { + let href = filter_href(field, value.clone(), reset_from, current_params); + + rsx! { + Link { + class: "link", + to: href, + title: title.unwrap_or_default(), + {children} + } + } +} diff --git a/mlm_web_dioxus/src/components/icons.rs b/mlm_web_dioxus/src/components/icons.rs new file mode 100644 index 00000000..9bfcdd91 --- /dev/null +++ b/mlm_web_dioxus/src/components/icons.rs @@ -0,0 +1,105 @@ +use dioxus::prelude::*; + +pub fn media_icon_src(mediatype: u8, main_cat: u8) -> Option<&'static str> { + match (mediatype, main_cat) { + (1, 1) => Some("/assets/icons/mediatypes/AudiobookF.png"), + (1, 2) => Some("/assets/icons/mediatypes/AudiobookNF.png"), + (1, _) => Some("/assets/icons/mediatypes/Audiobook.png"), + (2, 1) => Some("/assets/icons/mediatypes/EBookF.png"), + (2, 2) => Some("/assets/icons/mediatypes/EBookNF.png"), + (2, _) => Some("/assets/icons/mediatypes/EBook.png"), + (3, 1) => Some("/assets/icons/mediatypes/MusicF.png"), + (3, 2) => Some("/assets/icons/mediatypes/MusicNF.png"), + (3, _) => Some("/assets/icons/mediatypes/Music.png"), + (4, 1) => Some("/assets/icons/mediatypes/RadioF.png"), + (4, 2) => Some("/assets/icons/mediatypes/RadioNF.png"), + (4, _) => Some("/assets/icons/mediatypes/Radio.png"), + (5, 1) => Some("/assets/icons/mediatypes/MangaF.png"), + (5, 2) => Some("/assets/icons/mediatypes/MangaNF.png"), + (5, _) => Some("/assets/icons/mediatypes/Manga.png"), + (6, 1) => Some("/assets/icons/mediatypes/ComicsF.png"), + (6, 2) => Some("/assets/icons/mediatypes/ComicsNF.png"), + (6, _) => Some("/assets/icons/mediatypes/Comics.png"), + (7, 1) => Some("/assets/icons/mediatypes/PeriodicalsF.png"), + (7, 2) => Some("/assets/icons/mediatypes/PeriodicalsNF.png"), + (7, _) => Some("/assets/icons/mediatypes/Periodicals.png"), + (8, 1) => Some("/assets/icons/mediatypes/PeriodicalsAudioF.png"), + (8, 2) => Some("/assets/icons/mediatypes/PeriodicalsAudioNF.png"), + (8, _) => Some("/assets/icons/mediatypes/PeriodicalsAudio.png"), + _ => None, + } +} + +pub fn flag_icon(flag: &str) -> Option<(&'static str, &'static str)> { + match flag { + "language" => Some(("/assets/icons/language.png", "Crude Language")), + "violence" => Some(("/assets/icons/hand.png", "Violence")), + "some_explicit" => Some(( + "/assets/icons/lipssmall.png", + "Some Sexually Explicit Content", + )), + "explicit" => Some(("/assets/icons/flames.png", "Sexually Explicit Content")), + "abridged" => Some(("/assets/icons/abridged.png", "Abridged")), + "lgbt" => Some(("/assets/icons/lgbt.png", "LGBT")), + _ => None, + } +} + +#[component] +pub fn CategoryPills(categories: Vec, old_category: Option) -> Element { + if categories.is_empty() && old_category.is_none() { + return rsx! {}; + } + + rsx! { + div { class: "CategoryPills", + if let Some(ref old_category) = old_category { + span { class: "CategoryPill old", "{old_category}" } + } + for category in &categories { + if old_category.as_ref() != Some(category) { + span { class: "CategoryPill", "{category}" } + } + } + } + } +} + +#[component] +pub fn TorrentIcons( + vip: bool, + personal_freeleech: bool, + free: bool, + flags: Vec, +) -> Element { + rsx! { + div { class: "TorrentIcons", grid_area: "icons", + if vip { + img { src: "/assets/icons/vip.png", alt: "VIP", title: "VIP" } + } else if personal_freeleech { + img { + src: "/assets/icons/freedownload.png", + alt: "Personal Freeleech", + title: "Personal Freeleech", + style: "filter:hue-rotate(180deg)", + } + } else if free { + img { + src: "/assets/icons/freedownload.png", + alt: "Freeleech", + title: "Freeleech", + } + } + for flag in &flags { + if let Some((src, title)) = flag_icon(flag) { + img { + class: "flag", + src: "{src}", + alt: "{title}", + title: "{title}", + } + } + } + } + } +} diff --git a/mlm_web_dioxus/src/components/mod.rs b/mlm_web_dioxus/src/components/mod.rs new file mode 100644 index 00000000..054ac0a4 --- /dev/null +++ b/mlm_web_dioxus/src/components/mod.rs @@ -0,0 +1,37 @@ +mod action_button; +mod details; +mod download_buttons; +mod filter_controls; +mod filter_link; +mod icons; +mod pagination; +mod query_params; +mod search_row; +mod selection; +mod sort_header; +mod status_message; +mod table_view; +mod task_box; + +pub use action_button::ActionButton; +pub use details::Details; +pub use download_buttons::{DownloadButtonMode, DownloadButtons, SimpleDownloadButtons}; +pub use filter_controls::{ + ActiveFilterChip, ActiveFilters, ColumnSelector, ColumnToggleOption, PageSizeSelector, +}; +pub use filter_link::{FilterLink, TorrentTitleLink, filter_href}; +pub use icons::{CategoryPills, TorrentIcons, flag_icon, media_icon_src}; +pub use pagination::Pagination; +pub use query_params::{ + PageColumns, apply_click_filter, build_location_href, build_query_string, encode_query_enum, + parse_location_query_pairs, parse_query_enum, set_location_query_string, +}; +pub use search_row::{ + SearchMetadataFilterItem, SearchMetadataFilterRow, SearchMetadataKind, SearchTorrentRow, + search_filter_href, +}; +pub use selection::update_row_selection; +pub use sort_header::SortHeader; +pub use status_message::StatusMessage; +pub use table_view::TorrentGridTable; +pub use task_box::TaskBox; diff --git a/mlm_web_dioxus/src/components/pagination.rs b/mlm_web_dioxus/src/components/pagination.rs new file mode 100644 index 00000000..0f03047f --- /dev/null +++ b/mlm_web_dioxus/src/components/pagination.rs @@ -0,0 +1,110 @@ +use dioxus::prelude::*; + +#[derive(Props, Clone, PartialEq)] +pub struct PaginationProps { + pub total: usize, + pub from: usize, + pub page_size: usize, + pub on_change: EventHandler, +} + +#[component] +pub fn Pagination(props: PaginationProps) -> Element { + if props.page_size == 0 || props.total <= props.page_size { + return rsx! { "" }; + } + + let max_pages = 7; + let num_pages = (props.total as f64 / props.page_size as f64).ceil() as usize; + let current_page = props.from / props.page_size + 1; + + let pages = { + if num_pages > max_pages { + let half = max_pages / 2; + if current_page <= half { + 1..=max_pages + } else if current_page >= num_pages - half { + (num_pages - max_pages + 1)..=num_pages + } else { + (current_page - half)..=(current_page + half) + } + } else { + 1..=num_pages + } + }; + + rsx! { + div { class: "pagination", + if num_pages > max_pages { + button { + r#type: "button", + "aria-label": "First page", + class: if current_page == 1 { "disabled" }, + disabled: current_page == 1, + onclick: move |_| { + if current_page != 1 { + props.on_change.call(0); + } + }, + "«" + } + } + button { + r#type: "button", + "aria-label": "Previous page", + class: if current_page == 1 { "disabled" }, + disabled: current_page == 1, + onclick: move |_| { + if current_page != 1 { + props.on_change.call(props.from.saturating_sub(props.page_size)); + } + }, + "‹" + } + div { + for p in pages { + { + let p_from = (p - 1) * props.page_size; + let active = p == current_page; + rsx! { + button { + r#type: "button", + class: if active { "active" }, + onclick: move |_| props.on_change.call(p_from), + "{p}" + } + } + } + } + } + button { + r#type: "button", + "aria-label": "Next page", + class: if current_page == num_pages { "disabled" }, + disabled: current_page == num_pages, + onclick: move |_| { + if current_page != num_pages { + props.on_change.call( + (props.from + props.page_size).min((num_pages - 1) * props.page_size) + ); + } + }, + "›" + } + if num_pages > max_pages { + button { + r#type: "button", + "aria-label": "Last page", + class: if current_page == num_pages { "disabled" }, + disabled: current_page == num_pages, + onclick: move |_| { + if current_page != num_pages { + props.on_change.call((num_pages - 1) * props.page_size); + } + }, + "»" + } + } + } + } +} diff --git a/mlm_web_dioxus/src/components/query_params.rs b/mlm_web_dioxus/src/components/query_params.rs new file mode 100644 index 00000000..901a5a1b --- /dev/null +++ b/mlm_web_dioxus/src/components/query_params.rs @@ -0,0 +1,144 @@ +use dioxus::prelude::{ReadableExt, Signal, WritableExt}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +pub trait PageColumns: Default + PartialEq + Sized { + fn to_query_value(&self) -> String; + fn from_query_value(s: &str) -> Self; +} + +pub fn apply_click_filter( + filters: &mut Signal>, + field: F, + value: String, +) { + let mut next = filters.read().clone(); + next.retain(|(f, _)| *f != field); + next.push((field, value)); + filters.set(next); +} + +#[cfg(feature = "web")] +pub fn parse_location_query_pairs() -> Vec<(String, String)> { + let Some(window) = web_sys::window() else { + return Vec::new(); + }; + let Ok(search) = window.location().search() else { + return Vec::new(); + }; + let search = search.trim_start_matches('?'); + if search.is_empty() { + return Vec::new(); + } + search + .split('&') + .map(|pair| { + let (raw_key, raw_value) = pair.split_once('=').unwrap_or((pair, "")); + (decode_query_value(raw_key), decode_query_value(raw_value)) + }) + .collect() +} + +#[cfg(not(feature = "web"))] +pub fn parse_location_query_pairs() -> Vec<(String, String)> { + #[cfg(feature = "server")] + { + let Some(context) = dioxus_fullstack::FullstackContext::current() else { + return Vec::new(); + }; + let parts = context.parts_mut(); + let Some(search) = parts.uri.query() else { + return Vec::new(); + }; + if search.is_empty() { + return Vec::new(); + } + search + .split('&') + .map(|pair| { + let (raw_key, raw_value) = pair.split_once('=').unwrap_or((pair, "")); + (decode_query_value(raw_key), decode_query_value(raw_value)) + }) + .collect() + } + #[cfg(not(feature = "server"))] + { + Vec::new() + } +} + +pub fn build_query_string(params: &[(String, String)]) -> String { + params + .iter() + .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) + .collect::>() + .join("&") +} + +pub fn build_location_href(query_string: &str) -> String { + let pathname = location_pathname(); + if query_string.is_empty() { + pathname + } else { + format!("{pathname}?{query_string}") + } +} + +pub fn parse_query_enum(value: &str) -> Option { + serde_json::from_str::(&format!("\"{value}\"")).ok() +} + +pub fn encode_query_enum(value: T) -> Option { + serde_json::to_string(&value) + .ok() + .map(|raw| raw.trim_matches('"').to_string()) +} + +#[cfg(feature = "web")] +pub fn set_location_query_string(query_string: &str) { + let Some(window) = web_sys::window() else { + return; + }; + let target = build_location_href(query_string); + let Ok(history) = window.history() else { + return; + }; + let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&target)); +} + +#[cfg(not(feature = "web"))] +pub fn set_location_query_string(_query_string: &str) {} + +#[cfg(any(feature = "web", feature = "server"))] +fn decode_query_value(value: &str) -> String { + let replaced = value.replace('+', " "); + urlencoding::decode(&replaced) + .map(|s| s.to_string()) + .unwrap_or(replaced) +} + +#[cfg(feature = "web")] +fn location_pathname() -> String { + let Some(window) = web_sys::window() else { + return "/".to_string(); + }; + window + .location() + .pathname() + .unwrap_or_else(|_| "/".to_string()) +} + +#[cfg(not(feature = "web"))] +fn location_pathname() -> String { + #[cfg(feature = "server")] + { + let Some(context) = dioxus_fullstack::FullstackContext::current() else { + return "/".to_string(); + }; + return context.parts_mut().uri.path().to_string(); + } + #[cfg(not(feature = "server"))] + { + "/".to_string() + } +} diff --git a/mlm_web_dioxus/src/components/search_row.rs b/mlm_web_dioxus/src/components/search_row.rs new file mode 100644 index 00000000..6a118766 --- /dev/null +++ b/mlm_web_dioxus/src/components/search_row.rs @@ -0,0 +1,245 @@ +use super::{ + CategoryPills, DownloadButtonMode, SimpleDownloadButtons, TorrentIcons, media_icon_src, +}; +use crate::app::Route; +use crate::search::SearchTorrent; +use dioxus::prelude::*; +use lucide_dioxus::{BookText, Mic, UserPen}; + +fn search_filter_query(prefix: &str, value: &str) -> String { + let escaped = value.replace('"', "\\\""); + format!("@{prefix} \"{escaped}\"") +} + +pub fn search_filter_href(prefix: &str, value: &str, sort: &str) -> String { + let mut params = vec![("q".to_string(), search_filter_query(prefix, value))]; + if !sort.is_empty() { + params.push(("sort".to_string(), sort.to_string())); + } + + let query = params + .iter() + .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) + .collect::>() + .join("&"); + format!("/dioxus/search?{query}") +} + +#[derive(Clone, PartialEq)] +pub struct SearchMetadataFilterItem { + pub label: String, + pub href: String, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SearchMetadataKind { + Authors, + Narrators, + Series, +} + +#[component] +pub fn SearchMetadataFilterRow( + kind: SearchMetadataKind, + items: Vec, +) -> Element { + if items.is_empty() { + return rsx! {}; + } + + let title = match kind { + SearchMetadataKind::Authors => "Authors", + SearchMetadataKind::Narrators => "Narrators", + SearchMetadataKind::Series => "Series", + }; + + rsx! { + div { class: "icon-row", + span { title: "{title}", + match kind { + SearchMetadataKind::Authors => rsx! { + UserPen { size: 16 } + }, + SearchMetadataKind::Narrators => rsx! { + Mic { size: 16 } + }, + SearchMetadataKind::Series => rsx! { + BookText { size: 16 } + }, + } + } + for (i , item) in items.iter().enumerate() { + if i > 0 { + ", " + } + Link { class: "filter-link", to: item.href.clone(), "{item.label}" } + } + } + } +} + +#[component] +pub fn SearchTorrentRow( + torrent: SearchTorrent, + mut status_msg: Signal>, + on_refresh: EventHandler<()>, +) -> Element { + let mam_id = torrent.mam_id; + let uploaded_parts = torrent + .uploaded_at + .split_once(' ') + .map(|(d, t)| (d.to_string(), t.to_string())); + let authors = torrent + .authors + .iter() + .map(|author| SearchMetadataFilterItem { + label: author.clone(), + href: search_filter_href("author", author, ""), + }) + .collect::>(); + let narrators = torrent + .narrators + .iter() + .map(|narrator| SearchMetadataFilterItem { + label: narrator.clone(), + href: search_filter_href("narrator", narrator, ""), + }) + .collect::>(); + let series = torrent + .series + .iter() + .map(|series| SearchMetadataFilterItem { + label: if series.entries.is_empty() { + series.name.clone() + } else { + format!("{} {}", series.name, series.entries) + }, + href: search_filter_href("series", &series.name, "series"), + }) + .collect::>(); + let torrent_detail_id = mam_id.to_string(); + + rsx! { + div { class: "TorrentRow", + div { class: "category", grid_area: "category", + if let Some(src) = media_icon_src(torrent.mediatype_id, torrent.main_cat_id) { + img { + class: "media-icon", + src: "{src}", + alt: "{torrent.media_type}", + title: "{torrent.media_type}", + } + } else { + span { class: "faint", "{torrent.media_type}" } + } + } + TorrentIcons { + vip: torrent.vip, + personal_freeleech: torrent.personal_freeleech, + free: torrent.free, + flags: torrent.flags.clone(), + } + div { grid_area: "main", + div { + if torrent.lang_code != "ENG" { + span { class: "faint", "[{torrent.lang_code}] " } + } + Link { + to: Route::TorrentDetailPage { + id: torrent_detail_id, + }, + b { "{torrent.title}" } + } + if let Some(edition) = &torrent.edition { + i { class: "faint", " {edition}" } + } + } + SearchMetadataFilterRow { kind: SearchMetadataKind::Authors, items: authors } + SearchMetadataFilterRow { kind: SearchMetadataKind::Narrators, items: narrators } + SearchMetadataFilterRow { kind: SearchMetadataKind::Series, items: series } + if !torrent.tags.is_empty() { + div { + i { "{torrent.tags}" } + } + } + div { class: "faint", + "{torrent.filetypes.join(\", \")}" + if let Some(duration) = &torrent.media_duration { + " | {duration}" + } + if let Some(format) = &torrent.media_format { + " | {format}" + } + if let Some(bitrate) = &torrent.audio_bitrate { + " | {bitrate}" + } + " | {torrent.comments} comments" + } + CategoryPills { + categories: torrent.categories.clone(), + old_category: torrent.old_category.clone(), + } + } + div { class: "download", grid_area: "download", + if torrent.is_selected { + span { class: "pill", "Queued" } + } else if torrent.is_downloaded { + span { class: "pill", "Downloaded" } + } else { + SimpleDownloadButtons { + mam_id, + can_wedge: torrent.can_wedge, + disabled: false, + mode: DownloadButtonMode::Compact, + on_status: move |(msg, is_error)| { + status_msg.set(Some((msg, is_error))); + }, + on_refresh: move |_| { + on_refresh.call(()); + }, + } + } + } + div { class: "files", grid_area: "files", + span { "{torrent.num_files}" } + span { "{torrent.size}" } + span { "{torrent.filetypes.first().map(|t| t.as_str()).unwrap_or_default()}" } + } + div { class: "uploaded", grid_area: "uploaded", + if let Some((date, time)) = uploaded_parts { + span { "{date}" } + span { "{time}" } + } else { + span { "{torrent.uploaded_at}" } + } + span { "{torrent.owner_name}" } + } + div { class: "stats", grid_area: "stats", + span { class: "icon-row", + "{torrent.seeders}" + img { + alt: "seeders", + title: "Seeders", + src: "/assets/icons/upBig3.png", + } + } + span { class: "icon-row", + "{torrent.leechers}" + img { + alt: "leechers", + title: "Leechers", + src: "/assets/icons/downBig3.png", + } + } + span { class: "icon-row", + "{torrent.snatches}" + img { + alt: "snatches", + title: "Snatches", + src: "/assets/icons/snatched.png", + } + } + } + } + } +} diff --git a/mlm_web_dioxus/src/components/selection.rs b/mlm_web_dioxus/src/components/selection.rs new file mode 100644 index 00000000..23c39fcf --- /dev/null +++ b/mlm_web_dioxus/src/components/selection.rs @@ -0,0 +1,43 @@ +use std::collections::BTreeSet; + +use dioxus::prelude::*; + +pub fn update_row_selection( + event: &MouseEvent, + mut selected: Signal>, + mut last_selected_idx: Signal>, + all_row_ids: &[T], + row_id: &T, + row_index: usize, +) { + let will_select = !selected.read().contains(row_id); + let mut next = selected.read().clone(); + + if event.modifiers().shift() { + if let Some(last_idx) = *last_selected_idx.read() { + let (start, end) = if last_idx <= row_index { + (last_idx, row_index) + } else { + (row_index, last_idx) + }; + for id in &all_row_ids[start..=end] { + if will_select { + next.insert(id.clone()); + } else { + next.remove(id); + } + } + } else if will_select { + next.insert(row_id.clone()); + } else { + next.remove(row_id); + } + } else if will_select { + next.insert(row_id.clone()); + } else { + next.remove(row_id); + } + + selected.set(next); + last_selected_idx.set(Some(row_index)); +} diff --git a/mlm_web_dioxus/src/components/sort_header.rs b/mlm_web_dioxus/src/components/sort_header.rs new file mode 100644 index 00000000..2422799e --- /dev/null +++ b/mlm_web_dioxus/src/components/sort_header.rs @@ -0,0 +1,37 @@ +use dioxus::prelude::*; + +#[component] +pub fn SortHeader( + label: String, + sort_key: S, + sort: Signal>, + mut asc: Signal, + mut from: Signal, +) -> Element { + let mut sort = sort; + let active = *sort.read() == Some(sort_key); + let arrow = if active { + if *asc.read() { "↑" } else { "↓" } + } else { + "" + }; + rsx! { + div { class: "header", + button { + r#type: "button", + class: "link", + onclick: move |_| { + if *sort.read() == Some(sort_key) { + let next_asc = !*asc.read(); + asc.set(next_asc); + } else { + sort.set(Some(sort_key)); + asc.set(false); + } + from.set(0); + }, + "{label}{arrow}" + } + } + } +} diff --git a/mlm_web_dioxus/src/components/status_message.rs b/mlm_web_dioxus/src/components/status_message.rs new file mode 100644 index 00000000..fd5ff529 --- /dev/null +++ b/mlm_web_dioxus/src/components/status_message.rs @@ -0,0 +1,26 @@ +use dioxus::prelude::*; + +#[component] +pub fn StatusMessage(mut status_msg: Signal>) -> Element { + let Some((msg, is_error)) = status_msg.read().as_ref().cloned() else { + return rsx! {}; + }; + + let class = if is_error { + "status-message error" + } else { + "status-message success" + }; + + rsx! { + div { class, + "{msg}" + button { + r#type: "button", + "aria-label": "Dismiss status message", + onclick: move |_| status_msg.set(None), + "⨯" + } + } + } +} diff --git a/mlm_web_dioxus/src/components/table_view.rs b/mlm_web_dioxus/src/components/table_view.rs new file mode 100644 index 00000000..a1bc8afa --- /dev/null +++ b/mlm_web_dioxus/src/components/table_view.rs @@ -0,0 +1,26 @@ +use dioxus::prelude::*; + +#[component] +pub fn TorrentGridTable( + grid_template: String, + extra_class: Option, + pending: bool, + children: Element, +) -> Element { + let refresh_class = if pending { " is-refreshing" } else { "" }; + let class = if let Some(extra_class) = extra_class { + format!("TorrentsTable table2 {extra_class}{refresh_class}") + } else { + format!("TorrentsTable table2{refresh_class}") + }; + rsx! { + div { class: "{class}", style: "--torrents-grid: {grid_template};", + {children} + if pending { + div { class: "stale-refresh-overlay", + div { class: "stale-refresh-spinner" } + } + } + } + } +} diff --git a/mlm_web_dioxus/src/components/task_box.rs b/mlm_web_dioxus/src/components/task_box.rs new file mode 100644 index 00000000..f7fdc5c8 --- /dev/null +++ b/mlm_web_dioxus/src/components/task_box.rs @@ -0,0 +1,43 @@ +use dioxus::prelude::*; + +#[derive(Props, Clone, PartialEq)] +pub struct TaskBoxProps { + pub last_run: Option, + pub result: Option>, + #[props(default = None)] + pub on_run: Option>, + #[props(default = true)] + pub show_result: bool, +} + +#[component] +pub fn TaskBox(props: TaskBoxProps) -> Element { + let last_run = props + .last_run + .clone() + .unwrap_or_else(|| "never".to_string()); + let has_run = props.last_run.is_some(); + let result_text = props + .result + .as_ref() + .map(|res| match res { + Ok(()) => "success".to_string(), + Err(e) => e.clone(), + }) + .unwrap_or_else(|| "running".to_string()); + let on_run = props.on_run; + + rsx! { + p { "Last run: {last_run}" } + if let Some(on_run) = on_run { + button { + r#type: "button", + onclick: move |_| on_run.call(()), + "run now" + } + } + if has_run && props.show_result { + p { "Result: {result_text}" } + } + } +} diff --git a/mlm_web_dioxus/src/config.rs b/mlm_web_dioxus/src/config.rs new file mode 100644 index 00000000..fbdae29d --- /dev/null +++ b/mlm_web_dioxus/src/config.rs @@ -0,0 +1,916 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::components::parse_location_query_pairs; +#[cfg(feature = "server")] +use mlm_core::ContextExt as _; +#[cfg(feature = "server")] +use mlm_core::autograbber::{ + PreparedTorrentMetaUpdate, finalize_torrent_meta_update, queue_torrent_meta_update, +}; +#[cfg(feature = "server")] +use mlm_core::qbittorrent::ensure_category_exists; +#[cfg(feature = "server")] +use mlm_db::DatabaseExt as _; +#[cfg(feature = "server")] +use mlm_db::Torrent; +#[cfg(feature = "server")] +use std::sync::Arc; +#[cfg(feature = "server")] +use tokio::sync::Semaphore; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ConfigPageData { + pub header_html: String, + pub qbittorrent: Vec, + pub autograbs: Vec, + pub goodreads_lists: Vec, + pub tags: Vec, + pub libraries: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct QbitBlock { + pub html: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct AutoGrabBlock { + pub mam_search: String, + pub html: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct GoodreadsListBlock { + pub html: String, + pub grabs: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TagBlock { + pub html: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct LibraryBlock { + pub html: String, +} + +#[server] +pub async fn get_config_page_data() -> Result { + let context = crate::error::get_context()?; + let config = context.config().await; + + let mut header_html = String::new(); + push_num(&mut header_html, "unsat_buffer", config.unsat_buffer); + push_num(&mut header_html, "wedge_buffer", config.wedge_buffer); + if config.add_torrents_stopped { + push_num( + &mut header_html, + "add_torrents_stopped", + config.add_torrents_stopped, + ); + } + if config.exclude_narrator_in_library_dir { + push_num( + &mut header_html, + "exclude_narrator_in_library_dir", + config.exclude_narrator_in_library_dir, + ); + } + push_num(&mut header_html, "search_interval", config.search_interval); + push_num(&mut header_html, "link_interval", config.link_interval); + push_num(&mut header_html, "import_interval", config.import_interval); + if !config.ignore_torrents.is_empty() { + push_yaml_nums(&mut header_html, "ignore_torrents", &config.ignore_torrents); + } + push_yaml_items(&mut header_html, "audio_types", &config.audio_types); + push_yaml_items(&mut header_html, "ebook_types", &config.ebook_types); + push_yaml_items(&mut header_html, "music_types", &config.music_types); + push_yaml_items(&mut header_html, "radio_types", &config.radio_types); + + let qbittorrent = config + .qbittorrent + .iter() + .map(|qbit| { + let mut html = String::new(); + push_str_json(&mut html, "url", &qbit.url); + if !qbit.username.is_empty() { + push_str_json(&mut html, "username", &qbit.username); + } + if !qbit.password.is_empty() { + html.push_str( + "password = \"\" # hidden
", + ); + } + if let Some(on_cleaned) = &qbit.on_cleaned { + html.push_str("

[qbittorrent.on_cleaned]

"); + if let Some(category) = &on_cleaned.category { + push_str_json(&mut html, "category", category); + } + if !on_cleaned.tags.is_empty() { + push_yaml_items(&mut html, "tags", &on_cleaned.tags); + } + } + QbitBlock { html } + }) + .collect::>(); + + let autograbs = config + .autograbs + .iter() + .map(|autograb| { + let mut html = String::new(); + if let Some(name) = autograb.filter.name.as_ref() { + push_str_json(&mut html, "name", name); + } + push_str_json(&mut html, "type", &autograb.kind); + push_str_json(&mut html, "cost", &autograb.cost); + if let Some(query) = autograb.query.as_ref() { + push_str_json(&mut html, "query", query); + } + if !autograb.search_in.is_empty() { + push_yaml_items(&mut html, "search_in", &autograb.search_in); + } + if let Some(sort_by) = autograb.sort_by.as_ref() { + push_str_json(&mut html, "sort_by", sort_by); + } + html.push_str(&render_filter_html(&autograb.filter)); + if let Some(search_interval) = autograb.search_interval { + push_num(&mut html, "search_interval", search_interval); + } + if let Some(unsat_buffer) = autograb.unsat_buffer { + push_num(&mut html, "unsat_buffer", unsat_buffer); + } + if let Some(max_active_downloads) = autograb.max_active_downloads { + push_num(&mut html, "max_active_downloads", max_active_downloads); + } + if let Some(wedge_buffer) = autograb.wedge_buffer { + push_num(&mut html, "wedge_buffer", wedge_buffer); + } + if autograb.dry_run { + push_num(&mut html, "dry_run", autograb.dry_run); + } + if let Some(category) = autograb.category.as_ref() { + push_str_json(&mut html, "category", category); + } + AutoGrabBlock { + mam_search: autograb.mam_search(), + html, + } + }) + .collect::>(); + + let goodreads_lists = config + .goodreads_lists + .iter() + .map(|list| { + let mut html = String::new(); + if let Some(name) = list.name.as_ref() { + push_str_json(&mut html, "name", name); + } + if let Some(search_interval) = list.search_interval { + push_num(&mut html, "search_interval", search_interval); + } + if let Some(unsat_buffer) = list.unsat_buffer { + push_num(&mut html, "unsat_buffer", unsat_buffer); + } + if let Some(wedge_buffer) = list.wedge_buffer { + push_num(&mut html, "wedge_buffer", wedge_buffer); + } + if list.dry_run { + push_num(&mut html, "dry_run", list.dry_run); + } + + let grabs = list + .grab + .iter() + .map(|grab| { + let mut grab_html = String::new(); + push_str_json(&mut grab_html, "cost", &grab.cost); + grab_html.push_str(&render_filter_html(&grab.filter)); + grab_html + }) + .collect::>(); + + GoodreadsListBlock { html, grabs } + }) + .collect::>(); + + let tags = config + .tags + .iter() + .map(|tag| { + let mut html = String::new(); + if let Some(name) = tag.filter.name.as_ref() { + push_str_json(&mut html, "name", name); + } + html.push_str(&render_filter_html(&tag.filter)); + if let Some(category) = tag.category.as_ref() { + push_str_json(&mut html, "category", category); + } + if !tag.tags.is_empty() { + push_yaml_items(&mut html, "tags", &tag.tags); + } + TagBlock { html } + }) + .collect::>(); + + let libraries = config + .libraries + .iter() + .map(|library| { + let mut html = String::new(); + if let Some(name) = library.options().name.as_ref() { + push_str_json(&mut html, "name", name); + } + match library { + mlm_core::config::Library::ByRipDir(l) => { + push_str_json(&mut html, "rip_dir", &l.rip_dir) + } + mlm_core::config::Library::ByDownloadDir(l) => { + push_str_json(&mut html, "download_dir", &l.download_dir) + } + mlm_core::config::Library::ByCategory(l) => { + push_str_json(&mut html, "category", &l.category) + } + } + push_str_json(&mut html, "library_dir", &library.options().library_dir); + if !library.tag_filters().allow_tags.is_empty() { + push_yaml_items(&mut html, "allow_tags", &library.tag_filters().allow_tags); + } + if !library.tag_filters().deny_tags.is_empty() { + push_yaml_items(&mut html, "deny_tags", &library.tag_filters().deny_tags); + } + if library.options().method != Default::default() { + push_str_json(&mut html, "method", &library.options().method); + } + if let Some(audio_types) = library.options().audio_types.as_ref() + && !audio_types.is_empty() + { + push_yaml_items(&mut html, "audio_types", audio_types); + } + if let Some(ebook_types) = library.options().ebook_types.as_ref() + && !ebook_types.is_empty() + { + push_yaml_items(&mut html, "ebook_types", ebook_types); + } + LibraryBlock { html } + }) + .collect::>(); + + Ok(ConfigPageData { + header_html, + qbittorrent, + autograbs, + goodreads_lists, + tags, + libraries, + }) +} + +#[server] +pub async fn apply_tag_filter_action( + qbit_index: usize, + tag_filter: usize, +) -> Result<(), ServerFnError> { + const BATCH_SIZE: usize = 100; + const MAX_CONCURRENT_MAM_REQUESTS: usize = 5; + + let context = crate::error::get_context()?; + let config = context.config().await; + + let tag_filter = config + .tags + .get(tag_filter) + .ok_or_else(|| ServerFnError::new("invalid tag_filter"))?; + let qbit_conf = config + .qbittorrent + .get(qbit_index) + .ok_or_else(|| ServerFnError::new("requires a qbit config"))?; + let qbit = qbit::Api::new_login_username_password( + &qbit_conf.url, + &qbit_conf.username, + &qbit_conf.password, + ) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let total_torrents = context + .db() + .r_transaction() + .map_err(|e| ServerFnError::new(e.to_string()))? + .len() + .primary::() + .map_err(|e| ServerFnError::new(e.to_string()))? as usize; + tracing::info!("Processing {} torrents for tag filter", total_torrents); + + let mam_semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_MAM_REQUESTS)); + let mut processed_count = 0; + let total_batches = total_torrents.div_ceil(BATCH_SIZE.max(1)); + let read_tx = context + .db() + .r_transaction() + .map_err(|e| ServerFnError::new(e.to_string()))?; + let scan = read_tx + .scan() + .primary::() + .map_err(|e| ServerFnError::new(e.to_string()))?; + let torrent_iter = scan.all().map_err(|e| ServerFnError::new(e.to_string()))?; + + let mut batch = Vec::with_capacity(BATCH_SIZE); + let mut batch_idx = 0usize; + let mut seen_count = 0usize; + + for torrent in torrent_iter { + batch.push(torrent.map_err(|e| ServerFnError::new(e.to_string()))?); + if batch.len() < BATCH_SIZE { + continue; + } + + seen_count += batch.len(); + process_tag_filter_batch( + &context, + &config, + &qbit, + qbit_conf, + tag_filter, + &mam_semaphore, + &batch, + total_torrents, + total_batches, + batch_idx, + seen_count, + &mut processed_count, + ) + .await?; + batch.clear(); + batch_idx += 1; + } + + if !batch.is_empty() { + seen_count += batch.len(); + process_tag_filter_batch( + &context, + &config, + &qbit, + qbit_conf, + tag_filter, + &mam_semaphore, + &batch, + total_torrents, + total_batches, + batch_idx, + seen_count, + &mut processed_count, + ) + .await?; + } + + tracing::info!( + "Tag filter application complete: {} torrents processed", + processed_count + ); + Ok(()) +} + +#[cfg(feature = "server")] +#[allow(clippy::too_many_arguments)] +async fn process_tag_filter_batch( + context: &mlm_core::Context, + config: &mlm_core::config::Config, + qbit: &qbit::Api, + qbit_conf: &mlm_core::config::QbitConfig, + tag_filter: &mlm_core::config::TagFilter, + mam_semaphore: &Arc, + batch: &[mlm_core::Torrent], + total_torrents: usize, + total_batches: usize, + batch_idx: usize, + seen_count: usize, + processed_count: &mut usize, +) -> Result<(), ServerFnError> { + use std::ops::Deref; + + tracing::info!( + "Processing batch {}/{} (torrents {}-{})", + batch_idx + 1, + total_batches, + seen_count.saturating_sub(batch.len()) + 1, + seen_count + ); + + let mut matched_torrents = Vec::with_capacity(batch.len()); + let mut meta_updates = Vec::new(); + + for torrent in batch { + match tag_filter.filter.matches_lib(torrent) { + Ok(matches) => { + if matches { + matched_torrents.push(torrent.clone()); + } + } + Err(_) => { + let Some(mam_id) = torrent.mam_id else { + continue; + }; + + let permit = mam_semaphore + .clone() + .acquire_owned() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let mam = context + .mam() + .map_err(|e| ServerFnError::new(e.to_string()))?; + let Some(mam_torrent) = mam + .get_torrent_info_by_id(mam_id) + .await + .map_err(|e| ServerFnError::new(e.to_string()))? + else { + drop(permit); + continue; + }; + drop(permit); + + let new_meta = mam_torrent + .as_meta() + .map_err(|e| ServerFnError::new(e.to_string()))?; + if new_meta != torrent.meta { + meta_updates.push((torrent.clone(), mam_torrent.clone(), new_meta)); + } + + if tag_filter.filter.matches(&mam_torrent) { + matched_torrents.push(torrent.clone()); + } + } + } + } + + if !meta_updates.is_empty() { + let (guard, rw) = context + .db() + .rw_async() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let mut pending_updates = Vec::new(); + let mut wrote_changes = false; + + for (torrent, mam_torrent, new_meta) in meta_updates { + match queue_torrent_meta_update( + &rw, + Some(&mam_torrent), + torrent, + new_meta, + false, + false, + ) + .map_err(|e| ServerFnError::new(e.to_string()))? + { + PreparedTorrentMetaUpdate::Unchanged => {} + PreparedTorrentMetaUpdate::Silent => { + wrote_changes = true; + } + PreparedTorrentMetaUpdate::Pending(pending) => { + wrote_changes = true; + pending_updates.push(pending); + } + } + } + + if wrote_changes { + rw.commit().map_err(|e| ServerFnError::new(e.to_string()))?; + } else { + drop(rw); + } + drop(guard); + + for pending in pending_updates { + finalize_torrent_meta_update(config, context.db(), *pending, &context.events) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + } + } + + if !matched_torrents.is_empty() { + let matched_count = matched_torrents.len(); + tracing::info!( + "Applying category/tags to {} torrents in batch", + matched_count + ); + + let hashes: Vec<&str> = matched_torrents.iter().map(|t| t.id.as_str()).collect(); + + if let Some(category) = &tag_filter.category { + ensure_category_exists(qbit, &qbit_conf.url, category) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + qbit.set_category(Some(hashes.clone()), category) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + tracing::info!("Set category '{}' for {} torrents", category, matched_count); + } + + if !tag_filter.tags.is_empty() { + let tags: Vec<&str> = tag_filter.tags.iter().map(Deref::deref).collect(); + qbit.add_tags(Some(hashes), tags) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + tracing::info!( + "Added tags {:?} to {} torrents", + tag_filter.tags, + matched_count + ); + } + + *processed_count += matched_count; + } + + tracing::info!( + "Completed batch {}/{}, total processed: {}/{}", + batch_idx + 1, + total_batches, + *processed_count, + total_torrents + ); + + Ok(()) +} + +#[component] +pub fn ConfigPage() -> Element { + let show_apply_tags = parse_location_query_pairs() + .into_iter() + .any(|(k, v)| k == "show_apply_tags" && matches!(v.as_str(), "1" | "true" | "yes")); + + let config_data = use_server_future(move || async move { get_config_page_data().await })?; + let data = config_data.suspend()?; + + match &*data.read() { + Ok(data) => rsx! { ConfigPageContent { data: data.clone(), show_apply_tags } }, + Err(e) => rsx! { + div { class: "config-page", + h1 { "Config" } + p { class: "error", "Error loading config page: {e}" } + } + }, + } +} + +#[component] +fn ConfigPageContent(data: ConfigPageData, show_apply_tags: bool) -> Element { + let mut qbit_index = use_signal(|| "0".to_string()); + let status_msg = use_signal(|| None::<(String, bool)>); + let applying = use_signal(|| None::); + + rsx! { + document::Title { "MLM - Config" } + + h1 { "Config" } + + if let Some((msg, is_error)) = status_msg.read().as_ref() { + p { class: if *is_error { "error" } else { "loading-indicator" }, + "{msg}" + } + } + + div { class: "infoboxes", + div { + class: "configbox", + dangerous_inner_html: "{data.header_html}" + } + } + + for qbit in data.qbittorrent.iter() { + div { class: "infoboxes", + div { class: "configbox", + div { class: "row", + h3 { "[[qbittorrent]]" } + } + div { dangerous_inner_html: "{qbit.html}" } + } + } + } + + for autograb in data.autograbs.iter() { + div { class: "infoboxes", + div { class: "configbox", + div { class: "row", + h3 { "[[autograb]]" } + a { href: "{autograb.mam_search}", target: "_blank", "search on MaM" } + } + div { dangerous_inner_html: "{autograb.html}" } + } + } + } + + for list in data.goodreads_lists.iter() { + div { class: "infoboxes", + div { class: "configbox", + div { class: "row", + h3 { "[[goodreads_list]]" } + } + div { dangerous_inner_html: "{list.html}" } + + for grab in list.grabs.iter() { + div { class: "infoboxes", + div { class: "configbox", + div { class: "row", + h4 { "[[goodreads_list.grab]]" } + } + div { dangerous_inner_html: "{grab}" } + } + } + } + } + } + } + + for (i, tag) in data.tags.iter().enumerate() { + div { class: "infoboxes", + div { class: "configbox", + div { class: "row", + h3 { "[[tag]]" } + if show_apply_tags { + div { + label { + "Client: " + input { + r#type: "number", + value: "{qbit_index.read()}", + oninput: move |ev| qbit_index.set(ev.value()), + } + } + button { + r#type: "button", + disabled: applying.read().is_some(), + onclick: { + let qbit_index_signal = qbit_index; + let mut status_msg = status_msg; + let mut applying = applying; + move |_| { + let qbit_index = qbit_index_signal + .read() + .parse::() + .unwrap_or_default(); + applying.set(Some(i)); + status_msg.set(None); + spawn(async move { + match apply_tag_filter_action(qbit_index, i).await { + Ok(_) => { + status_msg.set(Some(( + "Applied tags to matching torrents".to_string(), + false, + ))); + } + Err(e) => { + status_msg.set(Some((format!("Apply failed: {e}"), true))); + } + } + applying.set(None); + }); + } + }, + "apply to all" + } + } + } + } + div { dangerous_inner_html: "{tag.html}" } + } + } + } + + for library in data.libraries.iter() { + div { class: "infoboxes", + div { class: "configbox", + div { class: "row", + h3 { "[[library]]" } + } + div { dangerous_inner_html: "{library.html}" } + } + } + } + } +} + +#[cfg(feature = "server")] +fn push_num(html: &mut String, key: &str, value: T) { + html.push_str(&format!( + "{key} = {value}
" + )); +} + +#[cfg(feature = "server")] +fn push_str_json(html: &mut String, key: &str, value: &T) { + html.push_str(&format!( + "{} = {}
", + key, + to_json(value) + )); +} + +#[cfg(feature = "server")] +fn push_yaml_items(html: &mut String, key: &str, values: &[T]) { + html.push_str(&format!( + "{} = {}
", + key, + yaml_items(values, "string") + )); +} + +#[cfg(feature = "server")] +fn push_yaml_nums(html: &mut String, key: &str, values: &[T]) { + html.push_str(&format!( + "{} = {}
", + key, + yaml_items(values, "num") + )); +} + +#[cfg(feature = "server")] +fn yaml_items(values: &[T], class: &str) -> String { + if values.len() > 5 { + let mut s = String::from("[
"); + for v in values { + s.push_str(&format!( + "  {},
", + class, + to_json(v) + )); + } + s.push(']'); + s + } else { + let items = values + .iter() + .map(|v| format!("{}", class, to_json(v))) + .collect::>() + .join(", "); + format!("[ {} ]", items) + } +} + +#[cfg(feature = "server")] +fn to_json(value: &T) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()) +} + +#[cfg(feature = "server")] +fn render_filter_html(filter: &mlm_core::config::TorrentFilter) -> String { + use mlm_db::{AudiobookCategory, EbookCategory}; + use mlm_mam::serde::DATE_FORMAT; + + let mut html = String::new(); + + if filter.edition.categories.audio.is_some() || filter.edition.categories.ebook.is_some() { + html.push_str("categories = {
"); + + match filter.edition.categories.audio.as_ref() { + Some(cats) if cats.is_empty() => { + html.push_str("  audio = false
") + } + Some(cats) if cats == &AudiobookCategory::all() => { + html.push_str("  audio = true
") + } + Some(cats) => { + html.push_str(&format!( + "  audio = {}
", + yaml_items(cats, "string") + )); + } + None => html.push_str("  audio = true
"), + } + + match filter.edition.categories.ebook.as_ref() { + Some(cats) if cats.is_empty() => { + html.push_str("  ebook = false
") + } + Some(cats) if cats == &EbookCategory::all() => { + html.push_str("  ebook = true
") + } + Some(cats) => { + html.push_str(&format!( + "  ebook = {}
", + yaml_items(cats, "string") + )); + } + None => html.push_str("  ebook = true
"), + } + + html.push_str("}
"); + } + + if !filter.edition.languages.is_empty() { + push_yaml_items(&mut html, "languages", &filter.edition.languages); + } + + if filter.edition.flags.as_bitfield() > 0 { + html.push_str("flags = {"); + let flag_count = filter.edition.flags.as_search_bitfield().1.len(); + if flag_count > 3 { + html.push_str("
"); + push_flag_line_multi( + &mut html, + "crude_language", + filter.edition.flags.crude_language, + ); + push_flag_line_multi(&mut html, "violence", filter.edition.flags.violence); + push_flag_line_multi( + &mut html, + "some_explicit", + filter.edition.flags.some_explicit, + ); + push_flag_line_multi(&mut html, "explicit", filter.edition.flags.explicit); + push_flag_line_multi(&mut html, "abridged", filter.edition.flags.abridged); + push_flag_line_multi(&mut html, "lgbt", filter.edition.flags.lgbt); + } else { + push_flag_line_inline( + &mut html, + "crude_language", + filter.edition.flags.crude_language, + ); + push_flag_line_inline(&mut html, "violence", filter.edition.flags.violence); + push_flag_line_inline( + &mut html, + "some_explicit", + filter.edition.flags.some_explicit, + ); + push_flag_line_inline(&mut html, "explicit", filter.edition.flags.explicit); + push_flag_line_inline(&mut html, "abridged", filter.edition.flags.abridged); + push_flag_line_inline(&mut html, "lgbt", filter.edition.flags.lgbt); + } + html.push_str("}
"); + } + + if filter.edition.min_size.bytes() > 0 { + html.push_str(&format!( + "min_size = \"{}\"
", + filter.edition.min_size + )); + } + + if filter.edition.max_size.bytes() > 0 { + html.push_str(&format!( + "max_size = \"{}\"
", + filter.edition.max_size + )); + } + + if !filter.exclude_uploader.is_empty() { + push_yaml_items(&mut html, "exclude_uploader", &filter.exclude_uploader); + } + + if let Some(uploaded_after) = filter.uploaded_after { + let date = uploaded_after.format(&DATE_FORMAT).unwrap_or_default(); + html.push_str(&format!( + "uploaded_after = \"{}\"
", + date + )); + } + if let Some(uploaded_before) = filter.uploaded_before { + let date = uploaded_before.format(&DATE_FORMAT).unwrap_or_default(); + html.push_str(&format!( + "uploaded_before = \"{}\"
", + date + )); + } + + if let Some(v) = filter.min_seeders { + push_num(&mut html, "min_seeders", v); + } + if let Some(v) = filter.max_seeders { + push_num(&mut html, "max_seeders", v); + } + if let Some(v) = filter.min_leechers { + push_num(&mut html, "min_leechers", v); + } + if let Some(v) = filter.max_leechers { + push_num(&mut html, "max_leechers", v); + } + if let Some(v) = filter.min_snatched { + push_num(&mut html, "min_snatched", v); + } + if let Some(v) = filter.max_snatched { + push_num(&mut html, "max_snatched", v); + } + + html +} + +#[cfg(feature = "server")] +fn push_flag_line_multi(html: &mut String, key: &str, value: Option) { + if let Some(v) = value { + html.push_str(&format!( + "  {} = {}
", + key, v + )); + } +} + +#[cfg(feature = "server")] +fn push_flag_line_inline(html: &mut String, key: &str, value: Option) { + if let Some(v) = value { + html.push_str(&format!("{} = {} ", key, v)); + } +} diff --git a/mlm_web_dioxus/src/dto.rs b/mlm_web_dioxus/src/dto.rs new file mode 100644 index 00000000..38aa1f29 --- /dev/null +++ b/mlm_web_dioxus/src/dto.rs @@ -0,0 +1,188 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Series { + pub name: String, + pub entries: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TorrentMetaDiff { + pub field: String, + pub from: String, + pub to: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum TorrentCost { + GlobalFreeleech, + PersonalFreeleech, + Vip, + UseWedge, + TryWedge, + Ratio, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum MetadataSource { + Mam, + Manual, + File, + Match, +} + +impl std::fmt::Display for MetadataSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MetadataSource::Mam => write!(f, "MaM"), + MetadataSource::Manual => write!(f, "Manual"), + MetadataSource::File => write!(f, "File"), + MetadataSource::Match => write!(f, "Match"), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum EventType { + Grabbed { + grabber: Option, + cost: Option, + wedged: bool, + }, + Linked { + linker: Option, + library_path: PathBuf, + }, + Cleaned { + library_path: PathBuf, + files: Vec, + }, + Updated { + fields: Vec, + source: (MetadataSource, String), + }, + RemovedFromTracker, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Event { + pub id: String, + pub created_at: String, + pub event: EventType, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TorrentMeta { + pub title: String, + pub media_type: String, + pub size: u64, + pub filetypes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Torrent { + pub id: String, + pub meta: TorrentMeta, + pub library_path: Option, + pub library_files: Vec, + pub linker: Option, + pub category: Option, +} + +#[cfg(feature = "server")] +impl From<&mlm_core::TorrentCost> for TorrentCost { + fn from(cost: &mlm_core::TorrentCost) -> Self { + match cost { + mlm_core::TorrentCost::Vip => TorrentCost::Vip, + mlm_core::TorrentCost::GlobalFreeleech => TorrentCost::GlobalFreeleech, + mlm_core::TorrentCost::PersonalFreeleech => TorrentCost::PersonalFreeleech, + mlm_core::TorrentCost::UseWedge => TorrentCost::UseWedge, + mlm_core::TorrentCost::TryWedge => TorrentCost::TryWedge, + mlm_core::TorrentCost::Ratio => TorrentCost::Ratio, + } + } +} + +#[cfg(feature = "server")] +impl From<&mlm_core::MetadataSource> for MetadataSource { + fn from(source: &mlm_core::MetadataSource) -> Self { + match source { + mlm_core::MetadataSource::Mam => MetadataSource::Mam, + mlm_core::MetadataSource::Manual => MetadataSource::Manual, + mlm_core::MetadataSource::File => MetadataSource::File, + mlm_core::MetadataSource::Match => MetadataSource::Match, + } + } +} + +#[cfg(feature = "server")] +pub fn sanitize_html(value: &str) -> String { + mlm_parse::clean_html(value) +} + +#[cfg(feature = "server")] +pub fn sanitize_optional_html(value: Option) -> Option { + value.map(|value| sanitize_html(&value)) +} + +#[cfg(feature = "server")] +pub fn convert_torrent(db_torrent: &mlm_core::Torrent) -> Torrent { + Torrent { + id: db_torrent.id.clone(), + meta: TorrentMeta { + title: db_torrent.meta.title.clone(), + media_type: db_torrent.meta.media_type.as_str().to_string(), + size: db_torrent.meta.size.bytes(), + filetypes: db_torrent.meta.filetypes.clone(), + }, + library_path: db_torrent.library_path.clone(), + library_files: db_torrent.library_files.clone(), + linker: db_torrent.linker.clone(), + category: db_torrent.category.clone(), + } +} + +/// Convert a `mlm_core::EventType` to the DTO `EventType`. Used by both the +/// events page and the torrent-detail page so it lives here rather than in +/// either module. +#[cfg(feature = "server")] +pub fn convert_event_type(event: &mlm_core::EventType) -> EventType { + match event { + mlm_core::EventType::Grabbed { + grabber, + cost, + wedged, + } => EventType::Grabbed { + grabber: grabber.clone(), + cost: cost.as_ref().map(|c| c.into()), + wedged: *wedged, + }, + mlm_core::EventType::Linked { + linker, + library_path, + } => EventType::Linked { + linker: linker.clone(), + library_path: library_path.clone(), + }, + mlm_core::EventType::Cleaned { + library_path, + files, + } => EventType::Cleaned { + library_path: library_path.clone(), + files: files.clone(), + }, + mlm_core::EventType::Updated { fields, source } => EventType::Updated { + fields: fields + .iter() + .map(|f| TorrentMetaDiff { + field: f.field.to_string(), + from: f.from.clone(), + to: f.to.clone(), + }) + .collect(), + source: (MetadataSource::from(&source.0), source.1.clone()), + }, + mlm_core::EventType::RemovedFromTracker => EventType::RemovedFromTracker, + } +} diff --git a/mlm_web_dioxus/src/duplicate/components.rs b/mlm_web_dioxus/src/duplicate/components.rs new file mode 100644 index 00000000..807d6c2a --- /dev/null +++ b/mlm_web_dioxus/src/duplicate/components.rs @@ -0,0 +1,532 @@ +use std::collections::BTreeSet; +use std::sync::Arc; + +use crate::components::{ + ActiveFilterChip, ActiveFilters, FilterLink, PageSizeSelector, Pagination, SortHeader, + TorrentGridTable, TorrentTitleLink, build_query_string, encode_query_enum, + parse_location_query_pairs, parse_query_enum, set_location_query_string, +}; +use dioxus::prelude::*; + +use super::server_fns::{apply_duplicate_action, get_duplicate_data}; +use super::types::*; + +fn filter_name(filter: DuplicatePageFilter) -> &'static str { + match filter { + DuplicatePageFilter::Kind => "Type", + DuplicatePageFilter::Title => "Title", + DuplicatePageFilter::Author => "Authors", + DuplicatePageFilter::Narrator => "Narrators", + DuplicatePageFilter::Series => "Series", + DuplicatePageFilter::Filetype => "Filetypes", + } +} + +#[derive(Clone)] +struct PageQueryState { + sort: Option, + asc: bool, + filters: Vec<(DuplicatePageFilter, String)>, + from: usize, + page_size: usize, +} + +impl Default for PageQueryState { + fn default() -> Self { + Self { + sort: None, + asc: false, + filters: Vec::new(), + from: 0, + page_size: 500, + } + } +} + +fn parse_query_state() -> PageQueryState { + let mut state = PageQueryState::default(); + for (key, value) in parse_location_query_pairs() { + match key.as_str() { + "sort_by" => state.sort = parse_query_enum::(&value), + "asc" => state.asc = value == "true", + "from" => { + if let Ok(v) = value.parse::() { + state.from = v; + } + } + "page_size" => { + if let Ok(v) = value.parse::() { + state.page_size = v; + } + } + _ => { + if let Some(field) = parse_query_enum::(&key) { + state.filters.push((field, value)); + } + } + } + } + state +} + +fn build_query_url( + sort: Option, + asc: bool, + filters: &[(DuplicatePageFilter, String)], + from: usize, + page_size: usize, +) -> String { + let mut params = Vec::new(); + if let Some(sort) = sort.and_then(encode_query_enum) { + params.push(("sort_by".to_string(), sort)); + } + if asc { + params.push(("asc".to_string(), "true".to_string())); + } + if from > 0 { + params.push(("from".to_string(), from.to_string())); + } + if page_size != 500 { + params.push(("page_size".to_string(), page_size.to_string())); + } + for (field, value) in filters { + if let Some(name) = encode_query_enum(*field) { + params.push((name, value.clone())); + } + } + build_query_string(¶ms) +} + +#[component] +pub fn DuplicatePage() -> Element { + let _route: crate::app::Route = use_route(); + let initial_state = parse_query_state(); + let initial_sort = initial_state.sort; + let initial_asc = initial_state.asc; + let initial_filters = initial_state.filters.clone(); + let initial_from = initial_state.from; + let initial_page_size = initial_state.page_size; + let initial_request_key = build_query_url( + initial_state.sort, + initial_state.asc, + &initial_state.filters, + initial_state.from, + initial_state.page_size, + ); + + let sort = use_signal(move || initial_sort); + let asc = use_signal(move || initial_asc); + let filters = use_signal(move || initial_filters.clone()); + let mut from = use_signal(move || initial_from); + let mut page_size = use_signal(move || initial_page_size); + let mut selected = use_signal(BTreeSet::::new); + let mut last_selected_idx = use_signal(|| None::); + let mut status_msg = use_signal(|| None::<(String, bool)>); + let mut cached = use_signal(|| None::); + let loading_action = use_signal(|| false); + let mut last_request_key = use_signal(move || initial_request_key.clone()); + + let mut duplicate_data = use_server_future(move || async move { + get_duplicate_data( + *sort.read(), + *asc.read(), + filters.read().clone(), + Some(*from.read()), + Some(*page_size.read()), + ) + .await + }) + .ok(); + + let pending = duplicate_data + .as_ref() + .map(|resource| resource.pending()) + .unwrap_or(true); + let value = duplicate_data.as_ref().map(|resource| resource.value()); + + { + let route_state = parse_query_state(); + let route_request_key = build_query_url( + route_state.sort, + route_state.asc, + &route_state.filters, + route_state.from, + route_state.page_size, + ); + if *last_request_key.read() != route_request_key { + let mut sort = sort; + let mut asc = asc; + let mut filters_signal = filters; + let mut from = from; + let mut page_size = page_size; + let mut selected = selected; + let mut last_selected_idx = last_selected_idx; + sort.set(route_state.sort); + asc.set(route_state.asc); + filters_signal.set(route_state.filters); + from.set(route_state.from); + page_size.set(route_state.page_size); + selected.set(BTreeSet::new()); + last_selected_idx.set(None); + last_request_key.set(route_request_key); + if let Some(resource) = duplicate_data.as_mut() { + resource.restart(); + } + } + } + + let cache_value = value; + use_effect(move || { + if let Some(value) = &cache_value { + let value = value.read(); + if let Some(Ok(data)) = &*value { + cached.set(Some(data.clone())); + } + } + }); + + let data_to_show = { + if let Some(value) = &value { + let value = value.read(); + match &*value { + Some(Ok(data)) => Some(data.clone()), + _ => cached.read().clone(), + } + } else { + cached.read().clone() + } + }; + + use_effect(move || { + let query_string = build_query_url( + *sort.read(), + *asc.read(), + &filters.read().clone(), + *from.read(), + *page_size.read(), + ); + let should_restart = *last_request_key.read() != query_string; + if should_restart { + last_request_key.set(query_string.clone()); + set_location_query_string(&query_string); + if let Some(resource) = duplicate_data.as_mut() { + resource.restart(); + } + } + }); + + let mut active_chips = Vec::new(); + for (field, value) in filters.read().clone() { + active_chips.push(ActiveFilterChip { + label: format!("{}: {}", filter_name(field), value), + on_remove: Callback::new({ + let value = value.clone(); + let mut filters = filters; + let mut from = from; + move |_| { + filters + .write() + .retain(|(f, v)| !(*f == field && *v == value)); + from.set(0); + } + }), + }); + } + + let clear_all: Option> = if active_chips.is_empty() { + None + } else { + Some(Callback::new({ + let mut filters = filters; + let mut from = from; + move |_| { + filters.set(Vec::new()); + from.set(0); + } + })) + }; + + let all_row_ids = Arc::new( + data_to_show + .as_ref() + .map(|data| { + data.torrents + .iter() + .map(|p| p.torrent.mam_id) + .collect::>() + }) + .unwrap_or_default(), + ); + + rsx! { + div { class: "duplicate-page", + div { class: "row", + h1 { "Duplicate Torrents" } + div { class: "actions actions_torrent", + style: if selected.read().is_empty() { "" } else { "display: flex" }, + for action in [DuplicateBulkAction::Replace, DuplicateBulkAction::Remove] { + button { + r#type: "button", + disabled: *loading_action.read(), + onclick: { + let mut loading_action = loading_action; + let mut status_msg = status_msg; + let mut duplicate_data = duplicate_data; + let mut selected = selected; + move |_| { + let ids = selected.read().iter().copied().collect::>(); + if ids.is_empty() { + status_msg.set(Some(("Select at least one torrent".to_string(), true))); + return; + } + loading_action.set(true); + status_msg.set(None); + spawn(async move { + match apply_duplicate_action(action, ids).await { + Ok(_) => { + status_msg.set(Some((action.success_label().to_string(), false))); + selected.set(BTreeSet::new()); + if let Some(resource) = duplicate_data.as_mut() { + resource.restart(); + } + } + Err(e) => { + status_msg.set(Some((format!("{} failed: {e}", action.label()), true))); + } + } + loading_action.set(false); + }); + } + }, + "{action.label()}" + } + } + } + div { class: "table_options", + PageSizeSelector { + page_size: *page_size.read(), + options: vec![100, 500, 1000, 5000], + show_all_option: true, + on_change: move |next| { + page_size.set(next); + from.set(0); + }, + } + } + } + + p { "Torrents that were not selected due to an existing torrent in your library" } + + if let Some((msg, is_error)) = status_msg.read().as_ref() { + p { class: if *is_error { "error" } else { "loading-indicator" }, + "{msg}" + button { + r#type: "button", + style: "margin-left: 10px; cursor: pointer;", + onclick: move |_| status_msg.set(None), + "⨯" + } + } + } + + ActiveFilters { + chips: active_chips, + on_clear_all: clear_all, + } + + if let Some(data) = data_to_show { + if data.torrents.is_empty() { + p { + i { "There are currently no duplicate torrents" } + } + } else { + TorrentGridTable { + grid_template: "30px 110px 2fr 1fr 1fr 1fr 81px 100px 72px 157px" + .to_string(), + extra_class: Some("DuplicateTable".to_string()), + pending: pending && cached.read().is_some(), + { + let all_selected = data.torrents.iter().all(|p| selected.read().contains(&p.torrent.mam_id)); + rsx! { + div { class: "torrents-grid-row", + div { class: "header", + input { + r#type: "checkbox", + checked: all_selected, + onchange: { + let row_ids = data.torrents.iter().map(|p| p.torrent.mam_id).collect::>(); + move |ev| { + if ev.value() == "true" { + let mut next = selected.read().clone(); + for id in &row_ids { + next.insert(*id); + } + selected.set(next); + } else { + let mut next = selected.read().clone(); + for id in &row_ids { + next.remove(id); + } + selected.set(next); + } + } + }, + } + } + SortHeader { label: "Type", sort_key: DuplicatePageSort::Kind, sort, asc, from } + SortHeader { label: "Title", sort_key: DuplicatePageSort::Title, sort, asc, from } + SortHeader { label: "Authors", sort_key: DuplicatePageSort::Authors, sort, asc, from } + SortHeader { label: "Narrators", sort_key: DuplicatePageSort::Narrators, sort, asc, from } + SortHeader { label: "Series", sort_key: DuplicatePageSort::Series, sort, asc, from } + SortHeader { label: "Size", sort_key: DuplicatePageSort::Size, sort, asc, from } + div { class: "header", "Filetypes" } + div { class: "header", "Linked" } + SortHeader { label: "Added At", sort_key: DuplicatePageSort::CreatedAt, sort, asc, from } + } + } + } + + for (i, pair) in data.torrents.iter().enumerate() { + { + let row_id = pair.torrent.mam_id; + let row_selected = selected.read().contains(&row_id); + let all_row_ids = all_row_ids.clone(); + rsx! { + div { class: "torrents-grid-row", key: "{row_id}", + div { + input { + r#type: "checkbox", + checked: row_selected, + onclick: move |ev| { + let will_select = !selected.read().contains(&row_id); + let mut next = selected.read().clone(); + if ev.modifiers().shift() { + if let Some(last_idx) = *last_selected_idx.read() { + let (start, end) = if last_idx <= i { (last_idx, i) } else { (i, last_idx) }; + for id in &all_row_ids[start..=end] { + if will_select { next.insert(*id); } else { next.remove(id); } + } + } else if will_select { next.insert(row_id); } else { next.remove(&row_id); } + } else if will_select { next.insert(row_id); } else { next.remove(&row_id); } + selected.set(next); + last_selected_idx.set(Some(i)); + }, + } + } + div { + FilterLink { + field: DuplicatePageFilter::Kind, + value: pair.torrent.meta.media_type.clone(), + reset_from: true, + "{pair.torrent.meta.media_type}" + } + } + div { + TorrentTitleLink { + detail_id: pair.torrent.mam_id.to_string(), + field: DuplicatePageFilter::Title, + value: pair.torrent.meta.title.clone(), + reset_from: true, + "{pair.torrent.meta.title}" + } + } + div { + for author in pair.torrent.meta.authors.clone() { + FilterLink { + field: DuplicatePageFilter::Author, + value: author.clone(), + reset_from: true, + "{author}" + } + } + } + div { + for narrator in pair.torrent.meta.narrators.clone() { + FilterLink { + field: DuplicatePageFilter::Narrator, + value: narrator.clone(), + reset_from: true, + "{narrator}" + } + } + } + div { + for series in pair.torrent.meta.series.clone() { + FilterLink { + field: DuplicatePageFilter::Series, + value: series.name.clone(), + reset_from: true, + if series.entries.is_empty() { + "{series.name}" + } else { + "{series.name} #{series.entries}" + } + } + } + } + div { "{pair.torrent.meta.size}" } + div { + for filetype in pair.torrent.meta.filetypes.clone() { + FilterLink { + field: DuplicatePageFilter::Filetype, + value: filetype.clone(), + reset_from: true, + "{filetype}" + } + } + } + div {} + div { "{pair.torrent.created_at}" } + + div {} + div { class: "faint", "duplicate of:" } + div { "{pair.duplicate_of.meta.title}" } + div { "{pair.duplicate_of.meta.authors.join(\", \")}" } + div { "{pair.duplicate_of.meta.narrators.join(\", \")}" } + div { + for series in pair.duplicate_of.meta.series.clone() { + span { + if series.entries.is_empty() { + "{series.name} " + } else { + "{series.name} #{series.entries} " + } + } + } + } + div { "{pair.duplicate_of.meta.size}" } + div { "{pair.duplicate_of.meta.filetypes.join(\", \")}" } + div { + span { title: "{pair.duplicate_of.linked_path.clone().unwrap_or_default()}", + "{pair.duplicate_of.linked}" + } + } + div { "{pair.duplicate_of.created_at}" } + } + } + } + } + } + + p { class: "faint", + "Showing {data.from} to {data.from + data.torrents.len()} of {data.total}" + } + Pagination { + total: data.total, + from: data.from, + page_size: data.page_size, + on_change: move |new_from| from.set(new_from), + } + } + } else if let Some(value) = &value { + if let Some(Err(e)) = &*value.read() { + p { class: "error", "Error: {e}" } + } else { + p { "Loading duplicate torrents..." } + } + } else { + p { "Loading duplicate torrents..." } + } + } + } +} diff --git a/mlm_web_dioxus/src/duplicate/mod.rs b/mlm_web_dioxus/src/duplicate/mod.rs new file mode 100644 index 00000000..7d42967d --- /dev/null +++ b/mlm_web_dioxus/src/duplicate/mod.rs @@ -0,0 +1,7 @@ +mod components; +mod server_fns; +mod types; + +pub use components::DuplicatePage; +pub use server_fns::{apply_duplicate_action, get_duplicate_data}; +pub use types::*; diff --git a/mlm_web_dioxus/src/duplicate/server_fns.rs b/mlm_web_dioxus/src/duplicate/server_fns.rs new file mode 100644 index 00000000..1d34cd6f --- /dev/null +++ b/mlm_web_dioxus/src/duplicate/server_fns.rs @@ -0,0 +1,300 @@ +use dioxus::prelude::*; + +use super::types::*; + +#[cfg(feature = "server")] +use crate::error::{IntoServerFnError, OptionIntoServerFnError}; +#[cfg(feature = "server")] +use crate::utils::format_timestamp_db; +#[cfg(feature = "server")] +use mlm_core::{ContextExt, Torrent, cleaner::clean_torrent}; +#[cfg(feature = "server")] +use mlm_db::{DatabaseExt as _, DuplicateTorrent, SelectedTorrent, Timestamp, TorrentCost, ids}; +#[cfg(feature = "server")] +use mlm_parse::normalize_title; + +#[cfg(feature = "server")] +fn matches_filter(t: &DuplicateTorrent, field: DuplicatePageFilter, value: &str) -> bool { + match field { + DuplicatePageFilter::Kind => t.meta.media_type.as_str() == value, + DuplicatePageFilter::Title => t.meta.title == value, + DuplicatePageFilter::Author => t.meta.authors.contains(&value.to_string()), + DuplicatePageFilter::Narrator => t.meta.narrators.contains(&value.to_string()), + DuplicatePageFilter::Series => t.meta.series.iter().any(|s| s.name == value), + DuplicatePageFilter::Filetype => t.meta.filetypes.iter().any(|f| f == value), + } +} + +#[cfg(feature = "server")] +fn convert_candidate_row(t: &DuplicateTorrent) -> DuplicateCandidateRow { + DuplicateCandidateRow { + mam_id: t.mam_id, + meta: DuplicateMeta { + title: t.meta.title.clone(), + media_type: t.meta.media_type.as_str().to_string(), + authors: t.meta.authors.clone(), + narrators: t.meta.narrators.clone(), + series: t + .meta + .series + .iter() + .map(|series| crate::dto::Series { + name: series.name.clone(), + entries: series.entries.to_string(), + }) + .collect(), + size: t.meta.size.to_string(), + filetypes: t.meta.filetypes.clone(), + }, + created_at: format_timestamp_db(&t.created_at), + } +} + +#[cfg(feature = "server")] +fn convert_original_row(t: &Torrent) -> DuplicateOriginalRow { + DuplicateOriginalRow { + id: t.id.clone(), + mam_id: t.mam_id, + meta: DuplicateMeta { + title: t.meta.title.clone(), + media_type: t.meta.media_type.as_str().to_string(), + authors: t.meta.authors.clone(), + narrators: t.meta.narrators.clone(), + series: t + .meta + .series + .iter() + .map(|series| crate::dto::Series { + name: series.name.clone(), + entries: series.entries.to_string(), + }) + .collect(), + size: t.meta.size.to_string(), + filetypes: t.meta.filetypes.clone(), + }, + linked: t.library_path.is_some(), + linked_path: t + .library_path + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + created_at: format_timestamp_db(&t.created_at), + abs_id: t.meta.ids.get(ids::ABS).cloned(), + } +} + +#[server] +pub async fn get_duplicate_data( + sort: Option, + asc: bool, + filters: Vec<(DuplicatePageFilter, String)>, + from: Option, + page_size: Option, +) -> Result { + let context = crate::error::get_context()?; + + let mut from_val = from.unwrap_or(0); + let page_size_val = page_size.unwrap_or(500); + + let r = context.db().r_transaction().server_err()?; + + let mut duplicates = r + .scan() + .primary::() + .server_err()? + .all() + .server_err()? + .filter_map(|result| match result { + Ok(torrent) => Some(torrent), + Err(err) => { + tracing::warn!("Skipping duplicate row after scan error: {err}"); + None + } + }) + .filter(|t| { + filters + .iter() + .all(|(field, value)| matches_filter(t, *field, value)) + }) + .collect::>(); + + if let Some(sort_by) = sort { + duplicates.sort_by(|a, b| { + let ord = match sort_by { + DuplicatePageSort::Kind => a.meta.media_type.cmp(&b.meta.media_type), + DuplicatePageSort::Title => a.meta.title.cmp(&b.meta.title), + DuplicatePageSort::Authors => a.meta.authors.cmp(&b.meta.authors), + DuplicatePageSort::Narrators => a.meta.narrators.cmp(&b.meta.narrators), + DuplicatePageSort::Series => a.meta.series.cmp(&b.meta.series), + DuplicatePageSort::Size => a.meta.size.cmp(&b.meta.size), + DuplicatePageSort::CreatedAt => a.created_at.cmp(&b.created_at), + }; + if asc { ord } else { ord.reverse() } + }); + } + + let total = duplicates.len(); + if page_size_val > 0 && from_val >= total && total > 0 { + from_val = ((total - 1) / page_size_val) * page_size_val; + } + + let limit = if page_size_val == 0 { + usize::MAX + } else { + page_size_val + }; + + let mut rows = Vec::new(); + for duplicate in duplicates.into_iter().skip(from_val).take(limit) { + let Some(duplicate_of_id) = &duplicate.duplicate_of else { + continue; + }; + let Some(duplicate_of) = r + .get() + .primary::(duplicate_of_id.clone()) + .server_err()? + else { + continue; + }; + rows.push(DuplicatePairRow { + torrent: convert_candidate_row(&duplicate), + duplicate_of: convert_original_row(&duplicate_of), + }); + } + + let abs_url = context + .config() + .await + .audiobookshelf + .as_ref() + .map(|abs| abs.url.clone()); + + Ok(DuplicateData { + torrents: rows, + total, + from: from_val, + page_size: page_size_val, + abs_url, + }) +} + +#[server] +pub async fn apply_duplicate_action( + action: DuplicateBulkAction, + torrent_ids: Vec, +) -> Result<(), ServerFnError> { + if torrent_ids.is_empty() { + return Err(ServerFnError::new("No torrents selected")); + } + + let context = crate::error::get_context()?; + let config = context.config().await; + + match action { + DuplicateBulkAction::Replace => { + let mam = context.mam().server_err()?; + let r = context.db().r_transaction().server_err()?; + let replace_rows = torrent_ids + .into_iter() + .map(|mam_id| { + let duplicate_torrent = r + .get() + .primary::(mam_id) + .server_err()? + .ok_or_else(|| ServerFnError::new("Duplicate torrent not found"))?; + let hash = duplicate_torrent + .duplicate_of + .clone() + .ok_or_else(|| ServerFnError::new("No duplicate_of set"))?; + let duplicate_of = r + .get() + .primary::(hash) + .server_err()? + .ok_or_else(|| ServerFnError::new("Could not find original torrent"))?; + Ok((duplicate_torrent, duplicate_of)) + }) + .collect::, ServerFnError>>()?; + drop(r); + + let mut inserts = Vec::with_capacity(replace_rows.len()); + for (duplicate_torrent, _) in &replace_rows { + let Some(mam_torrent) = mam + .get_torrent_info_by_id(duplicate_torrent.mam_id) + .await + .server_err()? + else { + return Err(ServerFnError::new( + "Could not find duplicate torrent on MaM", + )); + }; + + let meta = mam_torrent.as_meta().server_err()?; + let title_search = normalize_title(&meta.title); + let tags: Vec<_> = config + .tags + .iter() + .filter(|t| t.filter.matches(&mam_torrent)) + .collect(); + let category = tags.iter().find_map(|t| t.category.clone()); + let tags = tags.iter().flat_map(|t| t.tags.clone()).collect(); + let cost = if mam_torrent.vip { + TorrentCost::Vip + } else if mam_torrent.personal_freeleech { + TorrentCost::PersonalFreeleech + } else if mam_torrent.free { + TorrentCost::GlobalFreeleech + } else { + TorrentCost::TryWedge + }; + + inserts.push(SelectedTorrent { + mam_id: mam_torrent.id, + hash: None, + dl_link: mam_torrent + .dl + .clone() + .or_else(|| duplicate_torrent.dl_link.clone()) + .ok_or_server_err("No download link for duplicate torrent")?, + unsat_buffer: None, + wedge_buffer: None, + cost, + category, + tags, + title_search, + meta, + grabber: None, + created_at: Timestamp::now(), + started_at: None, + removed_at: None, + }); + } + + let (_guard, rw) = context.db().rw_async().await.server_err()?; + for insert in inserts { + rw.insert(insert).server_err()?; + } + for (duplicate_torrent, _) in &replace_rows { + rw.remove(duplicate_torrent.clone()).server_err()?; + } + rw.commit().server_err()?; + + for (_, duplicate_of) in replace_rows { + clean_torrent(&config, context.db(), duplicate_of, false, &context.events) + .await + .server_err()?; + } + } + DuplicateBulkAction::Remove => { + let (_guard, rw) = context.db().rw_async().await.server_err()?; + for mam_id in torrent_ids { + let Some(torrent) = rw.get().primary::(mam_id).server_err()? + else { + continue; + }; + rw.remove(torrent).server_err()?; + } + rw.commit().server_err()?; + } + } + + Ok(()) +} diff --git a/mlm_web_dioxus/src/duplicate/types.rs b/mlm_web_dioxus/src/duplicate/types.rs new file mode 100644 index 00000000..d741e8fe --- /dev/null +++ b/mlm_web_dioxus/src/duplicate/types.rs @@ -0,0 +1,91 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum DuplicatePageSort { + Kind, + Title, + Authors, + Narrators, + Series, + Size, + CreatedAt, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum DuplicatePageFilter { + Kind, + Title, + Author, + Narrator, + Series, + Filetype, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct DuplicateMeta { + pub title: String, + pub media_type: String, + pub authors: Vec, + pub narrators: Vec, + pub series: Vec, + pub size: String, + pub filetypes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct DuplicateCandidateRow { + pub mam_id: u64, + pub meta: DuplicateMeta, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct DuplicateOriginalRow { + pub id: String, + pub mam_id: Option, + pub meta: DuplicateMeta, + pub linked: bool, + pub linked_path: Option, + pub created_at: String, + pub abs_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct DuplicatePairRow { + pub torrent: DuplicateCandidateRow, + pub duplicate_of: DuplicateOriginalRow, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)] +pub struct DuplicateData { + pub torrents: Vec, + pub total: usize, + pub from: usize, + pub page_size: usize, + pub abs_url: Option, +} + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum DuplicateBulkAction { + Replace, + Remove, +} + +impl DuplicateBulkAction { + pub(super) fn label(self) -> &'static str { + match self { + Self::Replace => "replace original", + Self::Remove => "remove duplicate", + } + } + + pub(super) fn success_label(self) -> &'static str { + match self { + Self::Replace => "Replaced original torrents", + Self::Remove => "Removed duplicate torrents", + } + } +} diff --git a/mlm_web_dioxus/src/error.rs b/mlm_web_dioxus/src/error.rs new file mode 100644 index 00000000..92a2ad3b --- /dev/null +++ b/mlm_web_dioxus/src/error.rs @@ -0,0 +1,41 @@ +use dioxus::prelude::ServerFnError; + +/// Helper trait for converting errors to `ServerFnError`. +pub trait IntoServerFnError { + /// Convert an error to `ServerFnError` using the error's Display representation. + fn server_err(self) -> Result; + + /// Convert an error to `ServerFnError` with a context message. + fn server_err_ctx(self, msg: &str) -> Result; +} + +impl IntoServerFnError for Result { + fn server_err(self) -> Result { + self.map_err(|e| ServerFnError::new(e.to_string())) + } + + fn server_err_ctx(self, msg: &str) -> Result { + self.map_err(|e| ServerFnError::new(format!("{}: {}", msg, e))) + } +} + +/// Helper trait for converting Option to `ServerFnError`. +pub trait OptionIntoServerFnError { + /// Convert `None` to `ServerFnError` with a message. + fn ok_or_server_err(self, msg: &str) -> Result; +} + +impl OptionIntoServerFnError for Option { + fn ok_or_server_err(self, msg: &str) -> Result { + self.ok_or_else(|| ServerFnError::new(msg.to_string())) + } +} + +/// Extract the application `Context` from the current Dioxus fullstack request. +#[cfg(feature = "server")] +pub fn get_context() -> Result { + use dioxus_fullstack::FullstackContext; + FullstackContext::current() + .and_then(|ctx| ctx.extension()) + .ok_or_server_err("Context not found") +} diff --git a/mlm_web_dioxus/src/errors/components.rs b/mlm_web_dioxus/src/errors/components.rs new file mode 100644 index 00000000..b6d705be --- /dev/null +++ b/mlm_web_dioxus/src/errors/components.rs @@ -0,0 +1,318 @@ +use std::collections::BTreeSet; +use std::sync::Arc; + +use crate::components::{ + ActiveFilterChip, ActiveFilters, FilterLink, SortHeader, TorrentGridTable, + set_location_query_string, update_row_selection, +}; +use crate::sse::ERRORS_UPDATE_TRIGGER; +use dioxus::prelude::*; + +use super::server_fns::*; +use super::types::*; + +#[component] +pub fn ErrorsPage() -> Element { + let _route: crate::app::Route = use_route(); + let initial_state = parse_query_state(); + let initial_sort = initial_state.sort; + let initial_asc = initial_state.asc; + let initial_filters = initial_state.filters.clone(); + let initial_request_key = build_query_url( + initial_state.sort, + initial_state.asc, + &initial_state.filters, + ); + + let sort = use_signal(move || initial_sort); + let asc = use_signal(move || initial_asc); + let filters = use_signal(move || initial_filters.clone()); + let mut selected = use_signal(BTreeSet::::new); + let last_selected_idx = use_signal(|| None::); + let mut status_msg = use_signal(|| None::<(String, bool)>); + let mut cached = use_signal(|| None::); + let loading_action = use_signal(|| false); + let mut last_request_key = use_signal(move || initial_request_key.clone()); + let mut last_errors_trigger = use_signal(|| 0u32); + let from = use_signal(|| 0usize); + + let mut errors_data = match use_server_future(move || async move { + get_errors_data(*sort.read(), *asc.read(), filters.read().clone()).await + }) { + Ok(resource) => resource, + Err(_) => { + return rsx! { + div { class: "errors-page", + h1 { "Torrent Errors" } + p { "Loading errors..." } + } + }; + } + }; + + let value = errors_data.value(); + let pending = errors_data.pending(); + + { + let route_state = parse_query_state(); + let route_request_key = + build_query_url(route_state.sort, route_state.asc, &route_state.filters); + if *last_request_key.read() != route_request_key { + let mut sort = sort; + let mut asc = asc; + let mut filters_signal = filters; + sort.set(route_state.sort); + asc.set(route_state.asc); + filters_signal.set(route_state.filters); + last_request_key.set(route_request_key); + errors_data.restart(); + } + } + + use_effect(move || { + let value = value.read(); + if let Some(Ok(data)) = &*value { + cached.set(Some(data.clone())); + } + }); + + use_effect(move || { + let current_trigger = *ERRORS_UPDATE_TRIGGER.read(); + if *last_errors_trigger.read() != current_trigger { + last_errors_trigger.set(current_trigger); + errors_data.restart(); + } + }); + + let data_to_show = { + let value = value.read(); + match &*value { + Some(Ok(data)) => Some(data.clone()), + _ => cached.read().clone(), + } + }; + + use_effect(move || { + let sort = *sort.read(); + let asc = *asc.read(); + let filters = filters.read().clone(); + let query_string = build_query_url(sort, asc, &filters); + let should_restart = *last_request_key.read() != query_string; + if should_restart { + last_request_key.set(query_string.clone()); + set_location_query_string(&query_string); + errors_data.restart(); + } + }); + + let mut active_chips = Vec::new(); + for (field, value) in filters.read().clone() { + active_chips.push(ActiveFilterChip { + label: format!("{}: {}", filter_name(field), value), + on_remove: Callback::new({ + let value = value.clone(); + let mut filters = filters; + move |_| { + filters + .write() + .retain(|(f, v)| !(*f == field && *v == value)); + } + }), + }); + } + + let clear_all: Option> = if active_chips.is_empty() { + None + } else { + Some(Callback::new({ + let mut filters = filters; + move |_| filters.set(Vec::new()) + })) + }; + + let all_row_ids = Arc::new( + data_to_show + .as_ref() + .map(|data| { + data.errors + .iter() + .map(|e| e.id_json.clone()) + .collect::>() + }) + .unwrap_or_default(), + ); + + rsx! { + div { class: "errors-page", + div { class: "row", + h1 { "Torrent Errors" } + p { "Errors encountered while grabbing, linking, or cleaning torrents" } + } + + if let Some((msg, is_error)) = status_msg.read().as_ref() { + p { class: if *is_error { "error" } else { "loading-indicator" }, + "{msg}" + button { + r#type: "button", + style: "margin-left: 10px; cursor: pointer;", + onclick: move |_| status_msg.set(None), + "⨯" + } + } + } + + ActiveFilters { + chips: active_chips, + on_clear_all: clear_all, + } + + if let Some(data) = data_to_show { + if data.errors.is_empty() { + p { + i { "There are currently no errors" } + } + } else { + div { class: "actions actions_error", + style: if selected.read().is_empty() { "" } else { "display: flex" }, + button { + r#type: "button", + disabled: *loading_action.read(), + onclick: { + let mut loading_action = loading_action; + let mut status_msg = status_msg; + let mut errors_data = errors_data; + let mut selected = selected; + move |_| { + let ids = selected.read().iter().cloned().collect::>(); + if ids.is_empty() { + status_msg.set(Some(("Select at least one error".to_string(), true))); + return; + } + loading_action.set(true); + status_msg.set(None); + spawn(async move { + match remove_errors_action(ids).await { + Ok(_) => { + status_msg.set(Some(("Removed errors".to_string(), false))); + selected.set(BTreeSet::new()); + errors_data.restart(); + } + Err(e) => { + status_msg.set(Some((format!("remove failed: {e}"), true))); + } + } + loading_action.set(false); + }); + } + }, + "remove" + } + } + + TorrentGridTable { + grid_template: "30px 100px 1fr 1fr 157px 88px".to_string(), + extra_class: Some("ErrorsTable".to_string()), + pending: pending && cached.read().is_some(), + { + let all_selected = data.errors.iter().all(|e| selected.read().contains(&e.id_json)); + rsx! { + div { class: "torrents-grid-row", + div { class: "header", + input { + r#type: "checkbox", + checked: all_selected, + onchange: { + let row_ids = data.errors.iter().map(|e| e.id_json.clone()).collect::>(); + move |ev| { + if ev.value() == "true" { + let mut next = selected.read().clone(); + for id in &row_ids { + next.insert(id.clone()); + } + selected.set(next); + } else { + let mut next = selected.read().clone(); + for id in &row_ids { + next.remove(id); + } + selected.set(next); + } + } + }, + } + } + SortHeader { label: "Step".to_string(), sort_key: ErrorsPageSort::Step, sort, asc, from } + SortHeader { label: "Title".to_string(), sort_key: ErrorsPageSort::Title, sort, asc, from } + SortHeader { label: "Error".to_string(), sort_key: ErrorsPageSort::Error, sort, asc, from } + SortHeader { label: "When".to_string(), sort_key: ErrorsPageSort::CreatedAt, sort, asc, from } + div { class: "header", "" } + } + } + } + + for (i, error) in data.errors.into_iter().enumerate() { + { + let row_id = error.id_json.clone(); + let row_selected = selected.read().contains(&row_id); + let all_row_ids = all_row_ids.clone(); + rsx! { + div { class: "torrents-grid-row", key: "{row_id}", + div { + input { + r#type: "checkbox", + checked: row_selected, + onclick: { + let row_id = row_id.clone(); + move |ev: MouseEvent| { + update_row_selection( + &ev, + selected, + last_selected_idx, + all_row_ids.as_ref(), + &row_id, + i, + ); + } + }, + } + } + div { + FilterLink { + field: ErrorsPageFilter::Step, + value: error.step.clone(), + "{error.step}" + } + } + div { + FilterLink { + field: ErrorsPageFilter::Title, + value: error.title.clone(), + "{error.title}" + } + } + div { "{error.error}" } + div { "{error.created_at}" } + div { + if let Some(mam_id) = error.mam_id { + a { href: "/dioxus/torrents/{mam_id}", "open" } + a { + href: "https://www.myanonamouse.net/t/{mam_id}", + target: "_blank", + "MaM" + } + } + } + } + } + } + } + } + } + } else if let Some(Err(e)) = &*value.read() { + p { class: "error", "Error: {e}" } + } else { + p { "Loading errors..." } + } + } + } +} diff --git a/mlm_web_dioxus/src/errors/mod.rs b/mlm_web_dioxus/src/errors/mod.rs new file mode 100644 index 00000000..544b2683 --- /dev/null +++ b/mlm_web_dioxus/src/errors/mod.rs @@ -0,0 +1,7 @@ +pub mod components; +pub mod server_fns; +pub mod types; + +pub use components::*; +pub use server_fns::*; +pub use types::*; diff --git a/mlm_web_dioxus/src/errors/server_fns.rs b/mlm_web_dioxus/src/errors/server_fns.rs new file mode 100644 index 00000000..4c7254f4 --- /dev/null +++ b/mlm_web_dioxus/src/errors/server_fns.rs @@ -0,0 +1,104 @@ +use dioxus::prelude::*; + +#[cfg(feature = "server")] +use crate::error::IntoServerFnError; +#[cfg(feature = "server")] +use crate::utils::format_timestamp_db; +#[cfg(feature = "server")] +use mlm_core::ContextExt; +#[cfg(feature = "server")] +use mlm_db::{DatabaseExt as _, ErroredTorrent, ErroredTorrentId, ErroredTorrentKey, ids}; + +use super::types::*; + +#[server] +pub async fn get_errors_data( + sort: Option, + asc: bool, + filters: Vec<(ErrorsPageFilter, String)>, +) -> Result { + let context = crate::error::get_context()?; + + let mut errors = context + .db() + .r_transaction() + .server_err()? + .scan() + .secondary::(ErroredTorrentKey::created_at) + .server_err()? + .all() + .server_err()? + .rev() + .collect::, _>>() + .server_err()? + .into_iter() + .filter(|t| { + filters.iter().all(|(field, value)| match field { + ErrorsPageFilter::Step => error_step(&t.id) == value, + ErrorsPageFilter::Title => t.title == *value, + }) + }) + .collect::>(); + + if let Some(sort_by) = sort { + errors.sort_by(|a, b| { + let ord = match sort_by { + ErrorsPageSort::Step => error_step(&a.id).cmp(error_step(&b.id)), + ErrorsPageSort::Title => a.title.cmp(&b.title), + ErrorsPageSort::Error => a.error.cmp(&b.error), + ErrorsPageSort::CreatedAt => a.created_at.cmp(&b.created_at), + }; + if asc { ord } else { ord.reverse() } + }); + } + + Ok(ErrorsData { + errors: errors.into_iter().map(convert_error_row).collect(), + }) +} + +#[server] +pub async fn remove_errors_action(error_ids: Vec) -> Result<(), ServerFnError> { + if error_ids.is_empty() { + return Err(ServerFnError::new("No errors selected")); + } + + let context = crate::error::get_context()?; + + let (_guard, rw) = context.db().rw_async().await.server_err()?; + + for error_id in error_ids { + let id = serde_json::from_str::(&error_id).server_err()?; + let Some(error) = rw.get().primary::(id).server_err()? else { + continue; + }; + rw.remove(error).server_err()?; + } + + rw.commit().server_err()?; + Ok(()) +} + +#[cfg(feature = "server")] +fn error_step(id: &ErroredTorrentId) -> &'static str { + match id { + ErroredTorrentId::Grabber(_) => "auto grabber", + ErroredTorrentId::Linker(_) => "library linker", + ErroredTorrentId::Cleaner(_) => "library cleaner", + } +} + +#[cfg(feature = "server")] +fn convert_error_row(error: ErroredTorrent) -> ErrorsRow { + ErrorsRow { + id_json: serde_json::to_string(&error.id).unwrap_or_default(), + step: error_step(&error.id).to_string(), + title: error.title, + error: error.error, + created_at: format_timestamp_db(&error.created_at), + mam_id: error + .meta + .and_then(|meta| meta.ids.get(ids::MAM).cloned()) + .and_then(|id| id.parse::().ok()), + } +} diff --git a/mlm_web_dioxus/src/errors/types.rs b/mlm_web_dioxus/src/errors/types.rs new file mode 100644 index 00000000..233424a2 --- /dev/null +++ b/mlm_web_dioxus/src/errors/types.rs @@ -0,0 +1,86 @@ +use serde::{Deserialize, Serialize}; + +use crate::components::{ + build_query_string, encode_query_enum, parse_location_query_pairs, parse_query_enum, +}; + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ErrorsPageSort { + Step, + Title, + Error, + CreatedAt, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum ErrorsPageFilter { + Step, + Title, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ErrorsRow { + pub id_json: String, + pub step: String, + pub title: String, + pub error: String, + pub created_at: String, + pub mam_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)] +pub struct ErrorsData { + pub errors: Vec, +} + +pub fn filter_name(filter: ErrorsPageFilter) -> &'static str { + match filter { + ErrorsPageFilter::Step => "Step", + ErrorsPageFilter::Title => "Title", + } +} + +#[derive(Clone, Default)] +pub struct PageQueryState { + pub sort: Option, + pub asc: bool, + pub filters: Vec<(ErrorsPageFilter, String)>, +} + +pub fn parse_query_state() -> PageQueryState { + let mut state = PageQueryState::default(); + for (key, value) in parse_location_query_pairs() { + match key.as_str() { + "sort_by" => state.sort = parse_query_enum::(&value), + "asc" => state.asc = value == "true", + _ => { + if let Some(field) = parse_query_enum::(&key) { + state.filters.push((field, value)); + } + } + } + } + state +} + +pub fn build_query_url( + sort: Option, + asc: bool, + filters: &[(ErrorsPageFilter, String)], +) -> String { + let mut params = Vec::new(); + if let Some(sort) = sort.and_then(encode_query_enum) { + params.push(("sort_by".to_string(), sort)); + } + if asc { + params.push(("asc".to_string(), "true".to_string())); + } + for (field, value) in filters { + if let Some(name) = encode_query_enum(*field) { + params.push((name, value.clone())); + } + } + build_query_string(¶ms) +} diff --git a/mlm_web_dioxus/src/events/components.rs b/mlm_web_dioxus/src/events/components.rs new file mode 100644 index 00000000..0de5b61a --- /dev/null +++ b/mlm_web_dioxus/src/events/components.rs @@ -0,0 +1,479 @@ +use crate::components::Pagination; +use crate::dto::{Event, EventType, Torrent, TorrentCost}; +use crate::sse::EVENTS_UPDATE_TRIGGER; +use crate::utils::format_size; +use dioxus::prelude::*; + +use super::server_fns::get_events_data; +use super::types::{EventData, EventsFilter}; + +#[component] +pub fn EventsPage() -> Element { + let show = use_signal(|| None::); + let grabber = use_signal(|| None::); + let linker = use_signal(|| None::); + let category = use_signal(|| None::); + let has_updates = use_signal(|| None::); + let field = use_signal(|| None::); + let from = use_signal(|| 0usize); + let page_size = use_signal(|| 500usize); + + let mut cached_data = use_signal(|| None::); + + let mut event_data = match use_server_future(move || async move { + get_events_data( + EventsFilter { + show: show.read().clone(), + grabber: grabber.read().clone(), + linker: linker.read().clone(), + category: category.read().clone(), + has_updates: has_updates.read().clone(), + field: field.read().clone(), + }, + Some(*from.read()), + Some(*page_size.read()), + ) + .await + }) { + Ok(resource) => resource, + Err(_) => { + return rsx! { + div { class: "events-page", + EventsHeader { + show: show, + grabber: grabber, + linker: linker, + category: category, + has_updates: has_updates, + page_size: page_size, + from: from, + } + p { "Loading..." } + } + }; + } + }; + + use_effect(move || { + let _ = *EVENTS_UPDATE_TRIGGER.read(); + event_data.restart(); + }); + + let current_value = event_data.value(); + let is_loading = event_data.pending(); + + // Sync cached_data when current_value changes, avoiding render-phase mutation + use_effect(move || { + let val = current_value.read(); + if let Some(Ok(data)) = &*val { + cached_data.set(Some(data.clone())); + } + }); + + let data_to_show = { + let val = current_value.read(); + match &*val { + Some(Ok(data)) => data.clone(), + Some(Err(_)) | None => cached_data.read().clone().unwrap_or_default(), + } + }; + let has_error = matches!(&*current_value.read(), Some(Err(_))); + let show_loading = is_loading && cached_data.read().is_some(); + + rsx! { + div { class: "events-page", + EventsHeader { + show: show, + grabber: grabber, + linker: linker, + category: category, + has_updates: has_updates, + page_size: page_size, + from: from, + } + + if has_error { + if let Some(Err(e)) = &*current_value.read() { + p { class: "error", "Error: {e}" } + } + } else { + EventsTable { + data: data_to_show, + from: from, + loading: show_loading + } + } + } + } +} + +#[component] +fn EventsHeader( + mut show: Signal>, + mut grabber: Signal>, + mut linker: Signal>, + mut category: Signal>, + mut has_updates: Signal>, + mut page_size: Signal, + mut from: Signal, +) -> Element { + rsx! { + div { class: "row", + h1 { "Events (Dioxus)" } + div { class: "option_group query", + "Show: " + button { + class: if show.read().is_none() { "active" }, + onclick: move |_| { + show.set(None); + from.set(0); + }, + "All" + } + button { + class: if show.read().as_deref() == Some("grabber") { "active" }, + onclick: move |_| { + show.set(Some("grabber".to_string())); + from.set(0); + }, + "Grabber" + } + button { + class: if show.read().as_deref() == Some("linker") { "active" }, + onclick: move |_| { + show.set(Some("linker".to_string())); + from.set(0); + }, + "Linker" + } + button { + class: if show.read().as_deref() == Some("cleaner") { "active" }, + onclick: move |_| { + show.set(Some("cleaner".to_string())); + from.set(0); + }, + "Cleaner" + } + button { + class: if show.read().as_deref() == Some("updated") { "active" }, + onclick: move |_| { + show.set(Some("updated".to_string())); + from.set(0); + }, + "Updated" + } + button { + class: if show.read().as_deref() == Some("removed") { "active" }, + onclick: move |_| { + show.set(Some("removed".to_string())); + from.set(0); + }, + "Removed" + } + } + div { class: "option_group query", + "Filters: " + button { + class: if has_updates.read().is_some() { "active" }, + onclick: move |_| { + if has_updates.read().is_some() { + has_updates.set(None); + } else { + has_updates.set(Some("true".to_string())); + } + from.set(0); + }, + "Has Updates" + } + if let Some(l) = linker.read().clone() { + label { class: "active", + "Linker: {l} " + button { + r#type: "button", + "aria-label": "Remove linker filter", + onclick: move |_| { + linker.set(None); + from.set(0); + }, + "×" + } + } + } + if let Some(g) = grabber.read().clone() { + label { class: "active", + "Grabber: {g} " + button { + r#type: "button", + "aria-label": "Remove grabber filter", + onclick: move |_| { + grabber.set(None); + from.set(0); + }, + "×" + } + } + } + if let Some(c) = category.read().clone() { + label { class: "active", + "Category: {c} " + button { + r#type: "button", + "aria-label": "Remove category filter", + onclick: move |_| { + category.set(None); + from.set(0); + }, + "×" + } + } + } + } + div { class: "option_group query", + "Page size: " + select { + value: "{page_size}", + onchange: move |ev| { + if let Ok(v) = ev.value().parse::() { + page_size.set(v); + from.set(0); + } + }, + option { value: "100", "100" } + option { value: "500", "500" } + option { value: "1000", "1000" } + option { value: "5000", "5000" } + } + } + } + } +} + +#[component] +fn EventsTable(data: EventData, mut from: Signal, loading: bool) -> Element { + rsx! { + div { id: "events-table-container", + if loading { + div { class: "loading-indicator", "Updating..." } + } + if data.events.is_empty() { + p { i { "No events yet" } } + } else { + div { id: "events-list", class: "EventsTable table", + for item in data.events.clone() { + EventListItem { + event: item.event, + torrent: item.torrent, + replacement: item.replacement, + show_created_at: true, + } + } + } + Pagination { + total: data.total, + from: *from.read(), + page_size: data.page_size, + on_change: move |new_from| { + from.set(new_from); + } + } + } + } + } +} + +#[component] +pub fn EventListItem( + event: Event, + torrent: Option, + replacement: Option, + show_created_at: bool, +) -> Element { + rsx! { + if show_created_at { + div { "{event.created_at}" } + } + div { + EventContent { + event, + torrent, + replacement, + } + } + } +} + +#[component] +pub fn EventContent( + event: Event, + torrent: Option, + replacement: Option, +) -> Element { + let media_type = torrent + .as_ref() + .map(|t| t.meta.media_type.clone()) + .unwrap_or_default(); + let title = torrent.as_ref().map(|t| t.meta.title.clone()); + let torrent_id = torrent.as_ref().map(|t| t.id.clone()); + let category = torrent.as_ref().and_then(|t| t.category.clone()); + + let render_torrent_link = |id: Option, title: Option| { + if let (Some(id), Some(title)) = (id, title) { + rsx! { a { href: "/dioxus/torrents/{id}", "{title}" } } + } else { + rsx! { "" } + } + }; + + match event.event { + EventType::Grabbed { + grabber, + cost, + wedged, + } => { + let cost_text = if wedged { + " using a wedge".to_string() + } else { + match cost { + Some(TorrentCost::Vip) => " as VIP".to_string(), + Some(TorrentCost::GlobalFreeleech) => " as Freeleech".to_string(), + Some(TorrentCost::PersonalFreeleech) => " as Personal Freeleech".to_string(), + Some(TorrentCost::Ratio) => " using ratio".to_string(), + _ => "".to_string(), + } + }; + + rsx! { + "Grabbed {media_type} Torrent " + {render_torrent_link(torrent_id, title)} + "{cost_text}" + if let Some(g) = grabber { + " with grabber {g}" + } + if let Some(c) = category { + " (category: {c})" + } + br {} + } + } + EventType::Linked { + linker, + library_path, + } => { + let files = torrent + .as_ref() + .map(|t| t.library_files.clone()) + .unwrap_or_default(); + + rsx! { + "Linked {media_type} Torrent " + {render_torrent_link(torrent_id, title)} + if let Some(l) = linker { + " with linker {l}" + } + if let Some(c) = category { + " (category: {c})" + } + br {} + "to: {library_path.to_string_lossy()}" + br {} + if !files.is_empty() { + details { + summary { "Files" } + ul { + for f in files { + li { "{f.to_string_lossy()}" } + } + } + } + } + } + } + EventType::Cleaned { + library_path, + files, + } => { + let size = torrent + .as_ref() + .map(|t| format_size(t.meta.size)) + .unwrap_or_default(); + let formats = torrent + .as_ref() + .map(|t| t.meta.filetypes.join(", ")) + .unwrap_or_default(); + + let r_id = replacement.as_ref().map(|t| t.id.clone()); + let r_title = replacement.as_ref().map(|t| t.meta.title.clone()); + let r_size = replacement + .as_ref() + .map(|t| format_size(t.meta.size)) + .unwrap_or_default(); + let r_formats = replacement + .as_ref() + .map(|t| t.meta.filetypes.join(", ")) + .unwrap_or_default(); + let r_path = replacement + .as_ref() + .and_then(|t| t.library_path.as_ref()) + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + rsx! { + "Cleaned {media_type} Torrent " + {render_torrent_link(torrent_id, title)} + if let Some(c) = category { + " (category: {c})" + } + br {} + if torrent.is_some() { + "size: {size}" br {} + "formats: {formats}" br {} + } + "from: {library_path.to_string_lossy()}" + br {} + if replacement.is_some() { + br {} "replaced with: " {render_torrent_link(r_id, r_title)} br {} + "size: {r_size}" br {} + "formats: {r_formats}" br {} + if !r_path.is_empty() { + "in: {r_path}" br {} + } + } + br {} + details { + summary { "Removed files" } + ul { + for f in files { + li { "{f.to_string_lossy()}" } + } + } + } + } + } + EventType::Updated { fields, source } => { + let source_text = format!("{} {}", source.0, source.1); + rsx! { + "Updated {media_type} Torrent " + {render_torrent_link(torrent_id, title)} + " from {source_text}" + if let Some(c) = category { + " (category: {c})" + } + br {} + ul { + for f in fields { + li { "{f.field}: {f.from} → {f.to}" } + } + } + } + } + EventType::RemovedFromTracker => rsx! { + "{media_type} Torrent " + {render_torrent_link(torrent_id, title)} + " was removed from Tracker" + if let Some(c) = category { + " (category: {c})" + } + br {} + }, + } +} diff --git a/mlm_web_dioxus/src/events/mod.rs b/mlm_web_dioxus/src/events/mod.rs new file mode 100644 index 00000000..55ff4472 --- /dev/null +++ b/mlm_web_dioxus/src/events/mod.rs @@ -0,0 +1,11 @@ +mod components; +mod server_fns; +mod types; + +pub use components::{EventContent, EventListItem, EventsPage}; +pub use server_fns::get_events_data; +pub use types::{EventData, EventWithTorrentData, EventsFilter}; + +// Re-export the SSE trigger for backward compatibility +pub use crate::sse::EVENTS_UPDATE_TRIGGER; +pub use crate::sse::trigger_events_update; diff --git a/mlm_web_dioxus/src/events/server_fns.rs b/mlm_web_dioxus/src/events/server_fns.rs new file mode 100644 index 00000000..5de50786 --- /dev/null +++ b/mlm_web_dioxus/src/events/server_fns.rs @@ -0,0 +1,263 @@ +use super::types::{EventData, EventsFilter}; +#[cfg(feature = "server")] +use crate::dto::{Event, convert_event_type, convert_torrent}; +#[cfg(feature = "server")] +use crate::error::IntoServerFnError; +#[cfg(feature = "server")] +use crate::utils::format_timestamp; +use dioxus::prelude::*; + +#[cfg(feature = "server")] +use mlm_core::ContextExt; +#[cfg(feature = "server")] +use mlm_core::{Event as DbEvent, EventKey, EventType as DbEventType, TorrentKey}; + +#[cfg(feature = "server")] +use super::types::EventWithTorrentData; + +#[server] +pub async fn get_events_data( + filter: EventsFilter, + from: Option, + page_size: Option, +) -> Result { + let context = crate::error::get_context()?; + let db = context.db(); + + const MAX_PAGE_SIZE: usize = 500; + + let from_val = from.unwrap_or(0); + let page_size_val = page_size.unwrap_or(MAX_PAGE_SIZE).clamp(1, MAX_PAGE_SIZE); + + let r = db.r_transaction().server_err_ctx("r_transaction")?; + + let convert_event = |db_event: &DbEvent| -> Event { + Event { + id: db_event.id.0.to_string(), + created_at: format_timestamp(&db_event.created_at), + event: convert_event_type(&db_event.event), + } + }; + + let no_filters = filter.show.is_none() + && filter.grabber.is_none() + && filter.linker.is_none() + && filter.category.is_none() + && filter.has_updates.is_none() + && filter.field.is_none(); + + if no_filters { + let total = r + .len() + .secondary::(EventKey::created_at) + .server_err()?; + let events_iter = r + .scan() + .secondary::(EventKey::created_at) + .server_err()?; + let events = events_iter + .all() + .server_err()? + .rev() + .skip(from_val) + .take(page_size_val); + + let mut result_events = Vec::new(); + for event_res in events { + let db_event = event_res.server_err()?; + let db_torrent: Option = if let Some(id) = &db_event.torrent_id { + r.get().primary(id.clone()).server_err()? + } else if let Some(mam_id) = &db_event.mam_id { + r.get() + .secondary(TorrentKey::mam_id, *mam_id) + .server_err()? + } else { + None + }; + + let mut db_replacement = None; + if let Some(ref t) = db_torrent { + db_replacement = t + .replaced_with + .clone() + .map(|(id, _)| r.get().primary(id).server_err()) + .transpose()? + .flatten(); + } + + result_events.push(EventWithTorrentData { + event: convert_event(&db_event), + torrent: db_torrent.as_ref().map(convert_torrent), + replacement: db_replacement.as_ref().map(convert_torrent), + }); + } + + return Ok(EventData { + events: result_events, + total: total as usize, + from: from_val, + page_size: page_size_val, + }); + } + + let events_iter = r + .scan() + .secondary::(EventKey::created_at) + .server_err_ctx("scan")?; + + let events = events_iter.all().server_err_ctx("all")?.rev(); + + let mut result_events = Vec::new(); + let mut total_matching = 0; + + let needs_torrent_for_filter = filter.linker.is_some() || filter.category.is_some(); + + for event_res in events { + let db_event = event_res.server_err()?; + + let mut event_matches = true; + + if let Some(ref val) = filter.show { + match &db_event.event { + DbEventType::Grabbed { .. } => { + if val != "grabber" { + event_matches = false; + } + } + DbEventType::Linked { .. } => { + if val != "linker" { + event_matches = false; + } + } + DbEventType::Cleaned { .. } => { + if val != "cleaner" { + event_matches = false; + } + } + DbEventType::Updated { .. } => { + if val != "updated" { + event_matches = false; + } + } + DbEventType::RemovedFromTracker => { + if val != "removed" { + event_matches = false; + } + } + } + } + + if event_matches && let Some(ref val) = filter.grabber { + match &db_event.event { + DbEventType::Grabbed { grabber, .. } => { + if val.is_empty() { + if grabber.is_some() { + event_matches = false; + } + } else if grabber.as_ref() != Some(val) { + event_matches = false; + } + } + _ => { + event_matches = false; + } + } + } + + if event_matches && filter.has_updates.is_some() { + match &db_event.event { + DbEventType::Updated { fields, .. } => { + if !fields.iter().any(|f| !f.from.is_empty()) { + event_matches = false; + } + } + _ => { + event_matches = false; + } + } + } + + if event_matches && let Some(ref val) = filter.field { + match &db_event.event { + DbEventType::Updated { fields, .. } => { + if !fields.iter().any(|f| &f.field.to_string() == val) { + event_matches = false; + } + } + _ => { + event_matches = false; + } + } + } + + if !event_matches { + continue; + } + + let mut torrent_matches = true; + let mut db_torrent: Option = None; + let mut db_replacement = None; + + let page_end = from_val.saturating_add(page_size_val); + let in_page = total_matching >= from_val && total_matching < page_end; + + if needs_torrent_for_filter || in_page { + db_torrent = if let Some(id) = &db_event.torrent_id { + r.get().primary(id.clone()).server_err()? + } else if let Some(mam_id) = &db_event.mam_id { + r.get() + .secondary(TorrentKey::mam_id, *mam_id) + .server_err()? + } else { + None + }; + + if let Some(ref t) = db_torrent { + if let Some(ref val) = filter.linker + && t.linker.as_ref() != Some(val) + { + torrent_matches = false; + } + if let Some(ref val) = filter.category { + let cat_matches = if val.is_empty() { + t.category.is_none() + } else { + t.category.as_ref() == Some(val) + }; + if !cat_matches { + torrent_matches = false; + } + } + + if torrent_matches && in_page { + db_replacement = t + .replaced_with + .clone() + .map(|(id, _)| r.get().primary(id).server_err()) + .transpose()? + .flatten(); + } + } else if needs_torrent_for_filter { + torrent_matches = false; + } + } + + if torrent_matches { + if in_page { + result_events.push(EventWithTorrentData { + event: convert_event(&db_event), + torrent: db_torrent.as_ref().map(convert_torrent), + replacement: db_replacement.as_ref().map(convert_torrent), + }); + } + total_matching += 1; + } + } + + Ok(EventData { + events: result_events, + total: total_matching, + from: from_val, + page_size: page_size_val, + }) +} diff --git a/mlm_web_dioxus/src/events/types.rs b/mlm_web_dioxus/src/events/types.rs new file mode 100644 index 00000000..a1beff23 --- /dev/null +++ b/mlm_web_dioxus/src/events/types.rs @@ -0,0 +1,27 @@ +use crate::dto::{Event, Torrent}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] +pub struct EventsFilter { + pub show: Option, + pub grabber: Option, + pub linker: Option, + pub category: Option, + pub has_updates: Option, + pub field: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct EventWithTorrentData { + pub event: Event, + pub torrent: Option, + pub replacement: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)] +pub struct EventData { + pub events: Vec, + pub total: usize, + pub from: usize, + pub page_size: usize, +} diff --git a/mlm_web_dioxus/src/home.rs b/mlm_web_dioxus/src/home.rs new file mode 100644 index 00000000..02c3b90a --- /dev/null +++ b/mlm_web_dioxus/src/home.rs @@ -0,0 +1,395 @@ +use crate::components::TaskBox; +#[cfg(feature = "server")] +use crate::error::IntoServerFnError; +use crate::sse::STATS_UPDATE_TRIGGER; +#[cfg(feature = "server")] +use crate::utils::format_datetime; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct HomeData { + pub username: Option, + pub mam_error: Option, + pub has_no_qbits: bool, + pub autograbbers: Vec, + pub snatchlist_grabbers: Vec, + pub lists: Vec, + pub torrent_linker: Option, + pub folder_linker: Option, + pub cleaner: Option, + pub downloader: Option, + pub audiobookshelf: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct AutograbberInfo { + pub index: usize, + pub display_name: String, + pub last_run: Option, + pub result: Option>, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ListInfo { + pub index: usize, + pub list_type: String, + pub display_name: String, + pub last_run: Option, + pub result: Option>, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TaskInfo { + pub last_run: Option, + pub result: Option>, +} + +#[server] +pub async fn get_home_data() -> Result { + use mlm_core::ContextExt; + + let context = crate::error::get_context()?; + let stats = context.stats.values.lock().await; + + let username = match context.mam() { + Ok(mam) => mam.cached_user_info().await.map(|u| u.username), + Err(_) => None, + }; + + let config = context.config().await; + + let mut autograbbers = Vec::new(); + for (i, grab) in config.autograbs.iter().enumerate() { + autograbbers.push(AutograbberInfo { + index: i, + display_name: grab.filter.display_name(i), + last_run: stats.autograbber_run_at.get(&i).map(format_datetime), + result: stats + .autograbber_result + .get(&i) + .map(|r| r.as_ref().map(|_| ()).map_err(|e| format!("{e:?}"))), + }); + } + + let mut snatchlist_grabbers = Vec::new(); + for (i, grab) in config.snatchlist.iter().enumerate() { + let idx = i + config.autograbs.len(); + snatchlist_grabbers.push(AutograbberInfo { + index: idx, + display_name: grab.filter.display_name(idx), + last_run: stats.autograbber_run_at.get(&idx).map(format_datetime), + result: stats + .autograbber_result + .get(&idx) + .map(|r| r.as_ref().map(|_| ()).map_err(|e| format!("{e:?}"))), + }); + } + + let config_lists = mlm_core::lists::get_lists(&config); + let mut lists = Vec::new(); + for (i, list) in config_lists.iter().enumerate() { + lists.push(ListInfo { + index: i, + list_type: list.list_type().to_string(), + display_name: list.display_name(i), + last_run: stats.import_run_at.get(&i).map(format_datetime), + result: stats + .import_result + .get(&i) + .map(|r| r.as_ref().map(|_| ()).map_err(|e| format!("{e:?}"))), + }); + } + + let task_info = |run_at: Option<&time::OffsetDateTime>, + result: Option<&Result<(), anyhow::Error>>| + -> Option { + run_at.map(|t| TaskInfo { + last_run: Some(format_datetime(t)), + result: result.map(|r| r.as_ref().map(|_| ()).map_err(|e| format!("{e:?}"))), + }) + }; + + Ok(HomeData { + username, + mam_error: context.mam().err().map(|e| format!("{e}")), + has_no_qbits: config.qbittorrent.is_empty(), + autograbbers, + snatchlist_grabbers, + lists, + torrent_linker: task_info( + stats.torrent_linker_run_at.as_ref(), + stats.torrent_linker_result.as_ref(), + ), + folder_linker: task_info( + stats.folder_linker_run_at.as_ref(), + stats.folder_linker_result.as_ref(), + ), + cleaner: task_info(stats.cleaner_run_at.as_ref(), stats.cleaner_result.as_ref()), + downloader: task_info( + stats.downloader_run_at.as_ref(), + stats.downloader_result.as_ref(), + ), + audiobookshelf: task_info( + stats.audiobookshelf_run_at.as_ref(), + stats.audiobookshelf_result.as_ref(), + ), + }) +} + +#[server] +pub async fn run_torrent_linker() -> Result<(), ServerFnError> { + let context = crate::error::get_context()?; + let tx = context + .triggers + .torrent_linker_tx + .as_ref() + .ok_or_else(|| ServerFnError::new("Torrent linker trigger is not configured"))?; + tx.send(()).server_err()?; + Ok(()) +} + +#[server] +pub async fn run_folder_linker() -> Result<(), ServerFnError> { + let context = crate::error::get_context()?; + let tx = context + .triggers + .folder_linker_tx + .as_ref() + .ok_or_else(|| ServerFnError::new("Folder linker trigger is not configured"))?; + tx.send(()).server_err()?; + Ok(()) +} + +#[server] +pub async fn run_search(index: usize) -> Result<(), ServerFnError> { + let context = crate::error::get_context()?; + if let Some(tx) = context.triggers.search_tx.get(&index) { + tx.send(()).server_err()?; + } else { + return Err(ServerFnError::new("Invalid index")); + } + Ok(()) +} + +#[server] +pub async fn run_import(index: usize) -> Result<(), ServerFnError> { + let context = crate::error::get_context()?; + if let Some(tx) = context.triggers.import_tx.get(&index) { + tx.send(()).server_err()?; + } else { + return Err(ServerFnError::new("Invalid index")); + } + Ok(()) +} + +#[server] +pub async fn run_downloader() -> Result<(), ServerFnError> { + let context = crate::error::get_context()?; + let tx = context + .triggers + .downloader_tx + .as_ref() + .ok_or_else(|| ServerFnError::new("Downloader trigger is not configured"))?; + tx.send(()).server_err()?; + Ok(()) +} + +#[server] +pub async fn run_abs_matcher() -> Result<(), ServerFnError> { + let context = crate::error::get_context()?; + let tx = context + .triggers + .audiobookshelf_tx + .as_ref() + .ok_or_else(|| ServerFnError::new("Audiobookshelf trigger is not configured"))?; + tx.send(()).server_err()?; + Ok(()) +} + +#[component] +pub fn HomePage() -> Element { + let mut home_data = use_server_future(move || async move { get_home_data().await })?; + + use_effect(move || { + let _ = *STATS_UPDATE_TRIGGER.read(); + home_data.restart(); + }); + + let data = home_data.suspend()?; + let data = data.read(); + + rsx! { + match &*data { + Ok(data) => rsx! { HomePageContent { data: data.clone() } }, + Err(e) => rsx! { p { class: "error", "Error loading home page: {e}" } }, + } + } +} + +#[component] +fn HomePageContent(data: HomeData) -> Element { + let greeting = match &data.username { + Some(u) => format!("Hi {}! Welcome to MLM, select a page above", u), + None => "Welcome to MLM, select a page above".to_string(), + }; + + let mam_warning = data + .mam_error + .as_ref() + .map(|err| format!("mam_id is invalid, all features are disabled: {}", err)) + .unwrap_or_default(); + + let qbit_warning = if data.has_no_qbits { + "no qbittorrent instances configured, all features are disabled" + } else { + "" + }; + + rsx! { + div { class: "home-page", + p { "{greeting}" } + + if !mam_warning.is_empty() { + p { class: "missing", "{mam_warning}" } + } + + if !qbit_warning.is_empty() { + p { class: "missing", "{qbit_warning}" } + } + + div { class: "infoboxes", + {data.autograbbers.iter().map(|grab| { + let title = format!("Autograbber: {}", grab.display_name); + let last_run = grab.last_run.clone(); + let result = grab.result.clone(); + let index = grab.index; + rsx! { + InfoTaskBox { + title, + last_run, + result, + on_run: Some(EventHandler::new(move |_| { + spawn(async move { let _ = run_search(index).await; }); + })), + } + } + })} + {data.snatchlist_grabbers.iter().map(|grab| { + let title = format!("Snatchlist: {}", grab.display_name); + let last_run = grab.last_run.clone(); + let result = grab.result.clone(); + let index = grab.index; + rsx! { + InfoTaskBox { + title, + last_run, + result, + on_run: Some(EventHandler::new(move |_| { + spawn(async move { let _ = run_search(index).await; }); + })), + } + } + })} + } + + if !data.lists.is_empty() { + div { class: "infoboxes", + {data.lists.iter().map(|list| { + let title = format!("{} Import: {}", list.list_type, list.display_name); + let last_run = list.last_run.clone(); + let result = list.result.clone(); + let index = list.index; + rsx! { + InfoTaskBox { + title, + last_run, + result, + on_run: Some(EventHandler::new(move |_| { + spawn(async move { let _ = run_import(index).await; }); + })), + } + } + })} + } + } + + div { class: "infoboxes", + if let Some(info) = &data.torrent_linker { + InfoTaskBox { + title: "Torrent Linker".to_string(), + last_run: info.last_run.clone(), + result: info.result.clone(), + on_run: Some(EventHandler::new(move |_| { + spawn(async move { let _ = run_torrent_linker().await; }); + })), + } + } + if let Some(info) = &data.folder_linker { + InfoTaskBox { + title: "Folder Linker".to_string(), + last_run: info.last_run.clone(), + result: info.result.clone(), + on_run: Some(EventHandler::new(move |_| { + spawn(async move { let _ = run_folder_linker().await; }); + })), + } + } + if let Some(info) = &data.cleaner { + InfoTaskBox { + title: "Cleaner".to_string(), + last_run: info.last_run.clone(), + result: info.result.clone(), + } + } + if let Some(info) = &data.downloader { + InfoTaskBox { + title: "Torrent downloader".to_string(), + last_run: info.last_run.clone(), + result: info.result.clone(), + on_run: Some(EventHandler::new(move |_| { + spawn(async move { let _ = run_downloader().await; }); + })), + } + } + if let Some(info) = &data.audiobookshelf { + InfoTaskBox { + title: "Audiobookshelf Matcher".to_string(), + last_run: info.last_run.clone(), + result: info.result.clone(), + on_run: Some(EventHandler::new(move |_| { + spawn(async move { let _ = run_abs_matcher().await; }); + })), + } + } + } + + hr {} + p { style: "display:flex;align-items:center;gap:0.8ex", + span { style: "font-size:2em", "🏳️‍⚧️" } + " Trans Rights are Human Rights" + } + } + } +} + +#[component] +fn InfoTaskBox( + title: String, + last_run: Option, + result: Option>, + #[props(default = None)] on_run: Option>, +) -> Element { + let has_run = last_run.is_some(); + rsx! { + div { class: "infobox", + h2 { "{title}" } + TaskBox { + last_run, + result, + show_result: has_run, + on_run, + } + } + } +} diff --git a/mlm_web_dioxus/src/lib.rs b/mlm_web_dioxus/src/lib.rs new file mode 100644 index 00000000..daf1b924 --- /dev/null +++ b/mlm_web_dioxus/src/lib.rs @@ -0,0 +1,31 @@ +pub mod app; +pub mod components; +pub mod config; +pub mod dto; +pub mod duplicate; +pub mod error; +pub mod errors; +pub mod events; +pub mod home; +pub mod list; +pub mod lists; +pub mod replaced; +pub mod search; +pub mod selected; +pub mod sse; +pub mod torrent_detail; +pub mod torrent_edit; +pub mod torrents; +pub mod utils; + +#[cfg(feature = "server")] +pub mod ssr; + +#[cfg(feature = "web")] +pub mod web { + use crate::app::root; + + pub fn launch() { + dioxus::launch(root); + } +} diff --git a/mlm_web_dioxus/src/list.rs b/mlm_web_dioxus/src/list.rs new file mode 100644 index 00000000..60c3d2c9 --- /dev/null +++ b/mlm_web_dioxus/src/list.rs @@ -0,0 +1,444 @@ +use crate::sse::STATS_UPDATE_TRIGGER; +#[cfg(feature = "server")] +use crate::utils::format_timestamp_db; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ListItemTorrentDto { + pub id: Option, + pub mam_id: Option, + pub status: String, + pub at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ListItemDto { + pub guid: (String, String), + pub title: String, + pub authors: Vec, + pub series: Vec<(String, f64)>, + pub cover_url: String, + pub book_url: Option, + pub want_audio: bool, + pub want_ebook: bool, + pub audio_torrent: Option, + pub ebook_torrent: Option, + pub marked_done_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ListDto { + pub id: String, + pub title: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ListPageData { + pub list: ListDto, + pub items: Vec, +} + +#[cfg(feature = "server")] +fn torrent_status_to_string(status: mlm_db::TorrentStatus) -> String { + use mlm_db::TorrentStatus; + match status { + TorrentStatus::Selected => "Selected".to_string(), + TorrentStatus::Wanted => "Wanted".to_string(), + TorrentStatus::NotWanted => "NotWanted".to_string(), + TorrentStatus::Existing => "Existing".to_string(), + } +} + +#[cfg(feature = "server")] +fn item_wants_audio(item: &mlm_db::ListItem) -> bool { + item.want_audio() +} + +#[cfg(feature = "server")] +fn item_wants_ebook(item: &mlm_db::ListItem) -> bool { + item.want_ebook() +} + +fn matches_show_filter(item: &ListItemDto, show: Option<&str>) -> bool { + match show { + Some("any") => item.want_audio || item.want_ebook, + Some("audio") => item.want_audio, + Some("ebook") => item.want_ebook, + _ => true, + } +} + +fn render_list_torrent_link(torrent: &ListItemTorrentDto) -> Element { + if let Some(id) = &torrent.id { + rsx! { a { href: "/dioxus/torrents/{id}", target: "_blank", rel: "noopener noreferrer", "torrent" } } + } else { + rsx! { "torrent" } + } +} + +fn render_list_torrent_status( + torrent: &ListItemTorrentDto, + format_name: &str, + skipped_reason: &'static str, +) -> Element { + match torrent.status.as_str() { + "Selected" => rsx! { + span { "Downloaded {format_name} " } + {render_list_torrent_link(torrent)} + span { " at {torrent.at}" } + br {} + }, + "Wanted" => rsx! { + span { "Suggest wedge {format_name} " } + {render_list_torrent_link(torrent)} + span { " at {torrent.at}" } + br {} + }, + "NotWanted" => rsx! { + span { "Skipped {format_name} " } + {render_list_torrent_link(torrent)} + span { " {skipped_reason} at {torrent.at}" } + br {} + }, + "Existing" => rsx! { + span { "Found matching {format_name} " } + {render_list_torrent_link(torrent)} + span { " in library at {torrent.at}" } + br {} + }, + _ => rsx! {}, + } +} + +#[server] +pub async fn get_list_data(list_id: String) -> Result { + use mlm_core::ContextExt; + use mlm_db::{List, ListItem, ListItemKey}; + + let context = crate::error::get_context()?; + let r = context + .db() + .r_transaction() + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let list = r + .get() + .primary::(list_id.as_str()) + .map_err(|e| ServerFnError::new(e.to_string()))? + .ok_or_else(|| ServerFnError::new("List not found"))?; + + let mut items = r + .scan() + .secondary::(ListItemKey::list_id) + .map_err(|e| ServerFnError::new(e.to_string()))? + .range(Some(list.id.clone())..=Some(list.id.clone())) + .map_err(|e| ServerFnError::new(e.to_string()))? + .collect::, _>>() + .map_err(|e| ServerFnError::new(e.to_string()))?; + items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + let items_dto = items + .into_iter() + .map(|item| { + let want_audio = item_wants_audio(&item); + let want_ebook = item_wants_ebook(&item); + ListItemDto { + guid: item.guid, + title: item.title, + authors: item.authors, + series: item.series, + cover_url: item.cover_url, + book_url: item.book_url, + want_audio, + want_ebook, + audio_torrent: item.audio_torrent.map(|t| ListItemTorrentDto { + id: t.torrent_id, + mam_id: t.mam_id, + status: torrent_status_to_string(t.status), + at: format_timestamp_db(&t.at), + }), + ebook_torrent: item.ebook_torrent.map(|t| ListItemTorrentDto { + id: t.torrent_id, + mam_id: t.mam_id, + status: torrent_status_to_string(t.status), + at: format_timestamp_db(&t.at), + }), + marked_done_at: item.marked_done_at.map(|ts| format_timestamp_db(&ts)), + } + }) + .collect(); + + Ok(ListPageData { + list: ListDto { + id: list.id, + title: list.title, + }, + items: items_dto, + }) +} + +#[server] +pub async fn mark_list_item_done(list_id: String, item_id: String) -> Result<(), ServerFnError> { + use mlm_core::ContextExt; + use mlm_db::{DatabaseExt as _, ListItem, Timestamp}; + + let context = crate::error::get_context()?; + let db = context.db(); + + let (_guard, rw) = db + .rw_async() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + let mut item = rw + .get() + .primary::((list_id.as_str(), item_id.as_str())) + .map_err(|e| ServerFnError::new(e.to_string()))? + .ok_or_else(|| ServerFnError::new("Could not find item"))?; + item.marked_done_at = Some(Timestamp::now()); + rw.upsert(item) + .map_err(|e| ServerFnError::new(e.to_string()))?; + rw.commit().map_err(|e| ServerFnError::new(e.to_string()))?; + + Ok(()) +} + +#[component] +pub fn ListPage(id: String) -> Element { + let list_id = id.clone(); + let mut cached_data = use_signal(|| None::); + + let mut list_data = match use_server_future(move || { + let list_id = list_id.clone(); + async move { get_list_data(list_id).await } + }) { + Ok(resource) => resource, + Err(_) => { + return rsx! { + p { "Loading..." } + }; + } + }; + + use_effect(move || { + let _ = *STATS_UPDATE_TRIGGER.read(); + list_data.restart(); + }); + + let current_value = list_data.value(); + + use_effect(move || { + let val = current_value.read(); + if let Some(Ok(data)) = &*val { + cached_data.set(Some(data.clone())); + } + }); + + let data_to_show = { + let val = current_value.read(); + match &*val { + Some(Ok(data)) => Some(data.clone()), + Some(Err(_)) | None => cached_data.read().clone(), + } + }; + + rsx! { + if let Some(data) = data_to_show { + ListPageContent { + list_id: id, + data, + on_refresh: move |_| list_data.restart(), + } + } else { + p { "Loading..." } + } + } +} + +#[derive(Props, Clone, PartialEq)] +struct ListPageContentProps { + list_id: String, + data: ListPageData, + on_refresh: EventHandler<()>, +} + +#[component] +fn ListPageContent(props: ListPageContentProps) -> Element { + let list_id = props.list_id.clone(); + let mut show = use_signal(|| None::); + + let items: Vec = props + .data + .items + .iter() + .filter(|item| matches_show_filter(item, show.read().as_deref())) + .cloned() + .collect(); + + rsx! { + div { class: "list-page", + div { class: "row", + h1 { "{props.data.list.title}" } + div { class: "option_group query", + "Show: " + label { + "All" + input { + r#type: "radio", + name: "show", + checked: show.read().is_none(), + onclick: move |_| { + show.set(None); + }, + } + } + label { + "Any Missing" + input { + r#type: "radio", + name: "show", + checked: show.read().as_deref() == Some("any"), + value: "any", + onclick: move |_| { + show.set(Some("any".to_string())); + }, + } + } + label { + "Audio Missing" + input { + r#type: "radio", + name: "show", + checked: show.read().as_deref() == Some("audio"), + value: "audio", + onclick: move |_| { + show.set(Some("audio".to_string())); + }, + } + } + label { + "Ebook Missing" + input { + r#type: "radio", + name: "show", + checked: show.read().as_deref() == Some("ebook"), + value: "ebook", + onclick: move |_| { + show.set(Some("ebook".to_string())); + }, + } + } + } + } + + for item in &items { + ListItemComponent { + list_id: list_id.clone(), + item: item.clone(), + on_refresh: props.on_refresh, + } + } + + if items.is_empty() { + p { + i { "The list is empty" } + } + } + } + } +} + +#[derive(Props, Clone, PartialEq)] +struct ListItemComponentProps { + list_id: String, + item: ListItemDto, + on_refresh: EventHandler<()>, +} + +#[component] +fn ListItemComponent(props: ListItemComponentProps) -> Element { + let list_id = props.list_id.clone(); + let item = props.item.clone(); + let guid = props.item.guid.clone(); + let item_id = guid.1.clone(); + let can_mark_done = props.item.want_audio || props.item.want_ebook; + let on_refresh = props.on_refresh; + + let mut marking_done = use_signal(|| false); + + let authors_str = props.item.authors.join(", "); + + rsx! { + div { class: "list_item", + img { src: "{item.cover_url}" } + div { + div { class: "row", + h3 { "{item.title}" } + div { + a { href: "{mam_search_url(&item)}", target: "_blank", rel: "noopener noreferrer", "search on MaM" } + if let Some(url) = &item.book_url { + a { href: "{url}", target: "_blank", rel: "noopener noreferrer", "goodreads" } + } + } + } + p { class: "author", "by {authors_str}" } + + if !item.series.is_empty() { + p { + for (i , (name , num)) in item.series.iter().enumerate() { + "{name} #{num}" + if i < item.series.len() - 1 { + ", " + } + } + } + } + + if let Some(torrent) = &item.audio_torrent { + {render_list_torrent_status(torrent, "audiobook", "as an ebook was found")} + } else if item.want_audio { + span { class: "missing", "Audiobook missing" } + br {} + } + + if let Some(torrent) = &item.ebook_torrent { + {render_list_torrent_status(torrent, "ebook", "as an ebook was found")} + } else if item.want_ebook { + span { class: "missing", "Ebook missing" } + br {} + } + + if can_mark_done { + button { + disabled: *marking_done.read(), + onclick: move |_| { + let list_id = list_id.clone(); + let item_id = item_id.clone(); + marking_done.set(true); + spawn(async move { + match mark_list_item_done(list_id, item_id).await { + Ok(_) => on_refresh.call(()), + Err(e) => { + tracing::error!("Failed to mark done: {}", e); + } + } + marking_done.set(false); + }); + }, + "mark done" + } + } + } + } + } +} + +fn mam_search_url(item: &ListItemDto) -> String { + let base = "https://www.myanonamouse.net/tor/browse.php?thumbnail=true&tor[srchIn][title]=true&tor[srchIn][author]=true&tor[searchType]=all&tor[searchIn]=torrents"; + let search_text = format!("{} {}", item.title, item.authors.join(" ")); + let search_term = urlencoding::encode(&search_text); + let mut result = base.to_string(); + result.push_str("&tor[text]="); + result.push_str(&search_term); + result +} diff --git a/mlm_web_dioxus/src/lists.rs b/mlm_web_dioxus/src/lists.rs new file mode 100644 index 00000000..922b32d5 --- /dev/null +++ b/mlm_web_dioxus/src/lists.rs @@ -0,0 +1,192 @@ +use crate::app::Route; +#[cfg(feature = "server")] +use crate::utils::format_timestamp_db; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct GoodreadsListInfo { + pub list_id: String, + pub name: Option, + pub title: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ListInfo { + pub id: String, + pub title: String, + pub updated_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ListsData { + pub lists: Vec<(GoodreadsListInfo, ListInfo)>, + pub inactive_lists: Vec, +} + +#[server] +pub async fn get_lists() -> Result { + #[cfg(feature = "server")] + { + use itertools::Itertools as _; + use mlm_core::ContextExt; + use mlm_db::{List, ListKey}; + + let context = crate::error::get_context()?; + + let config = context.config().await; + let db = context.db(); + + let db_lists: Vec = db + .r_transaction() + .map_err(|e| ServerFnError::new(e.to_string()))? + .scan() + .secondary::(ListKey::title) + .map_err(|e| ServerFnError::new(e.to_string()))? + .all() + .map_err(|e| ServerFnError::new(e.to_string()))? + .filter_map(|result| match result { + Ok(list) => Some(list), + Err(err) => { + tracing::warn!("list scan error: {err}"); + None + } + }) + .collect(); + + let mut remaining_db_lists = db_lists; + let mut lists = Vec::new(); + + for list in config.goodreads_lists.iter() { + let id = match list.list_id() { + Ok(id) => id, + Err(_) => continue, + }; + if let Some((index, db_list)) = remaining_db_lists + .iter() + .find_position(|db_list| db_list.id == id) + .map(|(i, l)| (i, l.clone())) + { + remaining_db_lists.remove(index); + let info = GoodreadsListInfo { + list_id: id.clone(), + name: list.name.clone(), + title: id.clone(), + }; + let list_info = ListInfo { + id: db_list.id, + title: db_list.title, + updated_at: db_list.updated_at.map(|ts| format_timestamp_db(&ts)), + }; + lists.push((info, list_info)); + } else { + let info = GoodreadsListInfo { + list_id: id.clone(), + name: list.name.clone(), + title: id.clone(), + }; + let list_info = ListInfo { + id: id.clone(), + title: id, + updated_at: None, + }; + lists.push((info, list_info)); + } + } + + let inactive_lists: Vec = remaining_db_lists + .into_iter() + .map(|l| ListInfo { + id: l.id, + title: l.title, + updated_at: l.updated_at.map(|ts| format_timestamp_db(&ts)), + }) + .collect(); + + Ok(ListsData { + lists, + inactive_lists, + }) + } + #[cfg(not(feature = "server"))] + { + Err(ServerFnError::new("Server feature not enabled")) + } +} + +#[component] +pub fn ListsPage() -> Element { + let mut lists_data = use_server_future(move || async move { get_lists().await })?; + + use_effect(move || { + let _ = *crate::sse::STATS_UPDATE_TRIGGER.read(); + lists_data.restart(); + }); + + let data = lists_data.suspend()?; + let data = data.read(); + + rsx! { + match &*data { + Ok(data) => rsx! { ListsPageContent { data: data.clone() } }, + Err(e) => rsx! { p { class: "error", "Error loading lists: {e}" } }, + } + } +} + +#[component] +fn ListsPageContent(data: ListsData) -> Element { + rsx! { + div { class: "lists-page", + h1 { "Goodreads Lists" } + p { "Goodreads lists can be used to autograb want to read books" } + + for (config, list) in &data.lists { + div { + Link { to: Route::ListPage { id: list.id.clone() }, + h3 { + if let Some(name) = &config.name { + "{name}" + } else { + "{config.title}" + } + } + } + p { + "Last updated: " + if let Some(updated_at) = &list.updated_at { + "{updated_at}" + } else { + i { "never" } + } + } + } + } + + if !data.inactive_lists.is_empty() { + h2 { "Inactive Lists" } + p { "Lists that have been removed from the config but are still in the database. They won't be refreshed or have books searched for at MaM." } + + for list in &data.inactive_lists { + div { + Link { to: Route::ListPage { id: list.id.clone() }, + h3 { "{list.title}" } + } + p { + "Last updated: " + if let Some(updated_at) = &list.updated_at { + "{updated_at}" + } else { + i { "never" } + } + } + } + } + } + + if data.lists.is_empty() { + p { i { "You have no Goodreads lists" } } + } + } + } +} diff --git a/mlm_web_dioxus/src/main.rs b/mlm_web_dioxus/src/main.rs new file mode 100644 index 00000000..7a468759 --- /dev/null +++ b/mlm_web_dioxus/src/main.rs @@ -0,0 +1,133 @@ +#[cfg(feature = "web")] +use mlm_web_dioxus::app::root; + +fn main() { + #[cfg(feature = "web")] + dioxus::launch(root); + + #[cfg(feature = "server")] + server_main(); +} + +#[cfg(feature = "server")] +#[tokio::main] +async fn server_main() { + use anyhow::Result; + use axum::Router; + use mlm_core::Context; + use mlm_core::{SsrBackend, Stats, metadata::MetadataService}; + use mlm_mam::api::MaM; + use std::path::PathBuf; + use std::sync::Arc; + use std::time::Duration; + use tokio::sync::Mutex; + use tower_http::services::ServeDir; + + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .init(); + + let database_file = std::env::var("MLM_DX_DB_FILE") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("dev.db")); + if let Some(parent) = database_file.parent() { + std::fs::create_dir_all(parent).expect("Failed to create dev database directory"); + } + let db = native_db::Builder::new() + .create(&mlm_db::MODELS, database_file) + .expect("Failed to create database"); + mlm_db::migrate(&db).expect("Failed to migrate database"); + let db = Arc::new(db); + + let config_file = std::env::var_os("MLM_CONFIG_FILE") + .map(std::path::PathBuf::from) + .or_else(|| { + [ + std::path::PathBuf::from("config.toml"), + std::path::PathBuf::from("../config.toml"), + std::path::PathBuf::from("config/config.toml"), + std::path::PathBuf::from("../config/config.toml"), + ] + .into_iter() + .find(|path| path.exists()) + }); + let config: mlm_core::config::Config = if let Some(config_file) = config_file { + use figment::{ + Figment, + providers::{Format, Toml}, + }; + Figment::new() + .merge(Toml::file_exact(&config_file)) + .extract() + .expect("Failed to load config") + } else { + tracing::warn!( + "No config.toml found (checked MLM_CONFIG_FILE, config.toml, ../config.toml, config/config.toml, ../config/config.toml); using defaults" + ); + mlm_core::config::Config::default() + }; + let config = Arc::new(config); + + let mam: Arc>>> = if config.mam_id.is_empty() { + Arc::new(Err(anyhow::Error::msg("No mam_id set (dev mode)"))) + } else { + Arc::new(MaM::new(&config.mam_id, db.clone()).await.map(Arc::new)) + }; + + let default_timeout = Duration::from_secs(5); + let provider_settings: Vec = config + .metadata_providers + .iter() + .map(|p| match p { + mlm_core::config::ProviderConfig::Hardcover(c) => { + mlm_core::metadata::ProviderSetting::Hardcover { + enabled: c.enabled, + timeout_secs: c.timeout_secs, + api_key: c.api_key.clone(), + } + } + mlm_core::config::ProviderConfig::RomanceIo(c) => { + mlm_core::metadata::ProviderSetting::RomanceIo { + enabled: c.enabled, + timeout_secs: c.timeout_secs, + } + } + mlm_core::config::ProviderConfig::OpenLibrary(c) => { + mlm_core::metadata::ProviderSetting::OpenLibrary { + enabled: c.enabled, + timeout_secs: c.timeout_secs, + } + } + }) + .collect(); + let metadata = Arc::new(MetadataService::from_settings( + &provider_settings, + default_timeout, + )); + + let backend = Arc::new(SsrBackend { + db: db.clone(), + mam: mam.clone(), + metadata: metadata.clone(), + }); + + let ctx = Context { + config: Arc::new(Mutex::new(config)), + stats: Stats::new(), + events: mlm_core::Events::new(), + backend: Some(backend), + triggers: mlm_core::Triggers::default(), + }; + + let app = Router::new() + .nest_service("/assets", ServeDir::new("../server/assets")) + .merge(mlm_web_dioxus::ssr::router(ctx)); + + let addr: std::net::SocketAddr = "0.0.0.0:3002".parse().unwrap(); + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + tracing::info!("Dioxus dev server listening on http://{}", addr); + + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); +} diff --git a/mlm_web_dioxus/src/replaced/components.rs b/mlm_web_dioxus/src/replaced/components.rs new file mode 100644 index 00000000..b1252baf --- /dev/null +++ b/mlm_web_dioxus/src/replaced/components.rs @@ -0,0 +1,621 @@ +use crate::components::{ + ActiveFilterChip, ActiveFilters, ColumnSelector, ColumnToggleOption, FilterLink, PageColumns, + PageSizeSelector, Pagination, SortHeader, TorrentGridTable, TorrentTitleLink, + build_query_string, encode_query_enum, parse_location_query_pairs, parse_query_enum, + set_location_query_string, +}; +use dioxus::prelude::*; +use std::collections::BTreeSet; +use std::sync::Arc; + +use super::server_fns::*; +use super::types::*; + +fn filter_name(filter: ReplacedPageFilter) -> &'static str { + match filter { + ReplacedPageFilter::Kind => "Type", + ReplacedPageFilter::Title => "Title", + ReplacedPageFilter::Author => "Authors", + ReplacedPageFilter::Narrator => "Narrators", + ReplacedPageFilter::Series => "Series", + ReplacedPageFilter::Language => "Language", + ReplacedPageFilter::Filetype => "Filetypes", + ReplacedPageFilter::Linked => "Linked", + } +} + +#[derive(Clone)] +struct PageQueryState { + sort: Option, + asc: bool, + filters: Vec<(ReplacedPageFilter, String)>, + from: usize, + page_size: usize, + show: ReplacedPageColumns, +} + +impl Default for PageQueryState { + fn default() -> Self { + Self { + sort: None, + asc: false, + filters: Vec::new(), + from: 0, + page_size: 500, + show: ReplacedPageColumns::default(), + } + } +} + +fn parse_query_state() -> PageQueryState { + let mut state = PageQueryState::default(); + for (key, value) in parse_location_query_pairs() { + match key.as_str() { + "sort_by" => state.sort = parse_query_enum::(&value), + "asc" => state.asc = value == "true", + "from" => { + if let Ok(v) = value.parse::() { + state.from = v; + } + } + "page_size" => { + if let Ok(v) = value.parse::() { + state.page_size = v; + } + } + "show" => state.show = ReplacedPageColumns::from_query_value(&value), + _ => { + if let Some(field) = parse_query_enum::(&key) { + state.filters.push((field, value)); + } + } + } + } + state +} + +fn build_query_url( + sort: Option, + asc: bool, + filters: &[(ReplacedPageFilter, String)], + from: usize, + page_size: usize, + show: ReplacedPageColumns, +) -> String { + let mut params = Vec::new(); + if let Some(sort) = sort.and_then(encode_query_enum) { + params.push(("sort_by".to_string(), sort)); + } + if asc { + params.push(("asc".to_string(), "true".to_string())); + } + if from > 0 { + params.push(("from".to_string(), from.to_string())); + } + if page_size != 500 { + params.push(("page_size".to_string(), page_size.to_string())); + } + if show != ReplacedPageColumns::default() { + params.push(("show".to_string(), show.to_query_value())); + } + for (field, value) in filters { + if let Some(name) = encode_query_enum(*field) { + params.push((name, value.clone())); + } + } + build_query_string(¶ms) +} + +#[component] +pub fn ReplacedPage() -> Element { + let _route: crate::app::Route = use_route(); + let initial_state = parse_query_state(); + let initial_sort = initial_state.sort; + let initial_asc = initial_state.asc; + let initial_filters = initial_state.filters.clone(); + let initial_from = initial_state.from; + let initial_page_size = initial_state.page_size; + let initial_show = initial_state.show; + let initial_request_key = build_query_url( + initial_state.sort, + initial_state.asc, + &initial_state.filters, + initial_state.from, + initial_state.page_size, + initial_state.show, + ); + + let sort = use_signal(move || initial_sort); + let asc = use_signal(move || initial_asc); + let filters = use_signal(move || initial_filters.clone()); + let mut from = use_signal(move || initial_from); + let mut page_size = use_signal(move || initial_page_size); + let show = use_signal(move || initial_show); + let mut selected = use_signal(BTreeSet::::new); + let mut last_selected_idx = use_signal(|| None::); + let mut status_msg = use_signal(|| None::<(String, bool)>); + let mut cached = use_signal(|| None::); + let loading_action = use_signal(|| false); + let mut last_request_key = use_signal(move || initial_request_key.clone()); + + let mut replaced_data = use_server_future(move || async move { + get_replaced_data( + *sort.read(), + *asc.read(), + filters.read().clone(), + Some(*from.read()), + Some(*page_size.read()), + ) + .await + }) + .ok(); + + let pending = replaced_data + .as_ref() + .map(|resource| resource.pending()) + .unwrap_or(true); + let value = replaced_data.as_ref().map(|resource| resource.value()); + + { + let route_state = parse_query_state(); + let route_request_key = build_query_url( + route_state.sort, + route_state.asc, + &route_state.filters, + route_state.from, + route_state.page_size, + route_state.show, + ); + if *last_request_key.read() != route_request_key { + let mut sort = sort; + let mut asc = asc; + let mut filters_signal = filters; + let mut from = from; + let mut page_size = page_size; + let mut show = show; + let mut selected = selected; + let mut last_selected_idx = last_selected_idx; + sort.set(route_state.sort); + asc.set(route_state.asc); + filters_signal.set(route_state.filters); + from.set(route_state.from); + page_size.set(route_state.page_size); + show.set(route_state.show); + selected.set(BTreeSet::new()); + last_selected_idx.set(None); + last_request_key.set(route_request_key); + if let Some(resource) = replaced_data.as_mut() { + resource.restart(); + } + } + } + + let cache_value = value; + use_effect(move || { + if let Some(value) = &cache_value { + let value = value.read(); + if let Some(Ok(data)) = &*value { + cached.set(Some(data.clone())); + } + } + }); + + let data_to_show = { + if let Some(value) = &value { + let value = value.read(); + match &*value { + Some(Ok(data)) => Some(data.clone()), + _ => cached.read().clone(), + } + } else { + cached.read().clone() + } + }; + + use_effect(move || { + let query_string = build_query_url( + *sort.read(), + *asc.read(), + &filters.read().clone(), + *from.read(), + *page_size.read(), + *show.read(), + ); + let should_restart = *last_request_key.read() != query_string; + if should_restart { + last_request_key.set(query_string.clone()); + set_location_query_string(&query_string); + if let Some(resource) = replaced_data.as_mut() { + resource.restart(); + } + } + }); + + let column_options = COLUMN_OPTIONS + .iter() + .map(|(column, label)| { + let checked = show.read().get(*column); + let column = *column; + ColumnToggleOption { + label, + checked, + on_toggle: Callback::new({ + let mut show = show; + move |enabled| { + let mut next = *show.read(); + next.set(column, enabled); + show.set(next); + } + }), + } + }) + .collect::>(); + + let mut active_chips = Vec::new(); + for (field, value) in filters.read().clone() { + active_chips.push(ActiveFilterChip { + label: format!("{}: {}", filter_name(field), value), + on_remove: Callback::new({ + let value = value.clone(); + let mut filters = filters; + let mut from = from; + move |_| { + filters + .write() + .retain(|(f, v)| !(*f == field && *v == value)); + from.set(0); + } + }), + }); + } + + let clear_all: Option> = if active_chips.is_empty() { + None + } else { + Some(Callback::new({ + let mut filters = filters; + let mut from = from; + move |_| { + filters.set(Vec::new()); + from.set(0); + } + })) + }; + + let all_row_ids = Arc::new( + data_to_show + .as_ref() + .map(|data| { + data.torrents + .iter() + .map(|p| p.torrent.id.clone()) + .collect::>() + }) + .unwrap_or_default(), + ); + + rsx! { + div { class: "replaced-page", + div { class: "row", + h1 { "Replaced Torrents" } + div { class: "actions actions_torrent", + style: if selected.read().is_empty() { "" } else { "display: flex" }, + for action in [ReplacedBulkAction::Refresh, ReplacedBulkAction::RefreshRelink, ReplacedBulkAction::Remove] { + button { + r#type: "button", + disabled: *loading_action.read(), + onclick: { + let mut loading_action = loading_action; + let mut status_msg = status_msg; + let mut replaced_data = replaced_data; + let mut selected = selected; + move |_| { + let ids = selected.read().iter().cloned().collect::>(); + if ids.is_empty() { + status_msg.set(Some(("Select at least one torrent".to_string(), true))); + return; + } + loading_action.set(true); + status_msg.set(None); + spawn(async move { + match apply_replaced_action(action, ids).await { + Ok(_) => { + status_msg.set(Some((action.success_label().to_string(), false))); + selected.set(BTreeSet::new()); + if let Some(resource) = replaced_data.as_mut() { + resource.restart(); + } + } + Err(e) => { + status_msg.set(Some((format!("{} failed: {e}", action.label()), true))); + } + } + loading_action.set(false); + }); + } + }, + "{action.label()}" + } + } + } + div { class: "table_options", + ColumnSelector { options: column_options } + PageSizeSelector { + page_size: *page_size.read(), + options: vec![100, 500, 1000, 5000], + show_all_option: true, + on_change: move |next| { + page_size.set(next); + from.set(0); + }, + } + } + } + + p { "Torrents that were unlinked from the library and replaced with a preferred version" } + + if let Some((msg, is_error)) = status_msg.read().as_ref() { + p { class: if *is_error { "error" } else { "loading-indicator" }, + "{msg}" + button { + r#type: "button", + style: "margin-left: 10px; cursor: pointer;", + onclick: move |_| status_msg.set(None), + "⨯" + } + } + } + + ActiveFilters { + chips: active_chips, + on_clear_all: clear_all, + } + + if let Some(data) = data_to_show { + if data.torrents.is_empty() { + p { + i { "You have no replaced torrents" } + } + } else { + TorrentGridTable { + grid_template: show.read().table_grid_template(), + extra_class: None, + pending: pending && cached.read().is_some(), + { + let all_selected = data.torrents.iter().all(|p| selected.read().contains(&p.torrent.id)); + rsx! { + div { class: "torrents-grid-row", + div { class: "header", + input { + r#type: "checkbox", + checked: all_selected, + onchange: { + let row_ids = data.torrents.iter().map(|p| p.torrent.id.clone()).collect::>(); + move |ev| { + if ev.value() == "true" { + let mut next = selected.read().clone(); + for id in &row_ids { + next.insert(id.clone()); + } + selected.set(next); + } else { + let mut next = selected.read().clone(); + for id in &row_ids { + next.remove(id); + } + selected.set(next); + } + } + }, + } + } + SortHeader { label: "Type", sort_key: ReplacedPageSort::Kind, sort, asc, from } + SortHeader { label: "Title", sort_key: ReplacedPageSort::Title, sort, asc, from } + if show.read().authors { + SortHeader { label: "Authors", sort_key: ReplacedPageSort::Authors, sort, asc, from } + } + if show.read().narrators { + SortHeader { label: "Narrators", sort_key: ReplacedPageSort::Narrators, sort, asc, from } + } + if show.read().series { + SortHeader { label: "Series", sort_key: ReplacedPageSort::Series, sort, asc, from } + } + if show.read().language { + SortHeader { label: "Language", sort_key: ReplacedPageSort::Language, sort, asc, from } + } + if show.read().size { + SortHeader { label: "Size", sort_key: ReplacedPageSort::Size, sort, asc, from } + } + if show.read().filetypes { + div { class: "header", "Filetypes" } + } + SortHeader { label: "Replaced", sort_key: ReplacedPageSort::Replaced, sort, asc, from } + SortHeader { label: "Added At", sort_key: ReplacedPageSort::CreatedAt, sort, asc, from } + } + } + } + + for (i, pair) in data.torrents.iter().enumerate() { + { + let row_id = pair.torrent.id.clone(); + let row_selected = selected.read().contains(&row_id); + let all_row_ids = all_row_ids.clone(); + rsx! { + div { class: "torrents-grid-row", key: "{row_id}", + div { + input { + r#type: "checkbox", + checked: row_selected, + onclick: { + let row_id = row_id.clone(); + move |ev: MouseEvent| { + let will_select = !selected.read().contains(&row_id); + let mut next = selected.read().clone(); + if ev.modifiers().shift() { + if let Some(last_idx) = *last_selected_idx.read() { + let (start, end) = if last_idx <= i { (last_idx, i) } else { (i, last_idx) }; + for id in &all_row_ids[start..=end] { + if will_select { next.insert(id.clone()); } else { next.remove(id); } + } + } else if will_select { next.insert(row_id.clone()); } else { next.remove(&row_id); } + } else if will_select { next.insert(row_id.clone()); } else { next.remove(&row_id); } + selected.set(next); + last_selected_idx.set(Some(i)); + } + }, + } + } + div { + FilterLink { + field: ReplacedPageFilter::Kind, + value: pair.torrent.meta.media_type.clone(), + reset_from: true, + "{pair.torrent.meta.media_type}" + } + } + div { + TorrentTitleLink { + detail_id: pair.torrent.id.clone(), + field: ReplacedPageFilter::Title, + value: pair.torrent.meta.title.clone(), + reset_from: true, + "{pair.torrent.meta.title}" + } + } + if show.read().authors { + div { + for author in pair.torrent.meta.authors.clone() { + FilterLink { + field: ReplacedPageFilter::Author, + value: author.clone(), + reset_from: true, + "{author}" + } + } + } + } + if show.read().narrators { + div { + for narrator in pair.torrent.meta.narrators.clone() { + FilterLink { + field: ReplacedPageFilter::Narrator, + value: narrator.clone(), + reset_from: true, + "{narrator}" + } + } + } + } + if show.read().series { + div { + for series in pair.torrent.meta.series.clone() { + FilterLink { + field: ReplacedPageFilter::Series, + value: series.name.clone(), + reset_from: true, + if series.entries.is_empty() { + "{series.name}" + } else { + "{series.name} #{series.entries}" + } + } + } + } + } + if show.read().language { + div { + FilterLink { + field: ReplacedPageFilter::Language, + value: pair.torrent.meta.language.clone().unwrap_or_default(), + reset_from: true, + "{pair.torrent.meta.language.clone().unwrap_or_default()}" + } + } + } + if show.read().size { + div { "{pair.torrent.meta.size}" } + } + if show.read().filetypes { + div { + for filetype in pair.torrent.meta.filetypes.clone() { + FilterLink { + field: ReplacedPageFilter::Filetype, + value: filetype.clone(), + reset_from: true, + "{filetype}" + } + } + } + } + div { "{pair.torrent.replaced_at.clone().unwrap_or_default()}" } + div { "{pair.torrent.created_at}" } + + div {} + div { class: "faint", "replaced with:" } + div { "{pair.replacement.meta.title}" } + if show.read().authors { + div { + for author in pair.replacement.meta.authors.clone() { + span { "{author} " } + } + } + } + if show.read().narrators { + div { + for narrator in pair.replacement.meta.narrators.clone() { + span { "{narrator} " } + } + } + } + if show.read().series { + div { + for series in pair.replacement.meta.series.clone() { + span { + if series.entries.is_empty() { + "{series.name} " + } else { + "{series.name} #{series.entries} " + } + } + } + } + } + if show.read().language { + div { "{pair.replacement.meta.language.clone().unwrap_or_default()}" } + } + if show.read().size { + div { "{pair.replacement.meta.size}" } + } + if show.read().filetypes { + div { "{pair.replacement.meta.filetypes.join(\", \")}" } + } + div { "{pair.replacement.replaced_at.clone().unwrap_or_default()}" } + div { "{pair.replacement.created_at}" } + } + } + } + } + } + + p { class: "faint", + "Showing {data.from} to {data.from + data.torrents.len()} of {data.total}" + } + Pagination { + total: data.total, + from: data.from, + page_size: data.page_size, + on_change: move |new_from| from.set(new_from), + } + } + } else if let Some(value) = &value { + if let Some(Err(e)) = &*value.read() { + p { class: "error", "Error: {e}" } + } else { + p { "Loading replaced torrents..." } + } + } else { + p { "Loading replaced torrents..." } + } + } + } +} diff --git a/mlm_web_dioxus/src/replaced/mod.rs b/mlm_web_dioxus/src/replaced/mod.rs new file mode 100644 index 00000000..21f1c23f --- /dev/null +++ b/mlm_web_dioxus/src/replaced/mod.rs @@ -0,0 +1,10 @@ +mod components; +mod server_fns; +mod types; + +pub use components::ReplacedPage; +pub use server_fns::{apply_replaced_action, get_replaced_data}; +pub use types::{ + ReplacedBulkAction, ReplacedData, ReplacedMeta, ReplacedPageColumns, ReplacedPageFilter, + ReplacedPageSort, ReplacedPairRow, ReplacedRow, +}; diff --git a/mlm_web_dioxus/src/replaced/server_fns.rs b/mlm_web_dioxus/src/replaced/server_fns.rs new file mode 100644 index 00000000..fc7da3ac --- /dev/null +++ b/mlm_web_dioxus/src/replaced/server_fns.rs @@ -0,0 +1,212 @@ +use dioxus::prelude::*; +#[cfg(feature = "server")] +use std::str::FromStr; + +use super::types::*; + +#[cfg(feature = "server")] +use crate::error::IntoServerFnError; +#[cfg(feature = "server")] +use crate::utils::format_timestamp_db; +#[cfg(feature = "server")] +use mlm_core::{ + ContextExt, Torrent, + linker::{refresh_mam_metadata, refresh_metadata_relink}, +}; +#[cfg(feature = "server")] +use mlm_db::{DatabaseExt as _, Language, TorrentKey, ids}; + +#[cfg(feature = "server")] +fn matches_filter(t: &Torrent, field: ReplacedPageFilter, value: &str) -> bool { + match field { + ReplacedPageFilter::Kind => t.meta.media_type.as_str() == value, + ReplacedPageFilter::Title => t.meta.title == value, + ReplacedPageFilter::Author => t.meta.authors.contains(&value.to_string()), + ReplacedPageFilter::Narrator => t.meta.narrators.contains(&value.to_string()), + ReplacedPageFilter::Series => t.meta.series.iter().any(|s| s.name == value), + ReplacedPageFilter::Language => { + if value.is_empty() { + t.meta.language.is_none() + } else { + t.meta.language == Language::from_str(value).ok() + } + } + ReplacedPageFilter::Filetype => t.meta.filetypes.iter().any(|f| f == value), + ReplacedPageFilter::Linked => t.library_path.is_some() == (value == "true"), + } +} + +#[cfg(feature = "server")] +fn convert_row(t: &Torrent) -> ReplacedRow { + ReplacedRow { + id: t.id.clone(), + mam_id: t.mam_id, + meta: ReplacedMeta { + title: t.meta.title.clone(), + media_type: t.meta.media_type.as_str().to_string(), + authors: t.meta.authors.clone(), + narrators: t.meta.narrators.clone(), + series: t + .meta + .series + .iter() + .map(|series| crate::dto::Series { + name: series.name.clone(), + entries: series.entries.to_string(), + }) + .collect(), + language: t.meta.language.map(|l| l.to_str().to_string()), + size: t.meta.size.to_string(), + filetypes: t.meta.filetypes.clone(), + }, + linked: t.library_path.is_some(), + created_at: format_timestamp_db(&t.created_at), + replaced_at: t + .replaced_with + .as_ref() + .map(|(_, ts)| format_timestamp_db(ts)), + abs_id: t.meta.ids.get(ids::ABS).cloned(), + } +} + +#[server] +pub async fn get_replaced_data( + sort: Option, + asc: bool, + filters: Vec<(ReplacedPageFilter, String)>, + from: Option, + page_size: Option, +) -> Result { + let context = crate::error::get_context()?; + + let mut from_val = from.unwrap_or(0); + let page_size_val = page_size.unwrap_or(500); + + let r = context.db().r_transaction().server_err()?; + + let mut replaced = r + .scan() + .secondary::(TorrentKey::created_at) + .server_err()? + .all() + .server_err()? + .rev() + .filter_map(Result::ok) + .filter(|t| t.replaced_with.is_some()) + .filter(|t| { + filters + .iter() + .all(|(field, value)| matches_filter(t, *field, value)) + }) + .collect::>(); + + if let Some(sort_by) = sort { + replaced.sort_by(|a, b| { + let ord = match sort_by { + ReplacedPageSort::Kind => a.meta.media_type.cmp(&b.meta.media_type), + ReplacedPageSort::Title => a.meta.title.cmp(&b.meta.title), + ReplacedPageSort::Authors => a.meta.authors.cmp(&b.meta.authors), + ReplacedPageSort::Narrators => a.meta.narrators.cmp(&b.meta.narrators), + ReplacedPageSort::Series => a.meta.series.cmp(&b.meta.series), + ReplacedPageSort::Language => a.meta.language.cmp(&b.meta.language), + ReplacedPageSort::Size => a.meta.size.cmp(&b.meta.size), + ReplacedPageSort::Replaced => a + .replaced_with + .as_ref() + .map(|r| r.1) + .cmp(&b.replaced_with.as_ref().map(|r| r.1)), + ReplacedPageSort::CreatedAt => a.created_at.cmp(&b.created_at), + }; + if asc { ord } else { ord.reverse() } + }); + } + + let total = replaced.len(); + if page_size_val > 0 && from_val >= total && total > 0 { + from_val = ((total - 1) / page_size_val) * page_size_val; + } + + let limit = if page_size_val == 0 { + usize::MAX + } else { + page_size_val + }; + + let mut rows = Vec::new(); + for torrent in replaced.into_iter().skip(from_val).take(limit) { + let Some((replacement_id, _)) = &torrent.replaced_with else { + continue; + }; + let Some(replacement) = r + .get() + .primary::(replacement_id.clone()) + .server_err()? + else { + continue; + }; + rows.push(ReplacedPairRow { + torrent: convert_row(&torrent), + replacement: convert_row(&replacement), + }); + } + + let abs_url = context + .config() + .await + .audiobookshelf + .as_ref() + .map(|abs| abs.url.clone()); + + Ok(ReplacedData { + torrents: rows, + total, + from: from_val, + page_size: page_size_val, + abs_url, + }) +} + +#[server] +pub async fn apply_replaced_action( + action: ReplacedBulkAction, + torrent_ids: Vec, +) -> Result<(), ServerFnError> { + if torrent_ids.is_empty() { + return Err(ServerFnError::new("No torrents selected")); + } + + let context = crate::error::get_context()?; + + match action { + ReplacedBulkAction::Refresh => { + let config = context.config().await; + let mam = context.mam().server_err()?; + for id in torrent_ids { + refresh_mam_metadata(&config, context.db(), &mam, id, &context.events) + .await + .server_err()?; + } + } + ReplacedBulkAction::RefreshRelink => { + let config = context.config().await; + let mam = context.mam().server_err()?; + for id in torrent_ids { + refresh_metadata_relink(&config, context.db(), &mam, id, &context.events) + .await + .server_err()?; + } + } + ReplacedBulkAction::Remove => { + let (_guard, rw) = context.db().rw_async().await.server_err()?; + for id in torrent_ids { + let Some(torrent) = rw.get().primary::(id).server_err()? else { + continue; + }; + rw.remove(torrent).server_err()?; + } + rw.commit().server_err()?; + } + } + + Ok(()) +} diff --git a/mlm_web_dioxus/src/replaced/types.rs b/mlm_web_dioxus/src/replaced/types.rs new file mode 100644 index 00000000..e8b7a757 --- /dev/null +++ b/mlm_web_dioxus/src/replaced/types.rs @@ -0,0 +1,232 @@ +use crate::components::PageColumns; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ReplacedPageSort { + Kind, + Title, + Authors, + Narrators, + Series, + Language, + Size, + Replaced, + CreatedAt, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum ReplacedPageFilter { + Kind, + Title, + Author, + Narrator, + Series, + Language, + Filetype, + Linked, +} + +#[derive(Clone, Copy)] +pub enum ReplacedColumn { + Authors, + Narrators, + Series, + Language, + Size, + Filetypes, +} + +pub const COLUMN_OPTIONS: &[(ReplacedColumn, &str)] = &[ + (ReplacedColumn::Authors, "Authors"), + (ReplacedColumn::Narrators, "Narrators"), + (ReplacedColumn::Series, "Series"), + (ReplacedColumn::Language, "Language"), + (ReplacedColumn::Size, "Size"), + (ReplacedColumn::Filetypes, "Filetypes"), +]; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] +pub struct ReplacedPageColumns { + pub authors: bool, + pub narrators: bool, + pub series: bool, + pub language: bool, + pub size: bool, + pub filetypes: bool, +} + +impl Default for ReplacedPageColumns { + fn default() -> Self { + Self { + authors: true, + narrators: true, + series: true, + language: false, + size: true, + filetypes: true, + } + } +} + +impl ReplacedPageColumns { + pub fn table_grid_template(self) -> String { + let mut cols = vec!["30px", "110px", "2fr"]; + if self.authors { + cols.push("1fr"); + } + if self.narrators { + cols.push("1fr"); + } + if self.series { + cols.push("1fr"); + } + if self.language { + cols.push("100px"); + } + if self.size { + cols.push("81px"); + } + if self.filetypes { + cols.push("100px"); + } + cols.push("157px"); + cols.push("157px"); + cols.join(" ") + } + + pub fn get(self, col: ReplacedColumn) -> bool { + match col { + ReplacedColumn::Authors => self.authors, + ReplacedColumn::Narrators => self.narrators, + ReplacedColumn::Series => self.series, + ReplacedColumn::Language => self.language, + ReplacedColumn::Size => self.size, + ReplacedColumn::Filetypes => self.filetypes, + } + } + + pub fn set(&mut self, col: ReplacedColumn, enabled: bool) { + match col { + ReplacedColumn::Authors => self.authors = enabled, + ReplacedColumn::Narrators => self.narrators = enabled, + ReplacedColumn::Series => self.series = enabled, + ReplacedColumn::Language => self.language = enabled, + ReplacedColumn::Size => self.size = enabled, + ReplacedColumn::Filetypes => self.filetypes = enabled, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ReplacedMeta { + pub title: String, + pub media_type: String, + pub authors: Vec, + pub narrators: Vec, + pub series: Vec, + pub language: Option, + pub size: String, + pub filetypes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ReplacedRow { + pub id: String, + pub mam_id: Option, + pub meta: ReplacedMeta, + pub linked: bool, + pub created_at: String, + pub replaced_at: Option, + pub abs_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ReplacedPairRow { + pub torrent: ReplacedRow, + pub replacement: ReplacedRow, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)] +pub struct ReplacedData { + pub torrents: Vec, + pub total: usize, + pub from: usize, + pub page_size: usize, + pub abs_url: Option, +} + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum ReplacedBulkAction { + Refresh, + RefreshRelink, + Remove, +} + +impl ReplacedBulkAction { + pub fn label(self) -> &'static str { + match self { + Self::Refresh => "refresh metadata", + Self::RefreshRelink => "refresh metadata and relink", + Self::Remove => "remove torrent from MLM", + } + } + + pub fn success_label(self) -> &'static str { + match self { + Self::Refresh => "Refreshed metadata", + Self::RefreshRelink => "Refreshed metadata and relinked", + Self::Remove => "Removed torrents", + } + } +} + +impl PageColumns for ReplacedPageColumns { + fn to_query_value(&self) -> String { + let mut values = Vec::new(); + if self.authors { + values.push("author"); + } + if self.narrators { + values.push("narrator"); + } + if self.series { + values.push("series"); + } + if self.language { + values.push("language"); + } + if self.size { + values.push("size"); + } + if self.filetypes { + values.push("filetype"); + } + values.join(",") + } + + fn from_query_value(value: &str) -> Self { + let mut show = ReplacedPageColumns { + authors: false, + narrators: false, + series: false, + language: false, + size: false, + filetypes: false, + }; + for item in value.split(',') { + match item { + "author" => show.authors = true, + "narrator" => show.narrators = true, + "series" => show.series = true, + "language" => show.language = true, + "size" => show.size = true, + "filetype" => show.filetypes = true, + _ => {} + } + } + show + } +} diff --git a/mlm_web_dioxus/src/search.rs b/mlm_web_dioxus/src/search.rs new file mode 100644 index 00000000..72007550 --- /dev/null +++ b/mlm_web_dioxus/src/search.rs @@ -0,0 +1,387 @@ +use crate::components::SearchTorrentRow; +use crate::components::StatusMessage; +use crate::components::{build_query_string, parse_location_query_pairs, set_location_query_string}; +use crate::dto::Series; +#[cfg(feature = "server")] +use crate::dto::sanitize_optional_html; +#[cfg(feature = "server")] +use crate::error::IntoServerFnError; +use dioxus::prelude::*; +#[cfg(feature = "server")] +use mlm_mam::search::MaMTorrent; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "server")] +use mlm_core::{ContextExt, Torrent as DbTorrent, TorrentKey}; +#[cfg(feature = "server")] +use mlm_db::Flags; + +#[cfg(feature = "server")] +pub fn map_search_torrent( + mam_torrent: MaMTorrent, + meta: &mlm_db::TorrentMeta, + search_config: mlm_core::config::SearchConfig, + is_downloaded: bool, + is_selected: bool, +) -> SearchTorrent { + let can_wedge = search_config + .wedge_over + .is_some_and(|wedge_over| meta.size >= wedge_over && !mam_torrent.is_free()); + let media_duration = mam_torrent + .media_info + .as_ref() + .map(|m| m.general.duration.clone()); + let media_format = mam_torrent + .media_info + .as_ref() + .map(|m| format!("{} {}", m.general.format, m.audio.format)); + let audio_bitrate = mam_torrent + .media_info + .as_ref() + .map(|m| format!("{} {}", m.audio.bitrate, m.audio.mode)); + let old_category = meta.cat.as_ref().map(|cat| cat.to_string()); + let flags = Flags::from_bitfield(meta.flags.map_or(0, |f| f.0)); + let flag_values = crate::utils::flags_to_strings(&flags); + + SearchTorrent { + mam_id: mam_torrent.id, + mediatype_id: mam_torrent.mediatype, + main_cat_id: mam_torrent.main_cat, + lang_code: mam_torrent.lang_code, + title: meta.title.clone(), + edition: meta.edition.as_ref().map(|(ed, _)| ed.clone()), + authors: meta.authors.clone(), + narrators: meta.narrators.clone(), + series: meta + .series + .iter() + .map(|s| Series { + name: s.name.clone(), + entries: s.entries.to_string(), + }) + .collect(), + tags: mam_torrent.tags, + description_html: sanitize_optional_html(mam_torrent.description), + categories: meta + .categories + .iter() + .map(|c| c.as_str().to_string()) + .collect(), + flags: flag_values, + old_category, + media_type: meta.media_type.as_str().to_string(), + filetypes: meta.filetypes.clone(), + size: meta.size.to_string(), + num_files: mam_torrent.numfiles, + uploaded_at: mam_torrent.added, + owner_name: mam_torrent.owner_name, + seeders: mam_torrent.seeders, + leechers: mam_torrent.leechers, + snatches: mam_torrent.times_completed, + comments: mam_torrent.comments, + media_duration, + media_format, + audio_bitrate, + vip: mam_torrent.vip, + personal_freeleech: mam_torrent.personal_freeleech, + free: mam_torrent.free, + is_downloaded, + is_selected, + can_wedge, + } +} + +fn search_state_from_params(params: &[(String, String)]) -> (String, String, String, Option) { + let query = params + .iter() + .find_map(|(k, v)| (k == "q").then_some(v.clone())) + .unwrap_or_default(); + let sort = params + .iter() + .find_map(|(k, v)| (k == "sort").then_some(v.clone())) + .unwrap_or_default(); + let uploader_input = params + .iter() + .find_map(|(k, v)| (k == "uploader").then_some(v.clone())) + .unwrap_or_default(); + let uploader = uploader_input.trim().parse::().ok(); + (query, sort, uploader_input, uploader) +} + +fn search_query_string(query: &str, sort: &str, uploader_input: &str) -> String { + let mut params = Vec::new(); + if !query.is_empty() { + params.push(("q".to_string(), query.to_string())); + } + if !sort.is_empty() { + params.push(("sort".to_string(), sort.to_string())); + } + if !uploader_input.trim().is_empty() { + params.push(("uploader".to_string(), uploader_input.trim().to_string())); + } + build_query_string(¶ms) +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct SearchData { + pub torrents: Vec, + pub total: usize, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct SearchTorrent { + pub mam_id: u64, + pub mediatype_id: u8, + pub main_cat_id: u8, + pub lang_code: String, + pub title: String, + pub edition: Option, + pub authors: Vec, + pub narrators: Vec, + pub series: Vec, + pub tags: String, + pub description_html: Option, + pub categories: Vec, + pub flags: Vec, + pub old_category: Option, + pub media_type: String, + pub filetypes: Vec, + pub size: String, + pub num_files: u64, + pub uploaded_at: String, + pub owner_name: String, + pub seeders: u64, + pub leechers: u64, + pub snatches: u64, + pub comments: u64, + pub media_duration: Option, + pub media_format: Option, + pub audio_bitrate: Option, + pub vip: bool, + pub personal_freeleech: bool, + pub free: bool, + pub is_downloaded: bool, + pub is_selected: bool, + pub can_wedge: bool, +} + +#[server] +pub async fn get_search_data( + q: String, + sort: String, + uploader: Option, +) -> Result { + use mlm_mam::{ + enums::SearchTarget, + search::{SearchFields, SearchQuery, Tor}, + }; + + let context = crate::error::get_context()?; + + let mam = context.mam().server_err()?; + let result = mam + .search(&SearchQuery { + fields: SearchFields { + media_info: true, + ..Default::default() + }, + tor: Tor { + target: uploader.map(SearchTarget::Uploader), + text: q, + ..Default::default() + }, + ..Default::default() + }) + .await + .server_err()?; + + let search_config = context.config().await.search.clone(); + let r = context.db().r_transaction().server_err()?; + + let mut torrents = result + .data + .into_iter() + .map(|mam_torrent| -> Result { + let meta = mam_torrent.as_meta().server_err()?; + let torrent = r + .get() + .secondary::(TorrentKey::mam_id, meta.mam_id()) + .server_err()?; + let selected_torrent = r + .get() + .primary::(mam_torrent.id) + .server_err()?; + + Ok(map_search_torrent( + mam_torrent, + &meta, + search_config.clone(), + torrent.is_some(), + selected_torrent.is_some(), + )) + }) + .collect::, _>>()?; + + if sort == "series" { + torrents.sort_by(|a, b| { + a.series + .iter() + .map(|s| (s.name.as_str(), s.entries.as_str())) + .cmp( + b.series + .iter() + .map(|s| (s.name.as_str(), s.entries.as_str())), + ) + .then(a.media_type.cmp(&b.media_type)) + }); + } + + Ok(SearchData { + torrents, + total: result.total, + }) +} + +#[component] +pub fn SearchPage() -> Element { + let _route: crate::app::Route = use_route(); + let params = parse_location_query_pairs(); + let (initial_query, initial_sort, initial_uploader_input, initial_submitted_uploader) = + search_state_from_params(¶ms); + + let query_input_initial = initial_query.clone(); + let sort_input_initial = initial_sort.clone(); + let request_query_initial = initial_query; + let request_sort_initial = initial_sort; + let route_state_initial = ( + request_query_initial.clone(), + request_sort_initial.clone(), + initial_uploader_input.clone(), + ); + + let mut query_input = use_signal(move || query_input_initial.clone()); + let mut sort_input = use_signal(move || sort_input_initial.clone()); + let mut uploader_input = use_signal(move || initial_uploader_input.clone()); + let mut request_query = use_signal(move || request_query_initial.clone()); + let mut request_sort = use_signal(move || request_sort_initial.clone()); + let mut request_uploader = use_signal(move || initial_submitted_uploader); + let mut last_route_state = use_signal(move || route_state_initial.clone()); + let status_msg = use_signal(|| None::<(String, bool)>); + let mut cached = use_signal(|| None::); + + let mut data_res = use_server_future(move || async move { + get_search_data( + request_query.read().clone(), + request_sort.read().clone(), + *request_uploader.read(), + ) + .await + })?; + + let current_value = data_res.value(); + let pending = data_res.pending(); + + { + let params = parse_location_query_pairs(); + let (route_query, route_sort, route_uploader_input, route_uploader) = + search_state_from_params(¶ms); + let next_route_state = ( + route_query.clone(), + route_sort.clone(), + route_uploader_input.clone(), + ); + if *last_route_state.read() != next_route_state { + query_input.set(route_query.clone()); + sort_input.set(route_sort.clone()); + uploader_input.set(route_uploader_input); + request_query.set(route_query); + request_sort.set(route_sort); + request_uploader.set(route_uploader); + last_route_state.set(next_route_state); + data_res.restart(); + } + } + + use_effect(move || { + let value = current_value.read(); + if let Some(Ok(data)) = &*value { + cached.set(Some(data.clone())); + } + }); + + let data_to_show = { + let value = current_value.read(); + match &*value { + Some(Ok(data)) => Some(data.clone()), + _ => cached.read().clone(), + } + }; + + rsx! { + div { class: "search-page", + form { + class: "row", + onsubmit: move |ev: Event| { + ev.prevent_default(); + let next_query = query_input.read().clone(); + let next_sort = sort_input.read().clone(); + let next_uploader_input = uploader_input.read().clone(); + let uploader = next_uploader_input.trim().parse::().ok(); + let next_route_state = ( + next_query.clone(), + next_sort.clone(), + next_uploader_input.clone(), + ); + + set_location_query_string(&search_query_string( + &next_query, + &next_sort, + &next_uploader_input, + )); + last_route_state.set(next_route_state); + request_query.set(next_query); + request_sort.set(next_sort); + request_uploader.set(uploader); + data_res.restart(); + }, + h1 { "MaM Search" } + input { + r#type: "text", + value: "{query_input}", + placeholder: "Search torrents...", + oninput: move |ev| query_input.set(ev.value()), + } + button { r#type: "submit", "Search" } + } + + StatusMessage { status_msg } + + if pending && cached.read().is_some() { + p { class: "loading-indicator", "Refreshing..." } + } + + if let Some(data) = data_to_show { + p { class: "faint", "Showing {data.total} torrents" } + if data.torrents.is_empty() { + p { + i { "No torrents found" } + } + } else { + div { class: "Torrents", + for torrent in data.torrents { + SearchTorrentRow { + torrent, + status_msg, + on_refresh: move |_| data_res.restart(), + } + } + } + } + } else if let Some(Err(e)) = &*current_value.read() { + p { class: "error", "Error: {e}" } + } else { + p { "Loading search results..." } + } + } + } +} diff --git a/mlm_web_dioxus/src/selected/components.rs b/mlm_web_dioxus/src/selected/components.rs new file mode 100644 index 00000000..0dcd01dc --- /dev/null +++ b/mlm_web_dioxus/src/selected/components.rs @@ -0,0 +1,585 @@ +use std::collections::BTreeSet; +use std::sync::Arc; + +use crate::components::{ + ActiveFilterChip, ActiveFilters, ColumnSelector, ColumnToggleOption, FilterLink, SortHeader, + TorrentGridTable, TorrentTitleLink, flag_icon, set_location_query_string, update_row_selection, +}; +use crate::sse::{QBIT_PROGRESS, SELECTED_UPDATE_TRIGGER}; +use dioxus::prelude::*; + +use super::query::{build_query_url, parse_query_state}; +use super::server_fns::{apply_selected_action, get_selected_data, get_selected_user_info}; +use super::types::{ + COLUMN_OPTIONS, SelectedBulkAction, SelectedData, SelectedPageFilter, SelectedPageSort, + filter_name, +}; + +#[component] +pub fn SelectedPage() -> Element { + let _route: crate::app::Route = use_route(); + let initial_state = parse_query_state(); + let initial_sort = initial_state.sort; + let initial_asc = initial_state.asc; + let initial_filters = initial_state.filters.clone(); + let initial_show = initial_state.show; + let initial_request_key = build_query_url( + initial_state.sort, + initial_state.asc, + &initial_state.filters, + initial_state.show, + ); + + let sort = use_signal(move || initial_sort); + let asc = use_signal(move || initial_asc); + let from = use_signal(|| 0usize); + let filters = use_signal(move || initial_filters.clone()); + let show = use_signal(move || initial_show); + let mut selected = use_signal(BTreeSet::::new); + let last_selected_idx = use_signal(|| None::); + let mut unsats_input = use_signal(|| "1".to_string()); + let mut status_msg = use_signal(|| None::<(String, bool)>); + let mut cached = use_signal(|| None::); + let loading_action = use_signal(|| false); + let mut last_request_key = use_signal(move || initial_request_key.clone()); + let mut last_selected_trigger = use_signal(|| 0u32); + + let mut selected_data = use_server_future(move || async move { + get_selected_data( + *sort.read(), + *asc.read(), + filters.read().clone(), + *show.read(), + ) + .await + }) + .ok(); + + let user_info = + use_resource(move || async move { get_selected_user_info().await.ok().flatten() }); + + let pending = selected_data + .as_ref() + .map(|resource| resource.pending()) + .unwrap_or(true); + let value = selected_data.as_ref().map(|resource| resource.value()); + + { + let route_state = parse_query_state(); + let route_request_key = build_query_url( + route_state.sort, + route_state.asc, + &route_state.filters, + route_state.show, + ); + if *last_request_key.read() != route_request_key { + let mut sort = sort; + let mut asc = asc; + let mut filters_signal = filters; + let mut show = show; + sort.set(route_state.sort); + asc.set(route_state.asc); + filters_signal.set(route_state.filters); + show.set(route_state.show); + last_request_key.set(route_request_key); + if let Some(resource) = selected_data.as_mut() { + resource.restart(); + } + } + } + + let cache_value = value; + use_effect(move || { + if let Some(value) = &cache_value { + let value = value.read(); + if let Some(Ok(data)) = &*value { + cached.set(Some(data.clone())); + } + } + }); + + use_effect(move || { + let current_trigger = *SELECTED_UPDATE_TRIGGER.read(); + if *last_selected_trigger.read() != current_trigger { + last_selected_trigger.set(current_trigger); + if let Some(resource) = selected_data.as_mut() { + resource.restart(); + } + } + }); + + let data_to_show = { + if let Some(value) = &value { + let value = value.read(); + match &*value { + Some(Ok(data)) => Some(data.clone()), + _ => cached.read().clone(), + } + } else { + cached.read().clone() + } + }; + + use_effect(move || { + let query_string = build_query_url( + *sort.read(), + *asc.read(), + &filters.read().clone(), + *show.read(), + ); + let should_restart = *last_request_key.read() != query_string; + if should_restart { + last_request_key.set(query_string.clone()); + set_location_query_string(&query_string); + if let Some(resource) = selected_data.as_mut() { + resource.restart(); + } + } + }); + + let column_options = COLUMN_OPTIONS + .iter() + .map(|(column, label)| { + let checked = show.read().get(*column); + let column = *column; + ColumnToggleOption { + label, + checked, + on_toggle: Callback::new({ + let mut show = show; + move |enabled| { + let mut next = *show.read(); + next.set(column, enabled); + show.set(next); + } + }), + } + }) + .collect::>(); + + let mut active_chips = Vec::new(); + for (field, value) in filters.read().clone() { + active_chips.push(ActiveFilterChip { + label: format!("{}: {}", filter_name(field), value), + on_remove: Callback::new({ + let value = value.clone(); + let mut filters = filters; + move |_| { + filters + .write() + .retain(|(f, v)| !(*f == field && *v == value)); + } + }), + }); + } + + let clear_all: Option> = if active_chips.is_empty() { + None + } else { + Some(Callback::new({ + let mut filters = filters; + move |_| filters.set(Vec::new()) + })) + }; + + let all_row_ids = Arc::new( + data_to_show + .as_ref() + .map(|data| data.torrents.iter().map(|t| t.mam_id).collect::>()) + .unwrap_or_default(), + ); + + rsx! { + div { class: "selected-page", + div { class: "row", + h1 { "Selected Torrents" } + div { class: "actions actions_torrent", + style: if selected.read().is_empty() { "" } else { "display: flex" }, + button { + r#type: "button", + disabled: *loading_action.read(), + onclick: { + let mut loading_action = loading_action; + let mut status_msg = status_msg; + let mut selected_data = selected_data; + let mut selected = selected; + move |_| { + let ids = selected.read().iter().copied().collect::>(); + if ids.is_empty() { + status_msg.set(Some(("Select at least one torrent".to_string(), true))); + return; + } + loading_action.set(true); + status_msg.set(None); + spawn(async move { + match apply_selected_action(SelectedBulkAction::Remove, ids, None).await { + Ok(_) => { + status_msg.set(Some((SelectedBulkAction::Remove.success_label().to_string(), false))); + selected.set(BTreeSet::new()); + if let Some(resource) = selected_data.as_mut() { + resource.restart(); + } + } + Err(e) => { + status_msg.set(Some((format!("{} failed: {e}", SelectedBulkAction::Remove.label()), true))); + } + } + loading_action.set(false); + }); + } + }, + "{SelectedBulkAction::Remove.label()}" + } + span { "{SelectedBulkAction::Update.label()}:" } + input { + r#type: "number", + value: "{unsats_input}", + min: "0", + oninput: move |ev| unsats_input.set(ev.value()), + } + button { + r#type: "button", + disabled: *loading_action.read(), + onclick: { + let mut loading_action = loading_action; + let mut status_msg = status_msg; + let mut selected_data = selected_data; + let mut selected = selected; + move |_| { + let ids = selected.read().iter().copied().collect::>(); + if ids.is_empty() { + status_msg.set(Some(("Select at least one torrent".to_string(), true))); + return; + } + let unsats = unsats_input.read().trim().parse::().ok(); + loading_action.set(true); + status_msg.set(None); + spawn(async move { + match apply_selected_action(SelectedBulkAction::Update, ids, unsats).await { + Ok(_) => { + status_msg.set(Some((SelectedBulkAction::Update.success_label().to_string(), false))); + selected.set(BTreeSet::new()); + if let Some(resource) = selected_data.as_mut() { + resource.restart(); + } + } + Err(e) => { + status_msg.set(Some((format!("{} failed: {e}", SelectedBulkAction::Update.label()), true))); + } + } + loading_action.set(false); + }); + } + }, + "apply" + } + } + div { class: "table_options", + ColumnSelector { + options: column_options, + } + } + } + p { "Torrents that the autograbber has selected and will be downloaded" } + + if let Some((msg, is_error)) = status_msg.read().as_ref() { + p { class: if *is_error { "error" } else { "loading-indicator" }, + "{msg}" + button { + r#type: "button", + style: "margin-left: 10px; cursor: pointer;", + onclick: move |_| status_msg.set(None), + "⨯" + } + } + } + + if let Some(info) = user_info.read().as_ref().and_then(|info| info.as_ref()) { + p { + if let Some(buffer) = &info.remaining_buffer { + "Buffer: {buffer}" + br {} + } + "Unsats: {info.unsat_count} / {info.unsat_limit}" + br {} + "Wedges: {info.wedges}" + br {} + "Bonus: {info.bonus}" + if let Some(data) = data_to_show.clone() { + if !data.torrents.is_empty() { + br {} + "Queued Torrents: {data.queued}" + br {} + "Downloading Torrents: {data.downloading}" + } + } + } + } + + ActiveFilters { + chips: active_chips, + on_clear_all: clear_all, + } + + if let Some(data) = data_to_show { + if data.torrents.is_empty() { + p { + i { "There are currently no torrents selected for downloading" } + } + } else { + TorrentGridTable { + grid_template: show.read().table_grid_template(), + extra_class: Some("SelectedTable".to_string()), + pending: pending && cached.read().is_some(), + { + let all_selected = data.torrents.iter().all(|t| selected.read().contains(&t.mam_id)); + rsx! { + div { class: "torrents-grid-row", + div { class: "header", + input { + r#type: "checkbox", + checked: all_selected, + onchange: { + let row_ids = data.torrents.iter().map(|t| t.mam_id).collect::>(); + move |ev| { + if ev.value() == "true" { + let mut next = selected.read().clone(); + for id in &row_ids { + next.insert(*id); + } + selected.set(next); + } else { + let mut next = selected.read().clone(); + for id in &row_ids { + next.remove(id); + } + selected.set(next); + } + } + }, + } + } + SortHeader { label: "Type", sort_key: SelectedPageSort::Kind, sort, asc, from } + if show.read().flags { + div { class: "header", "Flags" } + } + SortHeader { label: "Title", sort_key: SelectedPageSort::Title, sort, asc, from } + if show.read().authors { + SortHeader { label: "Authors", sort_key: SelectedPageSort::Authors, sort, asc, from } + } + if show.read().narrators { + SortHeader { label: "Narrators", sort_key: SelectedPageSort::Narrators, sort, asc, from } + } + if show.read().series { + SortHeader { label: "Series", sort_key: SelectedPageSort::Series, sort, asc, from } + } + if show.read().language { + SortHeader { label: "Language", sort_key: SelectedPageSort::Language, sort, asc, from } + } + if show.read().size { + SortHeader { label: "Size", sort_key: SelectedPageSort::Size, sort, asc, from } + } + if show.read().filetypes { + div { class: "header", "Filetypes" } + } + SortHeader { label: "Cost", sort_key: SelectedPageSort::Cost, sort, asc, from } + SortHeader { label: "Required Unsats", sort_key: SelectedPageSort::Buffer, sort, asc, from } + if show.read().grabber { + SortHeader { label: "Grabber", sort_key: SelectedPageSort::Grabber, sort, asc, from } + } + if show.read().created_at { + SortHeader { label: "Added At", sort_key: SelectedPageSort::CreatedAt, sort, asc, from } + } + if show.read().started_at { + SortHeader { label: "Started At", sort_key: SelectedPageSort::StartedAt, sort, asc, from } + } + if show.read().removed_at { + div { class: "header", "Removed At" } + } + } + } + } + + for (i, torrent) in data.torrents.into_iter().enumerate() { + { + let row_id = torrent.mam_id; + let row_selected = selected.read().contains(&row_id); + let all_row_ids = all_row_ids.clone(); + rsx! { + div { class: "torrents-grid-row", key: "{row_id}", + div { + input { + r#type: "checkbox", + checked: row_selected, + onclick: move |ev| { + update_row_selection( + &ev, + selected, + last_selected_idx, + all_row_ids.as_ref(), + &row_id, + i, + ); + }, + } + } + div { + FilterLink { + field: SelectedPageFilter::Kind, + value: torrent.meta.media_type.clone(), + title: Some(torrent.meta.cat_name.clone()), + "{torrent.meta.media_type}" + } + if show.read().category { + if let Some(cat_id) = torrent.meta.cat_id.clone() { + div { + FilterLink { + field: SelectedPageFilter::Category, + value: cat_id.clone(), + "{torrent.meta.cat_name}" + } + } + } + } + } + if show.read().flags { + div { + for flag in torrent.meta.flags.clone() { + if let Some((src, title)) = flag_icon(&flag) { + FilterLink { + field: SelectedPageFilter::Flags, + value: flag.clone(), + img { + class: "flag", + src: "{src}", + alt: "{title}", + title: "{title}", + } + } + } + } + } + } + div { + TorrentTitleLink { + detail_id: torrent.mam_id.to_string(), + field: SelectedPageFilter::Title, + value: torrent.meta.title.clone(), + "{torrent.meta.title}" + } + } + if show.read().authors { + div { + for author in torrent.meta.authors.clone() { + FilterLink { + field: SelectedPageFilter::Author, + value: author.clone(), + "{author}" + } + } + } + } + if show.read().narrators { + div { + for narrator in torrent.meta.narrators.clone() { + FilterLink { + field: SelectedPageFilter::Narrator, + value: narrator.clone(), + "{narrator}" + } + } + } + } + if show.read().series { + div { + for series in torrent.meta.series.clone() { + FilterLink { + field: SelectedPageFilter::Series, + value: series.name.clone(), + if series.entries.is_empty() { + "{series.name}" + } else { + "{series.name} #{series.entries}" + } + } + } + } + } + if show.read().language { + div { + FilterLink { + field: SelectedPageFilter::Language, + value: torrent.meta.language.clone().unwrap_or_default(), + "{torrent.meta.language.clone().unwrap_or_default()}" + } + } + } + if show.read().size { + div { "{torrent.meta.size}" } + } + if show.read().filetypes { + div { + for filetype in torrent.meta.filetypes.clone() { + FilterLink { + field: SelectedPageFilter::Filetype, + value: filetype.clone(), + "{filetype}" + } + } + } + } + div { + FilterLink { + field: SelectedPageFilter::Cost, + value: torrent.cost.clone(), + "{torrent.cost}" + } + } + div { "{torrent.required_unsats}" } + if show.read().grabber { + div { + FilterLink { + field: SelectedPageFilter::Grabber, + value: torrent.grabber.clone().unwrap_or_default(), + "{torrent.grabber.clone().unwrap_or_default()}" + } + } + } + if show.read().created_at { + div { "{torrent.created_at}" } + } + if show.read().started_at { + div { + "{torrent.started_at.clone().unwrap_or_default()}" + if torrent.started_at.is_some() && torrent.removed_at.is_none() { + if let Some(pct) = QBIT_PROGRESS.read().iter().find(|(id, _)| *id == torrent.mam_id).map(|(_, p)| *p) { + " " + span { + title: "qBittorrent download progress", + "{pct}%" + } + } + } + } + } + if show.read().removed_at { + div { "{torrent.removed_at.clone().unwrap_or_default()}" } + } + } + } + } + } + } + } + } else if let Some(value) = &value { + if let Some(Err(e)) = &*value.read() { + p { class: "error", "Error: {e}" } + } else { + p { class: "loading-indicator", "Loading selected torrents..." } + } + } else { + p { class: "loading-indicator", "Loading selected torrents..." } + } + } + } +} diff --git a/mlm_web_dioxus/src/selected/mod.rs b/mlm_web_dioxus/src/selected/mod.rs new file mode 100644 index 00000000..15c339d3 --- /dev/null +++ b/mlm_web_dioxus/src/selected/mod.rs @@ -0,0 +1,11 @@ +mod components; +mod query; +mod server_fns; +mod types; + +pub use components::SelectedPage; +pub use server_fns::{apply_selected_action, get_selected_data}; +pub use types::{ + SelectedBulkAction, SelectedData, SelectedMeta, SelectedPageColumns, SelectedPageFilter, + SelectedPageSort, SelectedRow, SelectedUserInfo, +}; diff --git a/mlm_web_dioxus/src/selected/query.rs b/mlm_web_dioxus/src/selected/query.rs new file mode 100644 index 00000000..6b343ab4 --- /dev/null +++ b/mlm_web_dioxus/src/selected/query.rs @@ -0,0 +1,55 @@ +use crate::components::{ + PageColumns, build_query_string, encode_query_enum, parse_location_query_pairs, + parse_query_enum, +}; + +use super::types::{SelectedPageColumns, SelectedPageFilter, SelectedPageSort}; + +#[derive(Clone, Default)] +pub struct PageQueryState { + pub sort: Option, + pub asc: bool, + pub filters: Vec<(SelectedPageFilter, String)>, + pub show: SelectedPageColumns, +} + +pub fn parse_query_state() -> PageQueryState { + let mut state = PageQueryState::default(); + for (key, value) in parse_location_query_pairs() { + match key.as_str() { + "sort_by" => state.sort = parse_query_enum::(&value), + "asc" => state.asc = value == "true", + "show" => state.show = SelectedPageColumns::from_query_value(&value), + _ => { + if let Some(field) = parse_query_enum::(&key) { + state.filters.push((field, value)); + } + } + } + } + state +} + +pub fn build_query_url( + sort: Option, + asc: bool, + filters: &[(SelectedPageFilter, String)], + show: SelectedPageColumns, +) -> String { + let mut params = Vec::new(); + if let Some(sort) = sort.and_then(encode_query_enum) { + params.push(("sort_by".to_string(), sort)); + } + if asc { + params.push(("asc".to_string(), "true".to_string())); + } + if show != SelectedPageColumns::default() { + params.push(("show".to_string(), show.to_query_value())); + } + for (field, value) in filters { + if let Some(name) = encode_query_enum(*field) { + params.push((name, value.clone())); + } + } + build_query_string(¶ms) +} diff --git a/mlm_web_dioxus/src/selected/server_fns.rs b/mlm_web_dioxus/src/selected/server_fns.rs new file mode 100644 index 00000000..50872f10 --- /dev/null +++ b/mlm_web_dioxus/src/selected/server_fns.rs @@ -0,0 +1,281 @@ +use dioxus::prelude::*; +#[cfg(feature = "server")] +use std::str::FromStr; + +#[cfg(feature = "server")] +use crate::error::IntoServerFnError; +#[cfg(feature = "server")] +use crate::utils::format_timestamp_db; +#[cfg(feature = "server")] +use mlm_core::ContextExt; +#[cfg(feature = "server")] +use mlm_db::{DatabaseExt as _, Flags, Language, OldCategory, SelectedTorrent, Timestamp}; + +use super::types::{ + SelectedBulkAction, SelectedData, SelectedMeta, SelectedPageColumns, SelectedPageFilter, + SelectedPageSort, SelectedRow, SelectedUserInfo, +}; + +#[server] +pub async fn get_selected_data( + sort: Option, + asc: bool, + filters: Vec<(SelectedPageFilter, String)>, + show: SelectedPageColumns, +) -> Result { + let context = crate::error::get_context()?; + let config = context.config().await; + + let mut torrents = context + .db() + .r_transaction() + .server_err()? + .scan() + .primary::() + .server_err()? + .all() + .server_err()? + .filter_map(Result::ok) + .filter(|t| show.removed_at || t.removed_at.is_none()) + .filter(|t| { + filters.iter().all(|(field, value)| match field { + SelectedPageFilter::Kind => t.meta.media_type.as_str() == value, + SelectedPageFilter::Category => { + if value.is_empty() { + t.meta.cat.is_none() + } else if let Some(cat) = &t.meta.cat { + let cats = value + .split(',') + .filter_map(|id| id.parse().ok()) + .filter_map(OldCategory::from_one_id) + .collect::>(); + cats.contains(cat) || cat.as_str() == value + } else { + false + } + } + SelectedPageFilter::Flags => { + if value.is_empty() { + t.meta.flags.is_none_or(|f| f.0 == 0) + } else if let Some(flags) = &t.meta.flags { + let flags = Flags::from_bitfield(flags.0); + match value.as_str() { + "violence" => flags.violence == Some(true), + "explicit" => flags.explicit == Some(true), + "some_explicit" => flags.some_explicit == Some(true), + "language" => flags.crude_language == Some(true), + "abridged" => flags.abridged == Some(true), + "lgbt" => flags.lgbt == Some(true), + _ => false, + } + } else { + false + } + } + SelectedPageFilter::Title => t.meta.title == *value, + SelectedPageFilter::Author => t.meta.authors.contains(value), + SelectedPageFilter::Narrator => t.meta.narrators.contains(value), + SelectedPageFilter::Series => t.meta.series.iter().any(|s| &s.name == value), + SelectedPageFilter::Language => { + if value.is_empty() { + t.meta.language.is_none() + } else { + t.meta.language == Language::from_str(value).ok() + } + } + SelectedPageFilter::Filetype => t.meta.filetypes.contains(value), + SelectedPageFilter::Cost => t.cost.as_str() == value, + SelectedPageFilter::Grabber => { + if value.is_empty() { + t.grabber.is_none() + } else { + t.grabber.as_deref() == Some(value) + } + } + }) + }) + .collect::>(); + + if let Some(sort_by) = sort { + torrents.sort_by(|a, b| { + let ord = match sort_by { + SelectedPageSort::Kind => a.meta.media_type.cmp(&b.meta.media_type), + SelectedPageSort::Title => a.meta.title.cmp(&b.meta.title), + SelectedPageSort::Authors => a.meta.authors.cmp(&b.meta.authors), + SelectedPageSort::Narrators => a.meta.narrators.cmp(&b.meta.narrators), + SelectedPageSort::Series => a.meta.series.cmp(&b.meta.series), + SelectedPageSort::Language => a.meta.language.cmp(&b.meta.language), + SelectedPageSort::Size => a.meta.size.cmp(&b.meta.size), + SelectedPageSort::Cost => a.cost.cmp(&b.cost), + SelectedPageSort::Buffer => a + .unsat_buffer + .unwrap_or(config.unsat_buffer) + .cmp(&b.unsat_buffer.unwrap_or(config.unsat_buffer)), + SelectedPageSort::Grabber => a.grabber.cmp(&b.grabber), + SelectedPageSort::CreatedAt => a.created_at.cmp(&b.created_at), + SelectedPageSort::StartedAt => a.started_at.cmp(&b.started_at), + }; + if asc { ord } else { ord.reverse() } + }); + } + + let queued = torrents.iter().filter(|t| t.started_at.is_none()).count(); + let downloading = torrents.iter().filter(|t| t.started_at.is_some()).count(); + + Ok(SelectedData { + torrents: torrents + .into_iter() + .map(|t| convert_selected_row(&t, config.unsat_buffer)) + .collect(), + queued, + downloading, + }) +} + +#[server] +pub async fn get_selected_user_info() -> Result, ServerFnError> { + let context = crate::error::get_context()?; + let config = context.config().await; + + let downloading_size: f64 = context + .db() + .r_transaction() + .server_err()? + .scan() + .primary::() + .server_err()? + .all() + .server_err()? + .filter_map(Result::ok) + .filter(|t| t.removed_at.is_none() && t.started_at.is_some()) + .map(|t| t.meta.size.bytes() as f64) + .sum(); + + let user_info = match context.mam() { + Ok(mam) => mam.user_info().await.ok().map(|user_info| { + let remaining_buffer_bytes = + ((user_info.uploaded_bytes - user_info.downloaded_bytes - downloading_size) + / config.min_ratio) + .max(0.0) as u64; + let remaining_buffer = mlm_db::Size::from_bytes(remaining_buffer_bytes).to_string(); + SelectedUserInfo { + unsat_count: user_info.unsat.count, + unsat_limit: user_info.unsat.limit, + wedges: user_info.wedges, + bonus: user_info.seedbonus, + remaining_buffer: Some(remaining_buffer), + } + }), + Err(_) => None, + }; + + Ok(user_info) +} + +#[server] +pub async fn apply_selected_action( + action: SelectedBulkAction, + mam_ids: Vec, + unsats: Option, +) -> Result<(), ServerFnError> { + if mam_ids.is_empty() { + return Err(ServerFnError::new("No torrents selected")); + } + + let context = crate::error::get_context()?; + + match action { + SelectedBulkAction::Remove => { + let (_guard, rw) = context.db().rw_async().await.server_err()?; + for mam_id in mam_ids { + let Some(mut torrent) = rw.get().primary::(mam_id).server_err()? + else { + continue; + }; + if torrent.removed_at.is_none() { + torrent.removed_at = Some(Timestamp::now()); + rw.upsert(torrent).server_err()?; + } else { + rw.remove(torrent).server_err()?; + } + } + rw.commit().server_err()?; + } + SelectedBulkAction::Update => { + let (_guard, rw) = context.db().rw_async().await.server_err()?; + for mam_id in mam_ids { + let Some(mut torrent) = rw.get().primary::(mam_id).server_err()? + else { + continue; + }; + torrent.unsat_buffer = Some(unsats.unwrap_or_default()); + torrent.removed_at = None; + rw.upsert(torrent).server_err()?; + } + rw.commit().server_err()?; + } + } + + Ok(()) +} + +#[cfg(feature = "server")] +fn convert_selected_row(t: &SelectedTorrent, default_unsat: u64) -> SelectedRow { + let flags = Flags::from_bitfield(t.meta.flags.map_or(0, |f| f.0)); + let mut flag_values = Vec::new(); + if flags.crude_language == Some(true) { + flag_values.push("language".to_string()); + } + if flags.violence == Some(true) { + flag_values.push("violence".to_string()); + } + if flags.some_explicit == Some(true) { + flag_values.push("some_explicit".to_string()); + } + if flags.explicit == Some(true) { + flag_values.push("explicit".to_string()); + } + if flags.abridged == Some(true) { + flag_values.push("abridged".to_string()); + } + if flags.lgbt == Some(true) { + flag_values.push("lgbt".to_string()); + } + + let (cat_name, cat_id) = if let Some(cat) = &t.meta.cat { + (cat.as_str().to_string(), Some(cat.as_id().to_string())) + } else { + ("N/A".to_string(), None) + }; + + SelectedRow { + mam_id: t.mam_id, + meta: SelectedMeta { + title: t.meta.title.clone(), + media_type: t.meta.media_type.as_str().to_string(), + cat_name, + cat_id, + flags: flag_values, + authors: t.meta.authors.clone(), + narrators: t.meta.narrators.clone(), + series: t + .meta + .series + .iter() + .map(|series| crate::dto::Series { + name: series.name.clone(), + entries: series.entries.to_string(), + }) + .collect(), + language: t.meta.language.map(|l| l.to_str().to_string()), + size: t.meta.size.to_string(), + filetypes: t.meta.filetypes.clone(), + }, + cost: t.cost.as_str().to_string(), + required_unsats: t.unsat_buffer.unwrap_or(default_unsat), + grabber: t.grabber.clone(), + created_at: format_timestamp_db(&t.created_at), + started_at: t.started_at.as_ref().map(format_timestamp_db), + removed_at: t.removed_at.as_ref().map(format_timestamp_db), + } +} diff --git a/mlm_web_dioxus/src/selected/types.rs b/mlm_web_dioxus/src/selected/types.rs new file mode 100644 index 00000000..70b487fc --- /dev/null +++ b/mlm_web_dioxus/src/selected/types.rs @@ -0,0 +1,339 @@ +use serde::{Deserialize, Serialize}; + +use crate::components::PageColumns; + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum SelectedPageSort { + Kind, + Title, + Authors, + Narrators, + Series, + Language, + Size, + Cost, + Buffer, + Grabber, + CreatedAt, + StartedAt, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum SelectedPageFilter { + Kind, + Category, + Flags, + Title, + Author, + Narrator, + Series, + Language, + Filetype, + Cost, + Grabber, +} + +#[derive(Clone, Copy)] +pub enum SelectedColumn { + Category, + Flags, + Authors, + Narrators, + Series, + Language, + Size, + Filetypes, + Grabber, + CreatedAt, + StartedAt, + RemovedAt, +} + +pub const COLUMN_OPTIONS: &[(SelectedColumn, &str)] = &[ + (SelectedColumn::Category, "Category"), + (SelectedColumn::Flags, "Flags"), + (SelectedColumn::Authors, "Authors"), + (SelectedColumn::Narrators, "Narrators"), + (SelectedColumn::Series, "Series"), + (SelectedColumn::Language, "Language"), + (SelectedColumn::Size, "Size"), + (SelectedColumn::Filetypes, "Filetypes"), + (SelectedColumn::Grabber, "Grabber"), + (SelectedColumn::CreatedAt, "Added At"), + (SelectedColumn::StartedAt, "Started At"), + (SelectedColumn::RemovedAt, "Removed At"), +]; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] +pub struct SelectedPageColumns { + pub category: bool, + pub flags: bool, + pub authors: bool, + pub narrators: bool, + pub series: bool, + pub language: bool, + pub size: bool, + pub filetypes: bool, + pub grabber: bool, + pub created_at: bool, + pub started_at: bool, + pub removed_at: bool, +} + +impl Default for SelectedPageColumns { + fn default() -> Self { + Self { + category: false, + flags: false, + authors: true, + narrators: false, + series: true, + language: false, + size: true, + filetypes: true, + grabber: true, + created_at: true, + started_at: true, + removed_at: false, + } + } +} + +impl SelectedPageColumns { + pub fn table_grid_template(self) -> String { + let mut cols = vec!["30px", if self.category { "130px" } else { "84px" }]; + if self.flags { + cols.push("60px"); + } + cols.push("2fr"); + if self.authors { + cols.push("1fr"); + } + if self.narrators { + cols.push("1fr"); + } + if self.series { + cols.push("1fr"); + } + if self.language { + cols.push("100px"); + } + if self.size { + cols.push("81px"); + } + if self.filetypes { + cols.push("100px"); + } + cols.push("80px"); + cols.push("120px"); + if self.grabber { + cols.push("130px"); + } + if self.created_at { + cols.push("157px"); + } + if self.started_at { + cols.push("157px"); + } + if self.removed_at { + cols.push("157px"); + } + cols.join(" ") + } + + pub fn get(self, col: SelectedColumn) -> bool { + match col { + SelectedColumn::Category => self.category, + SelectedColumn::Flags => self.flags, + SelectedColumn::Authors => self.authors, + SelectedColumn::Narrators => self.narrators, + SelectedColumn::Series => self.series, + SelectedColumn::Language => self.language, + SelectedColumn::Size => self.size, + SelectedColumn::Filetypes => self.filetypes, + SelectedColumn::Grabber => self.grabber, + SelectedColumn::CreatedAt => self.created_at, + SelectedColumn::StartedAt => self.started_at, + SelectedColumn::RemovedAt => self.removed_at, + } + } + + pub fn set(&mut self, col: SelectedColumn, enabled: bool) { + match col { + SelectedColumn::Category => self.category = enabled, + SelectedColumn::Flags => self.flags = enabled, + SelectedColumn::Authors => self.authors = enabled, + SelectedColumn::Narrators => self.narrators = enabled, + SelectedColumn::Series => self.series = enabled, + SelectedColumn::Language => self.language = enabled, + SelectedColumn::Size => self.size = enabled, + SelectedColumn::Filetypes => self.filetypes = enabled, + SelectedColumn::Grabber => self.grabber = enabled, + SelectedColumn::CreatedAt => self.created_at = enabled, + SelectedColumn::StartedAt => self.started_at = enabled, + SelectedColumn::RemovedAt => self.removed_at = enabled, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct SelectedMeta { + pub title: String, + pub media_type: String, + pub cat_name: String, + pub cat_id: Option, + pub flags: Vec, + pub authors: Vec, + pub narrators: Vec, + pub series: Vec, + pub language: Option, + pub size: String, + pub filetypes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct SelectedRow { + pub mam_id: u64, + pub meta: SelectedMeta, + pub cost: String, + pub required_unsats: u64, + pub grabber: Option, + pub created_at: String, + pub started_at: Option, + pub removed_at: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct SelectedUserInfo { + pub unsat_count: u64, + pub unsat_limit: u64, + pub wedges: u64, + pub bonus: i64, + pub remaining_buffer: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)] +pub struct SelectedData { + pub torrents: Vec, + pub queued: usize, + pub downloading: usize, +} + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum SelectedBulkAction { + Remove, + Update, +} + +impl SelectedBulkAction { + pub fn label(self) -> &'static str { + match self { + Self::Remove => "unselect for download", + Self::Update => "set required unsats to", + } + } + + pub fn success_label(self) -> &'static str { + match self { + Self::Remove => "Updated selected torrents", + Self::Update => "Updated required unsats", + } + } +} + +pub fn filter_name(filter: SelectedPageFilter) -> &'static str { + match filter { + SelectedPageFilter::Kind => "Type", + SelectedPageFilter::Category => "Category", + SelectedPageFilter::Flags => "Flags", + SelectedPageFilter::Title => "Title", + SelectedPageFilter::Author => "Authors", + SelectedPageFilter::Narrator => "Narrators", + SelectedPageFilter::Series => "Series", + SelectedPageFilter::Language => "Language", + SelectedPageFilter::Filetype => "Filetypes", + SelectedPageFilter::Cost => "Cost", + SelectedPageFilter::Grabber => "Grabber", + } +} + +impl PageColumns for SelectedPageColumns { + fn to_query_value(&self) -> String { + let mut values = Vec::new(); + if self.category { + values.push("category"); + } + if self.flags { + values.push("flags"); + } + if self.authors { + values.push("author"); + } + if self.narrators { + values.push("narrator"); + } + if self.series { + values.push("series"); + } + if self.language { + values.push("language"); + } + if self.size { + values.push("size"); + } + if self.filetypes { + values.push("filetype"); + } + if self.grabber { + values.push("grabber"); + } + if self.created_at { + values.push("created_at"); + } + if self.started_at { + values.push("started_at"); + } + if self.removed_at { + values.push("removed_at"); + } + values.join(",") + } + + fn from_query_value(value: &str) -> Self { + let mut show = SelectedPageColumns { + category: false, + flags: false, + authors: false, + narrators: false, + series: false, + language: false, + size: false, + filetypes: false, + grabber: false, + created_at: false, + started_at: false, + removed_at: false, + }; + for item in value.split(',') { + match item { + "category" => show.category = true, + "flags" => show.flags = true, + "author" => show.authors = true, + "narrator" => show.narrators = true, + "series" => show.series = true, + "language" => show.language = true, + "size" => show.size = true, + "filetype" => show.filetypes = true, + "grabber" => show.grabber = true, + "created_at" => show.created_at = true, + "started_at" => show.started_at = true, + "removed_at" => show.removed_at = true, + _ => {} + } + } + show + } +} diff --git a/mlm_web_dioxus/src/sse.rs b/mlm_web_dioxus/src/sse.rs new file mode 100644 index 00000000..984484ef --- /dev/null +++ b/mlm_web_dioxus/src/sse.rs @@ -0,0 +1,91 @@ +use dioxus::prelude::*; + +pub static STATS_UPDATE_TRIGGER: GlobalSignal = Signal::global(|| 0); +pub static EVENTS_UPDATE_TRIGGER: GlobalSignal = Signal::global(|| 0); +pub static SELECTED_UPDATE_TRIGGER: GlobalSignal = Signal::global(|| 0); +pub static ERRORS_UPDATE_TRIGGER: GlobalSignal = Signal::global(|| 0); +pub static QBIT_PROGRESS: GlobalSignal> = Signal::global(Vec::new); + +pub fn trigger_stats_update() { + *STATS_UPDATE_TRIGGER.write() += 1; +} + +pub fn trigger_events_update() { + *EVENTS_UPDATE_TRIGGER.write() += 1; +} + +pub fn trigger_selected_update() { + *SELECTED_UPDATE_TRIGGER.write() += 1; +} + +pub fn trigger_errors_update() { + *ERRORS_UPDATE_TRIGGER.write() += 1; +} + +pub fn update_qbit_progress(progress: Vec<(u64, u32)>) { + *QBIT_PROGRESS.write() = progress; +} + +/// Connects SSE streams for real-time updates. No-op on the server. +pub fn setup_sse() { + #[cfg(feature = "web")] + { + use std::sync::atomic::{AtomicBool, Ordering}; + use wasm_bindgen::JsCast; + use wasm_bindgen::prelude::*; + use web_sys::EventSource; + + static SSE_INITIALIZED: AtomicBool = AtomicBool::new(false); + if SSE_INITIALIZED.swap(true, Ordering::AcqRel) { + return; + } + + fn connect_sse(url: &'static str, on_message: impl Fn() + 'static) { + spawn(async move { + match EventSource::new(url) { + Ok(es) => { + let callback = + Closure::::new(move |_: web_sys::MessageEvent| { + on_message(); + }); + es.set_onmessage(Some(callback.as_ref().unchecked_ref())); + // Intentionally leak to keep SSE connection alive for app lifetime. + // Browser cleans up on page unload. + std::mem::forget(callback); + std::mem::forget(es); + } + Err(e) => tracing::error!("Failed to create EventSource for {}: {:?}", url, e), + } + }); + } + + fn connect_sse_data(url: &'static str, on_message: impl Fn(String) + 'static) { + spawn(async move { + match EventSource::new(url) { + Ok(es) => { + let callback = + Closure::::new(move |ev: web_sys::MessageEvent| { + if let Some(data) = ev.data().as_string() { + on_message(data); + } + }); + es.set_onmessage(Some(callback.as_ref().unchecked_ref())); + std::mem::forget(callback); + std::mem::forget(es); + } + Err(e) => tracing::error!("Failed to create EventSource for {}: {:?}", url, e), + } + }); + } + + connect_sse("/dioxus-stats-updates", trigger_stats_update); + connect_sse("/dioxus-events-updates", trigger_events_update); + connect_sse("/dioxus-selected-updates", trigger_selected_update); + connect_sse("/dioxus-errors-updates", trigger_errors_update); + connect_sse_data("/dioxus-qbit-progress", |data| { + if let Ok(progress) = serde_json::from_str::>(&data) { + update_qbit_progress(progress); + } + }); + } +} diff --git a/mlm_web_dioxus/src/ssr.rs b/mlm_web_dioxus/src/ssr.rs new file mode 100644 index 00000000..d3ea2cd2 --- /dev/null +++ b/mlm_web_dioxus/src/ssr.rs @@ -0,0 +1,134 @@ +use crate::app::root; +use axum::Extension; +use axum::response::sse::KeepAlive; +use axum::{ + Router, + response::sse::{Event, Sse}, + routing::get, +}; +use dioxus::prelude::*; +use dioxus::server::{DioxusRouterExt, ServeConfig}; +use mlm_core::{Context, ContextExt as _}; +use mlm_db::SelectedTorrent; +use std::convert::Infallible; +use std::time::Duration; +use tokio_stream::StreamExt; +use tokio_stream::wrappers::{IntervalStream, WatchStream}; +use tracing::warn; + +async fn dioxus_stats_updates( + Extension(context): Extension, +) -> Sse>> { + let stream = + WatchStream::new(context.stats.updates()).map(|_time| Ok(Event::default().data("update"))); + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(10))) +} + +async fn dioxus_events_updates( + Extension(context): Extension, +) -> Sse>> { + let stream = WatchStream::new(context.events.event.1.clone()) + .map(|_event| Ok(Event::default().data("update"))); + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(10))) +} + +async fn dioxus_selected_updates( + Extension(context): Extension, +) -> Sse>> { + let stream = + WatchStream::new(context.stats.updates()).map(|_| Ok(Event::default().data("update"))); + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(10))) +} + +async fn dioxus_errors_updates( + Extension(context): Extension, +) -> Sse>> { + let stream = WatchStream::new(context.events.event.1.clone()) + .map(|_| Ok(Event::default().data("update"))); + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(10))) +} + +async fn dioxus_qbit_progress( + Extension(context): Extension, +) -> Sse>> { + let stream = + IntervalStream::new(tokio::time::interval(Duration::from_secs(10))).then(move |_| { + let context = context.clone(); + async move { fetch_qbit_progress(&context).await } + }); + // Always send an event (empty Vec if no downloading torrents) so client can clear stale progress. + let stream = + stream.map(|data| Ok(Event::default().data(data.unwrap_or_else(|| "[]".to_string())))); + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(10))) +} + +/// Polls qBittorrent for download progress of actively-seeding MLM torrents. +/// Returns a JSON-serialized `Vec<(mam_id, progress_pct)>` or `None` if nothing is downloading. +async fn fetch_qbit_progress(context: &Context) -> Option { + let config = context.config().await; + + let downloading: Vec<(u64, String)> = context + .db() + .r_transaction() + .ok()? + .scan() + .primary::() + .ok()? + .all() + .ok()? + .filter_map(Result::ok) + .filter(|t| t.started_at.is_some() && t.removed_at.is_none()) + .filter_map(|t| t.hash.map(|h| (t.mam_id, h))) + .collect(); + + if downloading.is_empty() { + return None; + } + + let hash_to_mam: std::collections::HashMap = + downloading.iter().map(|(id, h)| (h.clone(), *id)).collect(); + let hashes: Vec = downloading.into_iter().map(|(_, h)| h).collect(); + + let mut progress: Vec<(u64, u32)> = Vec::new(); + for qbit_conf in config.qbittorrent.iter() { + let Ok(qbit) = qbit::Api::new_login_username_password( + &qbit_conf.url, + &qbit_conf.username, + &qbit_conf.password, + ) + .await + else { + warn!("Failed logging in to qBittorrent at {}", qbit_conf.url); + continue; + }; + let params = qbit::parameters::TorrentListParams { + hashes: Some(hashes.clone()), + ..Default::default() + }; + let Ok(torrents) = qbit.torrents(Some(params)).await else { + warn!( + "Failed fetching torrent progress from qBittorrent at {}", + qbit_conf.url + ); + continue; + }; + for torrent in torrents { + if let Some(&mam_id) = hash_to_mam.get(&torrent.hash) { + progress.push((mam_id, (torrent.progress * 100.0) as u32)); + } + } + } + + serde_json::to_string(&progress).ok() +} + +pub fn router(ctx: Context) -> Router<()> { + Router::new() + .route("/dioxus-stats-updates", get(dioxus_stats_updates)) + .route("/dioxus-events-updates", get(dioxus_events_updates)) + .route("/dioxus-selected-updates", get(dioxus_selected_updates)) + .route("/dioxus-errors-updates", get(dioxus_errors_updates)) + .route("/dioxus-qbit-progress", get(dioxus_qbit_progress)) + .serve_api_application(ServeConfig::builder(), root) + .layer(Extension(ctx)) +} diff --git a/mlm_web_dioxus/src/torrent_detail/components.rs b/mlm_web_dioxus/src/torrent_detail/components.rs new file mode 100644 index 00000000..9aacb1b2 --- /dev/null +++ b/mlm_web_dioxus/src/torrent_detail/components.rs @@ -0,0 +1,1115 @@ +use super::server_fns::{ + clean_torrent_action, clear_replacement_action, get_metadata_providers, get_other_torrents, + get_qbit_data, get_torrent_detail, match_metadata_action, preview_match_metadata, + refresh_and_relink_action, refresh_metadata_action, relink_torrent_action, + remove_seeding_files_action, remove_torrent_action, set_qbit_category_tags_action, + torrent_start_action, torrent_stop_action, +}; +use super::types::*; +use crate::components::{ + CategoryPills, Details, DownloadButtonMode, DownloadButtons, SearchMetadataFilterItem, + SearchMetadataFilterRow, SearchMetadataKind, SearchTorrentRow, StatusMessage, TorrentIcons, + flag_icon, media_icon_src, search_filter_href, +}; +use crate::events::EventListItem; +use crate::search::SearchTorrent; +use dioxus::prelude::*; +use lucide_dioxus::Tag; + +fn spawn_action( + name: String, + mut loading: Signal, + mut status_msg: Signal>, + on_refresh: EventHandler<()>, + fut: std::pin::Pin>>>, +) { + spawn(async move { + loading.set(true); + status_msg.set(None); + match fut.await { + Ok(_) => { + status_msg.set(Some((format!("{name} succeeded"), false))); + on_refresh.call(()); + loading.set(false); + } + Err(e) => { + status_msg.set(Some((format!("{name} failed: {e}"), true))); + loading.set(false); + } + } + }); +} + +fn series_label(name: &str, entries: &str) -> String { + if entries.is_empty() { + name.to_string() + } else { + format!("{name} {entries}") + } +} + +#[component] +pub fn TorrentDetailPage(id: String) -> Element { + let status_msg = use_signal(|| None::<(String, bool)>); + let mut cached_data = use_signal(|| None::<(TorrentPageData, Vec)>); + + let mut data_res = use_server_future(move || { + let id = id.clone(); + async move { + // tokio::join! isn't available in WASM, so we run the two fetches + // concurrently on the server and sequentially on the client. + #[cfg(feature = "server")] + { + tokio::join!(get_torrent_detail(id.clone()), get_metadata_providers()) + } + #[cfg(not(feature = "server"))] + { + let detail = get_torrent_detail(id.clone()).await; + let providers = get_metadata_providers().await; + (detail, providers) + } + } + })?; + + let current_value = data_res.value(); + let is_loading = data_res.pending(); + let next_cache = { + let value = current_value.read(); + match &*value { + Some((Ok(detail), Ok(providers))) => Some((detail.clone(), providers.clone())), + _ => None, + } + }; + if let Some(next_cache) = next_cache { + let should_update = cached_data.read().as_ref() != Some(&next_cache); + if should_update { + cached_data.set(Some(next_cache)); + } + } + let rendered_data = { + let value = current_value.read(); + match &*value { + Some((Ok(detail), Ok(providers))) => Some((detail.clone(), providers.clone())), + _ => cached_data.read().clone(), + } + }; + let render_error = if cached_data.read().is_none() { + let value = current_value.read(); + if let Some((detail, providers)) = &*value { + detail + .as_ref() + .err() + .or_else(|| providers.as_ref().err()) + .map(|e| e.to_string()) + } else { + None + } + } else { + None + }; + + rsx! { + div { class: "torrent-detail-page", + StatusMessage { status_msg } + if is_loading && cached_data.read().is_some() { + p { class: "loading-indicator", "Refreshing..." } + } + if let Some((detail, providers)) = rendered_data { + match detail { + TorrentPageData::Downloaded(data) => { + rsx! { + TorrentDetailContent { + data, + providers, + status_msg, + on_refresh: move |_| data_res.restart(), + } + } + } + TorrentPageData::MamOnly(data) => { + rsx! { + TorrentMamContent { data, status_msg, on_refresh: move |_| data_res.restart() } + } + } + } + } else if let Some(e) = render_error { + p { class: "error", "Error: {e}" } + } else { + p { "Loading torrent details..." } + } + } + } +} + +#[component] +fn TorrentDetailContent( + data: TorrentDetailData, + providers: Vec, + mut status_msg: Signal>, + on_refresh: EventHandler<()>, +) -> Element { + let TorrentDetailData { + torrent, + events, + replacement_torrent, + replacement_missing, + abs_item_url, + abs_cover_url, + mam_torrent, + mam_meta_diff, + } = data; + + let library_files = torrent + .library_files + .iter() + .map(|file| { + let file_name = file.to_string_lossy().to_string(); + let encoded = urlencoding::encode(&file_name).to_string(); + (file_name, encoded) + }) + .collect::>(); + + let filetypes_text = torrent.filetypes.join(", "); + let author_filters = torrent + .authors + .iter() + .map(|author| SearchMetadataFilterItem { + label: author.clone(), + href: search_filter_href("author", author, ""), + }) + .collect::>(); + let narrator_filters = torrent + .narrators + .iter() + .map(|narrator| SearchMetadataFilterItem { + label: narrator.clone(), + href: search_filter_href("narrator", narrator, ""), + }) + .collect::>(); + let series_filters = torrent + .series + .iter() + .map(|series| SearchMetadataFilterItem { + label: series_label(&series.name, &series.entries), + href: search_filter_href("series", &series.name, "series"), + }) + .collect::>(); + + rsx! { + div { class: "torrent-detail-grid", + div { class: "torrent-side", + if let Some(abs_cover_url) = abs_cover_url { + div { class: "abs-cover", + img { + src: "{abs_cover_url}", + alt: "ABS cover for {torrent.title}", + loading: "lazy", + } + } + } + div { class: "pill", "{torrent.media_type}" } + + div { class: "category-row", + if let Some(src) = media_icon_src(torrent.mediatype_id, torrent.main_cat_id) { + img { + class: "media-icon", + src: "{src}", + alt: "{torrent.media_type}", + title: "{torrent.media_type}", + } + } else { + span { class: "faint", "{torrent.media_type}" } + } + CategoryPills { + categories: torrent.categories.clone(), + old_category: torrent.old_category.clone(), + } + TorrentIcons { + vip: mam_torrent.as_ref().is_some_and(|m| m.vip), + personal_freeleech: mam_torrent.as_ref().is_some_and(|m| m.personal_freeleech), + free: mam_torrent.as_ref().is_some_and(|m| m.free), + flags: torrent.flags.clone(), + } + } + + h3 { "Metadata" } + dl { class: "metadata-table", + if let Some(lang) = &torrent.language { + dt { "Language" } + dd { "{lang}" } + } + if let Some(ed) = &torrent.edition { + dt { "Edition" } + dd { "{ed}" } + } + if let Some(mam_id) = torrent.mam_id { + dt { "MaM ID" } + dd { + a { + href: "https://www.myanonamouse.net/t/{mam_id}", + target: "_blank", + "{mam_id}" + } + } + } + dt { "Size" } + dd { "{torrent.size}" } + dt { "Files" } + dd { "{torrent.num_files}" } + if !torrent.filetypes.is_empty() { + dt { "File Types" } + dd { "{filetypes_text}" } + } + dt { "Uploaded" } + dd { "{torrent.uploaded_at}" } + dt { "Source" } + dd { "{torrent.source}" } + if let Some(vip) = &torrent.vip_status { + dt { "VIP" } + dd { "{vip}" } + } + if let Some(path) = &torrent.library_path { + dt { "Library Path" } + dd { "{path.display()}" } + } + if let Some(linker) = &torrent.linker { + dt { "Linker" } + dd { "{linker}" } + } + if let Some(cat) = &torrent.category { + dt { "Category" } + dd { "{cat}" } + } + if let Some(status) = &torrent.client_status { + dt { "Client Status" } + dd { "{status}" } + } + } + } + + div { class: "torrent-main", + h1 { "{torrent.title}" } + if let Some(replacement) = replacement_torrent { + div { class: "warn", + strong { "Replaced with: " } + a { href: "/dioxus/torrents/{replacement.id}", "{replacement.title}" } + } + } + if replacement_missing { + div { class: "warn", + "This torrent had a stale replacement link and it was cleared." + } + } + + SearchMetadataFilterRow { kind: SearchMetadataKind::Authors, items: author_filters } + SearchMetadataFilterRow { + kind: SearchMetadataKind::Narrators, + items: narrator_filters, + } + SearchMetadataFilterRow { kind: SearchMetadataKind::Series, items: series_filters } + if let Some(mam) = mam_torrent.clone() { + if !mam.tags.is_empty() { + p { class: "icon-row", + span { title: "Tags", Tag {} } + "{mam.tags}" + } + } + } + if !torrent.tags.is_empty() { + div { + strong { "Tags: " } + for tag in &torrent.tags { + span { class: "pill", "{tag}" } + } + } + } + div { style: "display:flex; flex-wrap:wrap; gap:0.5em; margin:0.6em 0;", + a { + class: "btn", + href: "/dioxus/torrents/{torrent.id}/edit", + "Edit Metadata" + } + if let Some(abs_url) = abs_item_url { + a { + class: "btn", + href: "{abs_url}", + target: "_blank", + "Open in ABS" + } + } + if let Some(mam_id) = torrent.mam_id { + a { + class: "btn", + href: "https://www.myanonamouse.net/t/{mam_id}", + target: "_blank", + "Open in MaM" + } + } + if let Some(goodreads_id) = &torrent.goodreads_id { + a { + class: "btn", + href: "https://www.goodreads.com/book/show/{goodreads_id}", + target: "_blank", + "Open in Goodreads" + } + } + } + + TorrentActions { + torrent_id: torrent.id.clone(), + providers, + has_replacement: torrent.replaced_with.is_some(), + status_msg, + on_refresh, + } + } + + div { class: "torrent-description", + h3 { "Description" } + div { dangerous_inner_html: "{torrent.description_html}" } + + if let Some(mam) = mam_torrent.clone() { + if let Some(description_html) = mam.description_html { + Details { label: "MaM Description", + div { dangerous_inner_html: "{description_html}" } + } + } + } + + if !mam_meta_diff.is_empty() { + h3 { "MaM Metadata Differences" } + ul { + for field in mam_meta_diff { + li { + strong { "{field.field}" } + ": {field.to}" + } + } + } + } + + Details { label: "Event History", + for event in events { + div { class: "event-item", + EventListItem { + event, + torrent: None, + replacement: None, + show_created_at: true, + } + } + } + } + } + + div { class: "torrent-below", + if !library_files.is_empty() { + Details { label: "Library Files ({library_files.len()})", + ul { + for file in &library_files { + li { + a { + href: "/torrents/{torrent.id}/{file.1}", + target: "_blank", + "{file.0}" + } + } + } + } + } + } + + QbitSection { + torrent_id: torrent.id.clone(), + status_msg, + on_refresh, + } + OtherTorrentsSection { id: torrent.id.clone(), status_msg, on_refresh } + } + } + } +} + +#[component] +fn TorrentMamContent( + data: TorrentMamData, + mut status_msg: Signal>, + on_refresh: EventHandler<()>, +) -> Element { + let torrent = data.meta; + let mam = data.mam_torrent; + + let filetypes_text = torrent.filetypes.join(", "); + let author_filters = torrent + .authors + .iter() + .map(|author| SearchMetadataFilterItem { + label: author.clone(), + href: search_filter_href("author", author, ""), + }) + .collect::>(); + let narrator_filters = torrent + .narrators + .iter() + .map(|narrator| SearchMetadataFilterItem { + label: narrator.clone(), + href: search_filter_href("narrator", narrator, ""), + }) + .collect::>(); + let series_filters = torrent + .series + .iter() + .map(|series| SearchMetadataFilterItem { + label: series_label(&series.name, &series.entries), + href: search_filter_href("series", &series.name, "series"), + }) + .collect::>(); + + rsx! { + div { class: "torrent-detail-grid", + div { class: "torrent-side", + div { class: "category-row", + if let Some(src) = media_icon_src(torrent.mediatype_id, torrent.main_cat_id) { + img { + class: "media-icon", + src: "{src}", + alt: "{torrent.media_type}", + title: "{torrent.media_type}", + } + } else { + span { class: "faint", "{torrent.media_type}" } + } + CategoryPills { + categories: torrent.categories.clone(), + old_category: torrent.old_category.clone(), + } + TorrentIcons { + vip: mam.vip, + personal_freeleech: mam.personal_freeleech, + free: mam.free, + flags: torrent.flags.clone(), + } + } + dl { class: "metadata-table", + if !torrent.flags.is_empty() { + dt { "Flags" } + dd { + for flag in &torrent.flags { + if let Some((src, title)) = flag_icon(flag) { + img { + class: "flag", + src: "{src}", + alt: "{title}", + title: "{title}", + } + } + } + } + } + dt { "MaM ID" } + dd { + a { + href: "https://www.myanonamouse.net/t/{mam.mam_id}", + target: "_blank", + "{mam.mam_id}" + } + } + dt { "Uploader" } + dd { "{mam.owner_name}" } + dt { "Size" } + dd { "{torrent.size}" } + dt { "Files" } + dd { "{torrent.num_files}" } + if !torrent.filetypes.is_empty() { + dt { "File Types" } + dd { "{filetypes_text}" } + } + dt { "Uploaded" } + dd { "{torrent.uploaded_at}" } + } + } + div { class: "torrent-main", + h1 { "{torrent.title}" } + if let Some(ed) = &torrent.edition { + p { "{ed}" } + } + SearchMetadataFilterRow { kind: SearchMetadataKind::Authors, items: author_filters } + SearchMetadataFilterRow { + kind: SearchMetadataKind::Narrators, + items: narrator_filters, + } + SearchMetadataFilterRow { kind: SearchMetadataKind::Series, items: series_filters } + if !mam.tags.is_empty() { + p { class: "icon-row", + span { title: "Tags", Tag {} } + "{mam.tags}" + } + } + div { + class: "row", + style: "display:flex; flex-wrap:wrap; gap:0.5em; margin:0.6em 0;", + if let Some(goodreads_id) = &torrent.goodreads_id { + a { + class: "btn", + href: "https://www.goodreads.com/book/show/{goodreads_id}", + target: "_blank", + "Open in Goodreads" + } + } + } + div { style: "margin-top:0.8em;", + DownloadButtons { + mam_id: mam.mam_id, + is_vip: mam.vip, + is_free: mam.free, + is_personal_freeleech: mam.personal_freeleech, + can_wedge: true, + disabled: false, + mode: DownloadButtonMode::Full, + on_status: move |(msg, is_error)| { + status_msg.set(Some((msg, is_error))); + }, + on_refresh: move |_| { + on_refresh.call(()); + }, + } + } + } + div { class: "torrent-description", + if let Some(description_html) = mam.description_html { + h3 { "Description" } + div { dangerous_inner_html: "{description_html}" } + } + } + div { class: "torrent-below", + OtherTorrentsSection { id: torrent.id.clone(), status_msg, on_refresh } + } + } + } +} + +#[component] +fn OtherTorrentsSection( + id: String, + mut status_msg: Signal>, + on_refresh: EventHandler<()>, +) -> Element { + let mut data: Signal, ServerFnError>>> = use_signal(|| None); + let mut refresh_trigger = use_signal(|| 0u32); + + use_effect(move || { + let _ = *refresh_trigger.read(); + let id = id.clone(); + data.set(None); + spawn(async move { + data.set(Some(get_other_torrents(id).await)); + }); + }); + + let inner_refresh = move |_| { + *refresh_trigger.write() += 1; + on_refresh.call(()); + }; + + rsx! { + div { style: "margin-top:1em;", + h3 { "Other Torrents" } + match data.read().clone() { + None => rsx! { + p { class: "loading-indicator", "Loading other torrents..." } + }, + Some(Err(e)) => rsx! { + p { class: "error", "Error loading other torrents: {e}" } + }, + Some(Ok(torrents)) if torrents.is_empty() => rsx! { + p { + i { "No other torrents found for this book" } + } + }, + Some(Ok(torrents)) => rsx! { + div { class: "Torrents", + for torrent in torrents { + SearchTorrentRow { torrent, status_msg, on_refresh: inner_refresh } + } + } + }, + } + } + } +} + +#[component] +fn TorrentActions( + torrent_id: String, + providers: Vec, + has_replacement: bool, + mut status_msg: Signal>, + on_refresh: EventHandler<()>, +) -> Element { + let loading = use_signal(|| false); + let mut dialog_open = use_signal(|| false); + + let handle_action = move |name: String, + fut: std::pin::Pin< + Box>>, + >| { + spawn_action(name, loading, status_msg, on_refresh, fut); + }; + + rsx! { + div { class: "torrent-actions-widget", + h3 { "Actions" } + + div { class: "torrent-actions-row", + button { + class: "btn", + disabled: *loading.read(), + onclick: move |_| dialog_open.set(true), + "Match Metadata" + } + button { + class: "btn", + disabled: *loading.read(), + onclick: { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + handle_action("Clean".to_string(), Box::pin(clean_torrent_action(id))); + } + }, + "Clean" + } + button { + class: "btn", + disabled: *loading.read(), + onclick: { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + handle_action("Refresh".to_string(), Box::pin(refresh_metadata_action(id))); + } + }, + "Refresh" + } + button { + class: "btn", + disabled: *loading.read(), + onclick: { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + handle_action("Relink".to_string(), Box::pin(relink_torrent_action(id))); + } + }, + "Relink" + } + button { + class: "btn", + disabled: *loading.read(), + onclick: { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + handle_action( + "Refresh & Relink".to_string(), + Box::pin(refresh_and_relink_action(id)), + ); + } + }, + "Refresh & Relink" + } + if has_replacement { + button { + class: "btn", + disabled: *loading.read(), + onclick: { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + handle_action( + "Clear Replacement".to_string(), + Box::pin(clear_replacement_action(id)), + ); + } + }, + "Clear Replacement" + } + } + button { + class: "btn danger", + disabled: *loading.read(), + onclick: { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + handle_action("Remove".to_string(), Box::pin(remove_torrent_action(id))); + } + }, + "Remove" + } + } + } + + if *dialog_open.read() { + MatchDialog { + torrent_id: torrent_id.clone(), + providers: providers.clone(), + status_msg, + on_close: move |_| dialog_open.set(false), + on_refresh, + } + } + } +} + +#[component] +fn MatchDialog( + torrent_id: String, + providers: Vec, + mut status_msg: Signal>, + on_close: EventHandler<()>, + on_refresh: EventHandler<()>, +) -> Element { + let mut selected_provider = use_signal(|| providers.first().cloned().unwrap_or_default()); + let loading = use_signal(|| false); + + let preview_id = torrent_id.clone(); + let preview = use_resource(move || { + let id = preview_id.clone(); + let provider = selected_provider.read().clone(); + async move { preview_match_metadata(id, provider).await } + }); + + let do_match = { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + let provider = selected_provider.read().clone(); + spawn_action( + "Match Metadata".to_string(), + loading, + status_msg, + EventHandler::new(move |_| { + on_close.call(()); + on_refresh.call(()); + }), + Box::pin(match_metadata_action(id, provider)), + ); + } + }; + + rsx! { + div { + class: "dialog-overlay", + onclick: move |_| { + if !*loading.read() { + on_close.call(()); + } + }, + } + div { class: "dialog-box", + h3 { "Match Metadata" } + + div { class: "dialog-field", + label { "Provider" } + select { + disabled: *loading.read(), + onchange: move |ev| selected_provider.set(ev.value()), + for p in providers { + option { value: "{p}", "{p}" } + } + } + } + + div { class: "dialog-preview", + match &*preview.read() { + None => rsx! { + p { class: "loading-indicator", "Fetching preview..." } + }, + Some(Err(e)) => rsx! { + p { class: "error", "Preview failed: {e}" } + }, + Some(Ok(diffs)) if diffs.is_empty() => rsx! { + p { + i { "No changes would be made." } + } + }, + Some(Ok(diffs)) => rsx! { + table { class: "match-diff-table", + thead { + tr { + th { "Field" } + th { "Current" } + th { "New" } + } + } + tbody { + for diff in diffs.clone() { + tr { + td { "{diff.field}" } + td { class: "diff-from", "{diff.from}" } + td { class: "diff-to", "{diff.to}" } + } + } + } + } + }, + } + } + + div { class: "dialog-actions", + button { + class: "btn", + disabled: *loading.read() || preview.read().is_none(), + onclick: do_match, + if *loading.read() { + "Saving..." + } else { + "Save" + } + } + button { + class: "btn", + disabled: *loading.read(), + onclick: move |_| on_close.call(()), + "Cancel" + } + } + } + } +} + +#[component] +fn QbitSection( + torrent_id: String, + mut status_msg: Signal>, + on_refresh: EventHandler<()>, +) -> Element { + let mut data: Signal, ServerFnError>>> = use_signal(|| None); + let mut refresh_trigger = use_signal(|| 0u32); + let id_for_effect = torrent_id.clone(); + + use_effect(move || { + let _ = *refresh_trigger.read(); + let id = id_for_effect.clone(); + data.set(None); + spawn(async move { + data.set(Some(get_qbit_data(id).await)); + }); + }); + + let on_qbit_refresh = move |_| { + *refresh_trigger.write() += 1; + on_refresh.call(()); + }; + + match data.read().clone() { + None => rsx! { + p { class: "loading-indicator", "Loading qBittorrent data..." } + }, + Some(Err(_)) | Some(Ok(None)) => rsx! {}, + Some(Ok(Some(qbit))) => rsx! { + QbitControls { + torrent_id, + qbit, + status_msg, + on_refresh: on_qbit_refresh, + } + }, + } +} + +#[component] +fn QbitControls( + torrent_id: String, + qbit: QbitData, + mut status_msg: Signal>, + on_refresh: EventHandler<()>, +) -> Element { + let mut selected_category = use_signal(|| qbit.torrent_category.clone()); + let mut selected_tags = use_signal(|| qbit.torrent_tags.clone()); + let loading = use_signal(|| false); + let qbit_files = qbit + .qbit_files + .iter() + .map(|file| { + let encoded = urlencoding::encode(file).to_string(); + (file.clone(), encoded) + }) + .collect::>(); + + let is_paused = qbit.is_paused; + + let handle_qbit_action = move |name: String, + fut: std::pin::Pin< + Box>>, + >| { + spawn_action(name, loading, status_msg, on_refresh, fut); + }; + + rsx! { + div { style: "margin-top: 1em; padding: 1em; background: var(--above); border-radius: 4px;", + h3 { "qBittorrent" } + + dl { class: "metadata-table", + dt { "State" } + dd { "{qbit.torrent_state}" } + dt { "Uploaded" } + dd { "{qbit.uploaded}" } + } + + if let Some(path) = qbit.wanted_path { + div { style: "margin: 1em 0; padding: 0.5em; background: var(--bg); border-radius: 4px;", + p { + strong { "⚠️ Torrent should be in: " } + "{path.display()}" + } + button { + class: "btn", + disabled: *loading.read(), + onclick: { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + handle_qbit_action( + "Relink to Correct Path".to_string(), + Box::pin(relink_torrent_action(id)), + ); + } + }, + "Relink to Correct Path" + } + } + } + if qbit.no_longer_wanted { + div { style: "margin: 1em 0; padding: 0.5em; background: var(--bg); border-radius: 4px;", + p { + strong { "⚠️ " } + "No longer wanted in library" + } + } + } + + div { style: "display: flex; gap: 0.5em; margin: 1em 0;", + if is_paused { + button { + class: "btn", + disabled: *loading.read(), + onclick: { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + handle_qbit_action("Start".to_string(), Box::pin(torrent_start_action(id))); + } + }, + "Start" + } + } else { + button { + class: "btn", + disabled: *loading.read(), + onclick: { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + handle_qbit_action("Stop".to_string(), Box::pin(torrent_stop_action(id))); + } + }, + "Stop" + } + } + button { + class: "btn", + disabled: *loading.read(), + onclick: { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + handle_qbit_action( + "Remove Seeding-only Files".to_string(), + Box::pin(remove_seeding_files_action(id)), + ); + } + }, + "Remove Seeding-only Files" + } + } + + div { class: "option_group", + "Category: " + select { + disabled: *loading.read(), + onchange: move |ev| selected_category.set(ev.value()), + for cat in &qbit.categories { + option { + value: "{cat.name}", + selected: cat.name == qbit.torrent_category, + "{cat.name}" + } + } + } + } + + div { class: "option_group", style: "margin-top: 0.5em;", + "Tags: " + for tag in &qbit.tags { + label { + input { + r#type: "checkbox", + disabled: *loading.read(), + checked: selected_tags.read().contains(tag), + onchange: { + let tag = tag.clone(); + move |ev| { + if ev.value() == "true" { + if !selected_tags.read().contains(&tag) { + selected_tags.write().push(tag.clone()); + } + } else { + selected_tags.write().retain(|t| t != &tag); + } + } + }, + } + "{tag}" + } + } + } + + button { + class: "btn", + style: "margin-top: 1em;", + disabled: *loading.read(), + onclick: { + let torrent_id = torrent_id.clone(); + move |_| { + let id = torrent_id.clone(); + let cat = selected_category.read().clone(); + let tags = selected_tags.read().clone(); + handle_qbit_action( + "Save Category & Tags".to_string(), + Box::pin(set_qbit_category_tags_action(id, cat, tags)), + ); + } + }, + "Save Category & Tags" + } + + if !qbit_files.is_empty() { + Details { label: "qBittorrent Files ({qbit_files.len()})", + ul { + for file in &qbit_files { + li { + a { + href: "/torrents/{torrent_id}/{file.1}", + target: "_blank", + "{file.0}" + } + } + } + } + } + } + } + } +} diff --git a/mlm_web_dioxus/src/torrent_detail/mod.rs b/mlm_web_dioxus/src/torrent_detail/mod.rs new file mode 100644 index 00000000..9cc559b4 --- /dev/null +++ b/mlm_web_dioxus/src/torrent_detail/mod.rs @@ -0,0 +1,7 @@ +mod components; +mod server_fns; +mod types; + +pub use components::TorrentDetailPage; +pub use server_fns::*; +pub use types::*; diff --git a/mlm_web_dioxus/src/torrent_detail/server_fns.rs b/mlm_web_dioxus/src/torrent_detail/server_fns.rs new file mode 100644 index 00000000..9d48a8e4 --- /dev/null +++ b/mlm_web_dioxus/src/torrent_detail/server_fns.rs @@ -0,0 +1,943 @@ +#[cfg(feature = "server")] +use crate::dto::{Event as DbEventDto, Series, TorrentMetaDiff, convert_event_type}; +#[cfg(feature = "server")] +use crate::error::{IntoServerFnError, OptionIntoServerFnError}; +use crate::search::SearchTorrent; +#[cfg(feature = "server")] +use crate::search::map_search_torrent as map_mam_torrent; +#[cfg(feature = "server")] +use crate::utils::format_timestamp_db; +use dioxus::prelude::*; + +#[cfg(feature = "server")] +use mlm_core::{ + Context, ContextExt, Event as DbEvent, EventKey, Torrent as DbTorrent, + metadata::mam_meta::match_meta, +}; +#[cfg(feature = "server")] +use mlm_db::DatabaseExt; +#[cfg(feature = "server")] +use mlm_db::ids; +#[cfg(feature = "server")] +use qbit::parameters::TorrentState; +#[cfg(feature = "server")] +use tokio::fs; + +#[cfg(feature = "server")] +fn format_qbit_state(state: &qbit::parameters::TorrentState) -> String { + use qbit::parameters::TorrentState; + match state { + TorrentState::Downloading => "Downloading".to_string(), + TorrentState::Uploading => "Seeding".to_string(), + TorrentState::StoppedDownloading => "Stopped (Downloading)".to_string(), + TorrentState::StoppedUploading => "Stopped (Seeding)".to_string(), + TorrentState::QueuedDownloading => "Queued (Downloading)".to_string(), + TorrentState::QueuedUploading => "Queued (Seeding)".to_string(), + TorrentState::StalledDownloading => "Stalled (Downloading)".to_string(), + TorrentState::StalledUploading => "Stalled (Seeding)".to_string(), + TorrentState::CheckingDownloading => "Checking (Downloading)".to_string(), + TorrentState::CheckingUploading => "Checking (Seeding)".to_string(), + TorrentState::CheckingResumeData => "Checking Resume Data".to_string(), + TorrentState::ForcedDownloading => "Forced Downloading".to_string(), + TorrentState::ForcedUploading => "Forced Seeding".to_string(), + TorrentState::Allocating => "Allocating".to_string(), + TorrentState::Error => "Error".to_string(), + TorrentState::MissingFiles => "Missing Files".to_string(), + TorrentState::Moving => "Moving".to_string(), + TorrentState::MetadataDownloading => "Metadata Downloading".to_string(), + TorrentState::ForcedMetadataDownloading => "Forced Metadata Downloading".to_string(), + TorrentState::Unknown => "Unknown".to_string(), + } +} + +#[cfg(feature = "server")] +fn map_event(e: DbEvent) -> DbEventDto { + DbEventDto { + id: e.id.0.to_string(), + created_at: format_timestamp_db(&e.created_at), + event: convert_event_type(&e.event), + } +} + +#[cfg(feature = "server")] +fn torrent_info_from_meta( + meta: &mlm_db::TorrentMeta, + id: String, + mam_id: Option, +) -> super::types::TorrentInfo { + let goodreads_id = meta.ids.get(ids::GOODREADS).cloned(); + let flags = mlm_db::Flags::from_bitfield(meta.flags.map_or(0, |f| f.0)); + let flag_values = crate::utils::flags_to_strings(&flags); + + super::types::TorrentInfo { + id, + title: meta.title.clone(), + edition: meta.edition.as_ref().map(|(ed, _)| ed.clone()), + authors: meta.authors.clone(), + narrators: meta.narrators.clone(), + series: meta + .series + .iter() + .map(|s| Series { + name: s.name.clone(), + entries: s.entries.to_string(), + }) + .collect(), + tags: meta.tags.clone(), + description_html: crate::dto::sanitize_html(&meta.description), + media_type: meta.media_type.to_string(), + mediatype_id: meta.media_type.as_id(), + main_cat: meta.main_cat.map(|c| c.to_string()), + main_cat_id: meta.main_cat.map(|c| c.as_id()).unwrap_or(0), + language: meta.language.as_ref().map(|l| l.to_string()), + filetypes: meta.filetypes.iter().map(|f| f.to_string()).collect(), + size: meta.size.to_string(), + num_files: meta.num_files, + categories: meta + .categories + .iter() + .map(|c| c.as_str().to_string()) + .collect(), + old_category: meta.cat.as_ref().map(|c| c.to_string()), + flags: flag_values, + library_path: None, + library_files: vec![], + linker: None, + category: None, + mam_id, + vip_status: meta.vip_status.as_ref().map(|v| v.to_string()), + source: format!("{:?}", meta.source), + uploaded_at: format_timestamp_db(&meta.uploaded_at), + client_status: None, + replaced_with: None, + goodreads_id, + } +} + +#[cfg(feature = "server")] +async fn other_torrents_data( + context: &Context, + meta: &mlm_db::TorrentMeta, +) -> Result, ServerFnError> { + use itertools::Itertools; + use mlm_mam::{ + enums::SearchIn, + search::{SearchFields, SearchQuery, Tor}, + }; + + let mam = context.mam().server_err()?; + let config = context.config().await; + let title = meta + .title + .split_once(':') + .map_or(meta.title.as_str(), |(base, _)| base) + .trim() + .to_string(); + let text = if meta.authors.is_empty() { + title + } else { + format!( + "{} ({})", + title, + meta.authors + .iter() + .map(|author| format!("\"{author}\"")) + .join(" | ") + ) + }; + + let result = mam + .search(&SearchQuery { + fields: SearchFields { + media_info: true, + ..Default::default() + }, + tor: Tor { + text, + srch_in: vec![SearchIn::Title, SearchIn::Author], + ..Default::default() + }, + ..Default::default() + }) + .await + .server_err()?; + + let r = context.db().r_transaction().server_err()?; + + result + .data + .into_iter() + .filter(|t| Some(t.id) != meta.mam_id()) + .map(|mam_torrent| { + let meta = mam_torrent.as_meta().server_err()?; + let torrent = r + .get() + .secondary::(mlm_db::TorrentKey::mam_id, Some(mam_torrent.id)) + .server_err()?; + let selected = r + .get() + .primary::(mam_torrent.id) + .server_err()?; + Ok(map_mam_torrent( + mam_torrent, + &meta, + config.search.clone(), + torrent.is_some(), + selected.is_some(), + )) + }) + .collect() +} + +#[cfg(feature = "server")] +async fn read_library_files( + path: Option<&std::path::Path>, +) -> Result, ServerFnError> { + let Some(path) = path else { + return Ok(Vec::new()); + }; + + let mut entries = match fs::read_dir(path).await { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(ServerFnError::new(err.to_string())), + }; + + let mut files = Vec::new(); + while let Some(entry) = entries.next_entry().await.server_err()? { + files.push(entry.path()); + } + Ok(files) +} + +#[cfg(feature = "server")] +async fn get_downloaded_torrent_detail( + context: &Context, + torrent_id: String, +) -> Result { + use mlm_core::audiobookshelf::Abs; + use time::UtcDateTime; + + let config = context.config().await; + let db = context.db(); + let mut torrent = db + .r_transaction() + .server_err()? + .get() + .primary::(torrent_id.clone()) + .server_err()? + .ok_or_server_err("Torrent not found")?; + + let replacement_torrent = torrent + .replaced_with + .as_ref() + .map(|(id, _)| { + db.r_transaction() + .server_err()? + .get() + .primary::(id.clone()) + .server_err() + }) + .transpose()? + .flatten(); + let replacement_missing = replacement_torrent.is_none() && torrent.replaced_with.is_some(); + + let mut mam_torrent = None; + let mut mam_meta_diff = vec![]; + if let Some(mam_id) = torrent.mam_id + && let Ok(mam) = context.mam() + { + mam_torrent = mam.get_torrent_info_by_id(mam_id).await.server_err()?; + if let Some(ref mam_torrent_data) = mam_torrent { + let mut mam_meta = mam_torrent_data.as_meta().server_err()?; + let mut ids = torrent.meta.ids.clone(); + ids.append(&mut mam_meta.ids); + mam_meta.ids = ids; + + if match torrent.meta.uploaded_at.as_ref() { + None => true, + Some(uploaded_at) => uploaded_at.0 == UtcDateTime::UNIX_EPOCH, + } { + let (_guard, rw) = db.rw_async().await.server_err()?; + torrent.meta.uploaded_at = mam_meta.uploaded_at; + rw.upsert(torrent.clone()).server_err()?; + rw.commit().server_err()?; + } + + if torrent.meta != mam_meta { + mam_meta_diff = torrent + .meta + .diff(&mam_meta) + .into_iter() + .map(|f| TorrentMetaDiff { + field: f.field.to_string(), + from: f.from, + to: f.to, + }) + .collect(); + } + } + } + + let library_files = read_library_files(torrent.library_path.as_deref()).await?; + + let mut torrent_info = + torrent_info_from_meta(&torrent.meta, torrent.id.clone(), torrent.mam_id); + torrent_info.library_path = torrent.library_path.clone(); + torrent_info.library_files = library_files; + torrent_info.linker = torrent.linker.clone(); + torrent_info.category = torrent.category.clone(); + torrent_info.client_status = torrent.client_status.as_ref().map(|s| match s { + mlm_db::ClientStatus::NotInClient => "Not in Client".to_string(), + mlm_db::ClientStatus::RemovedFromTracker => "Removed from Tracker".to_string(), + }); + torrent_info.replaced_with = torrent.replaced_with.as_ref().map(|(id, _)| id.clone()); + + let mut events_data: Vec = db + .r_transaction() + .server_err()? + .scan() + .secondary(EventKey::torrent_id) + .server_err()? + .range(Some(torrent.id.clone())..=Some(torrent.id.clone())) + .server_err()? + .map(|event| event.map(map_event)) + .collect::, _>>() + .server_err()?; + events_data.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + let (abs_item_url, abs_cover_url) = if let Some(abs_cfg) = config.audiobookshelf.as_ref() { + let abs = Abs::new(abs_cfg).server_err()?; + if let Some(book) = abs.get_book(&torrent).await.server_err()? { + ( + Some(format!("{}/audiobookshelf/item/{}", abs_cfg.url, book.id)), + Some(format!( + "{}/audiobookshelf/api/items/{}/cover", + abs_cfg.url, book.id + )), + ) + } else { + (None, None) + } + } else { + (None, None) + }; + + let r = db.r_transaction().server_err()?; + Ok(super::types::TorrentDetailData { + torrent: torrent_info, + events: events_data, + replacement_torrent: replacement_torrent.map(|replacement| { + super::types::ReplacementTorrentInfo { + id: replacement.id, + title: replacement.meta.title, + size: replacement.meta.size.to_string(), + filetypes: replacement.meta.filetypes, + library_path: replacement.library_path, + } + }), + replacement_missing, + abs_item_url, + abs_cover_url, + mam_torrent: mam_torrent.as_ref().and_then(|mam_torrent| { + let meta = mam_torrent.as_meta().ok()?; + Some(map_mam_torrent( + mam_torrent.clone(), + &meta, + config.search.clone(), + true, // it's downloaded + r.get() + .primary::(mam_torrent.id) + .server_err() + .ok() + .flatten() + .is_some(), + )) + }), + mam_meta_diff, + }) +} + +#[server] +pub async fn get_torrent_detail( + id: String, +) -> Result { + let context = crate::error::get_context()?; + + if context + .db() + .r_transaction() + .server_err()? + .get() + .primary::(id.clone()) + .server_err()? + .is_some() + { + return get_downloaded_torrent_detail(&context, id) + .await + .map(super::types::TorrentPageData::Downloaded); + } + + if let Ok(mam_id) = id.parse::() + && let Ok(mam) = context.mam() + { + if let Some(torrent) = context + .db() + .r_transaction() + .server_err()? + .get() + .secondary::(mlm_db::TorrentKey::mam_id, Some(mam_id)) + .server_err()? + { + return get_downloaded_torrent_detail(&context, torrent.id) + .await + .map(super::types::TorrentPageData::Downloaded); + } + + let mam_torrent = mam + .get_torrent_info_by_id(mam_id) + .await + .server_err()? + .ok_or_server_err("Torrent not found")?; + let meta = mam_torrent.as_meta().server_err()?; + let search_config = context.config().await.search.clone(); + let selected = context + .db() + .r_transaction() + .server_err()? + .get() + .primary::(mam_id) + .server_err()? + .is_some(); + + return Ok(super::types::TorrentPageData::MamOnly( + super::types::TorrentMamData { + mam_torrent: map_mam_torrent(mam_torrent, &meta, search_config, false, selected), + meta: torrent_info_from_meta(&meta, mam_id.to_string(), Some(mam_id)), + }, + )); + } + + Err(ServerFnError::new("Torrent not found")) +} + +#[server] +pub async fn select_torrent_action(mam_id: u64, wedge: bool) -> Result<(), ServerFnError> { + use mlm_db::{SelectedTorrent, Timestamp}; + + let context = crate::error::get_context()?; + + let mam = context.mam().server_err()?; + let torrent = mam + .get_torrent_info_by_id(mam_id) + .await + .server_err()? + .ok_or_server_err("Torrent not found")?; + + let meta = torrent.as_meta().server_err()?; + let config = context.config().await; + + let tags: Vec<_> = config + .tags + .iter() + .filter(|t| t.filter.matches(&torrent)) + .collect(); + let category = tags.iter().find_map(|t| t.category.clone()); + let tags: Vec = tags.iter().flat_map(|t| t.tags.clone()).collect(); + let cost = if torrent.vip { + mlm_db::TorrentCost::Vip + } else if torrent.personal_freeleech { + mlm_db::TorrentCost::PersonalFreeleech + } else if torrent.free { + mlm_db::TorrentCost::GlobalFreeleech + } else if wedge { + mlm_db::TorrentCost::UseWedge + } else { + mlm_db::TorrentCost::Ratio + }; + + let (_guard, rw) = context.db().rw_async().await.server_err()?; + rw.insert(SelectedTorrent { + mam_id: torrent.id, + hash: None, + dl_link: torrent + .dl + .clone() + .ok_or_server_err(&format!("No dl field for torrent {}", torrent.id))?, + unsat_buffer: None, + wedge_buffer: None, + cost, + category, + tags, + title_search: mlm_parse::normalize_title(&meta.title), + meta, + grabber: None, + created_at: Timestamp::now(), + started_at: None, + removed_at: None, + }) + .server_err()?; + rw.commit().server_err()?; + + if let Some(tx) = &context.triggers.downloader_tx { + tx.send(()).server_err()?; + } + + Ok(()) +} + +#[server] +pub async fn remove_torrent_action(id: String) -> Result<(), ServerFnError> { + let context = crate::error::get_context()?; + + let torrent = context + .db() + .r_transaction() + .server_err()? + .get() + .primary::(id.clone()) + .server_err()? + .ok_or_server_err("Torrent not found")?; + + let (_guard, rw) = context.db().rw_async().await.server_err()?; + rw.remove(torrent).server_err()?; + rw.commit().server_err()?; + Ok(()) +} + +#[server] +pub async fn clean_torrent_action(id: String) -> Result<(), ServerFnError> { + use mlm_core::cleaner::clean_torrent; + let context = crate::error::get_context()?; + let config = context.config().await; + let Some(torrent) = context + .db() + .r_transaction() + .server_err()? + .get() + .primary::(id) + .server_err()? + else { + return Err(ServerFnError::new("Could not find torrent")); + }; + clean_torrent(&config, context.db(), torrent, true, &context.events) + .await + .server_err()?; + Ok(()) +} + +#[server] +pub async fn refresh_metadata_action(id: String) -> Result<(), ServerFnError> { + use mlm_core::linker::refresh_mam_metadata; + let context = crate::error::get_context()?; + let config = context.config().await; + let mam = context.mam().server_err()?; + refresh_mam_metadata(&config, context.db(), &mam, id, &context.events) + .await + .server_err()?; + Ok(()) +} + +#[server] +pub async fn relink_torrent_action(id: String) -> Result<(), ServerFnError> { + use mlm_core::linker::relink; + let context = crate::error::get_context()?; + let config = context.config().await; + relink(&config, context.db(), id, &context.events) + .await + .server_err()?; + Ok(()) +} + +#[server] +pub async fn refresh_and_relink_action(id: String) -> Result<(), ServerFnError> { + use mlm_core::linker::refresh_metadata_relink; + let context = crate::error::get_context()?; + let config = context.config().await; + let mam = context.mam().server_err()?; + refresh_metadata_relink(&config, context.db(), &mam, id, &context.events) + .await + .server_err()?; + Ok(()) +} + +#[server] +pub async fn match_metadata_action(id: String, provider: String) -> Result<(), ServerFnError> { + use mlm_db::Event as DbEvent; + + let context = crate::error::get_context()?; + let Some(mut torrent) = context + .db() + .r_transaction() + .server_err()? + .get() + .primary::(id.clone()) + .server_err()? + else { + return Err(ServerFnError::new("Could not find torrent")); + }; + + let (new_meta, pid, fields) = match_meta(&context, &torrent.meta, &provider) + .await + .server_err()?; + + let (_guard, rw) = context.db().rw_async().await.server_err()?; + + let mut meta = new_meta; + meta.source = mlm_core::MetadataSource::Match; + torrent.meta = meta; + torrent.title_search = mlm_parse::normalize_title(&torrent.meta.title); + + rw.upsert(torrent.clone()).server_err()?; + rw.commit().server_err()?; + drop(_guard); + + mlm_core::logging::write_event( + context.db(), + &context.events, + DbEvent::new( + Some(torrent.id.clone()), + torrent.mam_id, + mlm_core::EventType::Updated { + fields: fields.clone(), + source: (mlm_core::MetadataSource::Match, pid.clone()), + }, + ), + ) + .await; + + Ok(()) +} + +#[server] +pub async fn clear_replacement_action(id: String) -> Result<(), ServerFnError> { + let context = crate::error::get_context()?; + let (_guard, rw) = context.db().rw_async().await.server_err()?; + let Some(mut torrent) = rw.get().primary::(id).server_err()? else { + return Err(ServerFnError::new("Could not find torrent")); + }; + torrent.replaced_with.take(); + rw.upsert(torrent).server_err()?; + rw.commit().server_err()?; + Ok(()) +} + +#[server] +pub async fn get_metadata_providers() -> Result, ServerFnError> { + let context = crate::error::get_context()?; + Ok(context.metadata().enabled_providers()) +} + +#[server] +pub async fn get_other_torrents(id: String) -> Result, ServerFnError> { + let context = crate::error::get_context()?; + + if let Some(torrent) = context + .db() + .r_transaction() + .server_err()? + .get() + .primary::(id.clone()) + .server_err()? + { + return other_torrents_data(&context, &torrent.meta).await; + } + + if let Ok(mam_id) = id.parse::() { + if let Some(torrent) = context + .db() + .r_transaction() + .server_err()? + .get() + .secondary::(mlm_db::TorrentKey::mam_id, Some(mam_id)) + .server_err()? + { + return other_torrents_data(&context, &torrent.meta).await; + } + if let Ok(mam) = context.mam() + && let Some(mam_torrent) = mam.get_torrent_info_by_id(mam_id).await.server_err()? + { + let meta = mam_torrent.as_meta().server_err()?; + return other_torrents_data(&context, &meta).await; + } + } + + Ok(vec![]) +} + +#[server] +pub async fn preview_match_metadata( + id: String, + provider: String, +) -> Result, ServerFnError> { + let context = crate::error::get_context()?; + let Some(torrent) = context + .db() + .r_transaction() + .server_err()? + .get() + .primary::(id) + .server_err()? + else { + return Err(ServerFnError::new("Could not find torrent")); + }; + + let (_, _, fields) = match_meta(&context, &torrent.meta, &provider) + .await + .server_err()?; + + Ok(fields + .into_iter() + .map(|f| crate::dto::TorrentMetaDiff { + field: f.field.to_string(), + from: f.from, + to: f.to, + }) + .collect()) +} + +#[server] +pub async fn get_qbit_data(id: String) -> Result, ServerFnError> { + use mlm_core::linker::{find_library, library_dir}; + use mlm_core::qbittorrent::get_torrent; + + let context = crate::error::get_context()?; + let config = context.config().await; + let db = context.db(); + + let Some(torrent) = db + .r_transaction() + .server_err()? + .get() + .primary::(id.clone()) + .server_err()? + else { + return Ok(None); + }; + + let Some((qbit_torrent, qbit, _qbit_config)) = + get_torrent(&config, &torrent.id).await.server_err()? + else { + if !config.qbittorrent.is_empty() + && torrent.client_status != Some(mlm_db::ClientStatus::NotInClient) + { + let (_guard, rw) = db.rw_async().await.server_err()?; + let mut torrent = torrent.clone(); + torrent.client_status = Some(mlm_db::ClientStatus::NotInClient); + rw.upsert(torrent).server_err()?; + rw.commit().server_err()?; + } + return Ok(None); + }; + + let mut categories: Vec = qbit + .categories() + .await + .server_err()? + .into_values() + .map(|cat| super::types::QbitCategory { name: cat.name }) + .collect(); + categories.sort_by(|a, b| a.name.cmp(&b.name)); + + let tags: Vec = qbit.tags().await.server_err()?; + + let trackers_raw = qbit.trackers(&torrent.id).await.server_err()?; + let tracker_message = trackers_raw + .iter() + .rev() + .find_map(|tracker| (!tracker.msg.is_empty()).then(|| tracker.msg.clone())); + let trackers = trackers_raw + .into_iter() + .map(|t| super::types::QbitTracker { + url: t.url, + msg: (!t.msg.is_empty()).then_some(t.msg), + }) + .collect(); + + let expected_path = find_library(&config, &qbit_torrent).and_then(|library| { + library_dir( + config.exclude_narrator_in_library_dir, + library, + &torrent.meta, + ) + }); + let no_longer_wanted = expected_path.is_none() && torrent.library_path.is_some(); + let wanted_path = + expected_path.filter(|expected| torrent.library_path.as_ref() != Some(expected)); + + let qbit_files = qbit + .files(&qbit_torrent.hash, None) + .await + .server_err()? + .into_iter() + .map(|file| file.name) + .collect(); + + let torrent_tags: Vec = qbit_torrent + .tags + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| s.trim().to_string()) + .collect(); + + Ok(Some(super::types::QbitData { + torrent_state: format_qbit_state(&qbit_torrent.state), + is_paused: matches!( + qbit_torrent.state, + TorrentState::StoppedDownloading | TorrentState::StoppedUploading + ), + torrent_category: qbit_torrent.category.clone(), + torrent_tags, + categories, + tags, + trackers, + tracker_message, + uploaded: mlm_db::Size::from_bytes(qbit_torrent.uploaded as u64).to_string(), + wanted_path, + no_longer_wanted, + qbit_files, + })) +} + +#[server] +pub async fn torrent_start_action(id: String) -> Result<(), ServerFnError> { + use mlm_core::qbittorrent::get_torrent; + + let context = crate::error::get_context()?; + let config = context.config().await; + let Some((qbit_torrent, qbit, _config)) = get_torrent(&config, &id).await.server_err()? else { + return Err(ServerFnError::new( + "Torrent not found in qBittorrent".to_string(), + )); + }; + + qbit.start(vec![&qbit_torrent.hash]).await.server_err()?; + + Ok(()) +} + +#[server] +pub async fn torrent_stop_action(id: String) -> Result<(), ServerFnError> { + use mlm_core::qbittorrent::get_torrent; + + let context = crate::error::get_context()?; + let config = context.config().await; + let Some((qbit_torrent, qbit, _config)) = get_torrent(&config, &id).await.server_err()? else { + return Err(ServerFnError::new( + "Torrent not found in qBittorrent".to_string(), + )); + }; + + qbit.stop(vec![&qbit_torrent.hash]).await.server_err()?; + + Ok(()) +} + +#[server] +pub async fn set_qbit_category_tags_action( + id: String, + category: String, + tags: Vec, +) -> Result<(), ServerFnError> { + use mlm_core::qbittorrent::{ensure_category_exists, get_torrent}; + + let context = crate::error::get_context()?; + let config = context.config().await; + let Some((qbit_torrent, qbit, qbit_config)) = get_torrent(&config, &id).await.server_err()? + else { + return Err(ServerFnError::new( + "Torrent not found in qBittorrent".to_string(), + )); + }; + + ensure_category_exists(&qbit, &qbit_config.url, &category) + .await + .server_err()?; + qbit.set_category(Some(vec![&qbit_torrent.hash]), &category) + .await + .server_err()?; + + let existing_tags: Vec = qbit_torrent + .tags + .split(',') + .filter(|s| !s.is_empty()) + .map(|s| s.trim().to_string()) + .collect(); + + if !existing_tags.is_empty() { + qbit.remove_tags( + Some(vec![&qbit_torrent.hash]), + existing_tags.iter().map(|s| s.as_str()).collect(), + ) + .await + .server_err()?; + } + + if !tags.is_empty() { + qbit.add_tags( + Some(vec![&qbit_torrent.hash]), + tags.iter().map(|s| s.as_str()).collect(), + ) + .await + .server_err()?; + } + + Ok(()) +} + +#[server] +pub async fn remove_seeding_files_action(id: String) -> Result<(), ServerFnError> { + use mlm_core::qbittorrent::get_torrent; + + let context = crate::error::get_context()?; + let config = context.config().await; + let db = context.db(); + + let torrent = db + .r_transaction() + .server_err()? + .get() + .primary::(id.clone()) + .server_err()? + .ok_or_server_err("Torrent not found")?; + + let Some((qbit_torrent, qbit, _config)) = get_torrent(&config, &id).await.server_err()? else { + return Err(ServerFnError::new( + "Torrent not found in qBittorrent".to_string(), + )); + }; + + let files = qbit.files(&qbit_torrent.hash, None).await.server_err()?; + + let library_files_set: std::collections::HashSet<_> = + read_library_files(torrent.library_path.as_deref()) + .await? + .into_iter() + .collect(); + + let files_to_remove: Vec<_> = files + .iter() + .filter(|f| { + let file_path = std::path::PathBuf::from(&qbit_torrent.save_path).join(&f.name); + !library_files_set.contains(&file_path) + }) + .map(|f| f.index) + .collect(); + + if !files_to_remove.is_empty() { + for file_id in files_to_remove { + let path = std::path::PathBuf::from(&qbit_torrent.save_path).join( + files + .iter() + .find(|f| f.index == file_id) + .map(|f| &f.name) + .unwrap_or(&String::new()), + ); + if fs::try_exists(&path).await.server_err()? { + fs::remove_file(path).await.server_err()?; + } + } + } + + Ok(()) +} diff --git a/mlm_web_dioxus/src/torrent_detail/types.rs b/mlm_web_dioxus/src/torrent_detail/types.rs new file mode 100644 index 00000000..4fb8ec3c --- /dev/null +++ b/mlm_web_dioxus/src/torrent_detail/types.rs @@ -0,0 +1,101 @@ +use crate::{ + dto::{Event, Series, TorrentMetaDiff}, + search::SearchTorrent, +}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TorrentDetailData { + pub torrent: TorrentInfo, + pub events: Vec, + pub replacement_torrent: Option, + pub replacement_missing: bool, + pub abs_item_url: Option, + pub abs_cover_url: Option, + pub mam_torrent: Option, + pub mam_meta_diff: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TorrentInfo { + pub id: String, + pub title: String, + pub edition: Option, + pub authors: Vec, + pub narrators: Vec, + pub series: Vec, + pub tags: Vec, + pub description_html: String, + pub media_type: String, + pub mediatype_id: u8, + pub main_cat: Option, + pub main_cat_id: u8, + pub language: Option, + pub filetypes: Vec, + pub size: String, + pub num_files: u64, + pub categories: Vec, + pub old_category: Option, + pub flags: Vec, + pub library_path: Option, + pub library_files: Vec, + pub linker: Option, + pub category: Option, + pub mam_id: Option, + pub vip_status: Option, + pub source: String, + pub uploaded_at: String, + pub client_status: Option, + pub replaced_with: Option, + pub goodreads_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[allow(clippy::large_enum_variant)] +pub enum TorrentPageData { + Downloaded(TorrentDetailData), + MamOnly(TorrentMamData), +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TorrentMamData { + pub mam_torrent: SearchTorrent, + pub meta: TorrentInfo, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ReplacementTorrentInfo { + pub id: String, + pub title: String, + pub size: String, + pub filetypes: Vec, + pub library_path: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct QbitData { + pub torrent_state: String, + pub is_paused: bool, + pub torrent_category: String, + pub torrent_tags: Vec, + pub categories: Vec, + pub tags: Vec, + pub trackers: Vec, + pub tracker_message: Option, + pub uploaded: String, + pub wanted_path: Option, + pub no_longer_wanted: bool, + pub qbit_files: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct QbitCategory { + pub name: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct QbitTracker { + pub url: String, + pub msg: Option, +} diff --git a/mlm_web_dioxus/src/torrent_edit.rs b/mlm_web_dioxus/src/torrent_edit.rs new file mode 100644 index 00000000..a89f54fd --- /dev/null +++ b/mlm_web_dioxus/src/torrent_edit.rs @@ -0,0 +1,796 @@ +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "server")] +use crate::error::{IntoServerFnError, OptionIntoServerFnError}; +#[cfg(feature = "server")] +use mlm_core::ContextExt; +#[cfg(feature = "server")] +use mlm_db::DatabaseExt; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct TorrentMetaEditForm { + pub torrent_id: String, + pub ids_text: String, + pub vip_mode: String, + pub vip_temp_date: String, + pub category_id: String, + pub media_type_id: String, + pub main_cat_id: String, + pub categories_text: String, + pub tags_text: String, + pub language_id: String, + pub crude_language: bool, + pub violence: bool, + pub some_explicit: bool, + pub explicit: bool, + pub abridged: bool, + pub lgbt: bool, + pub filetypes_text: String, + pub num_files: String, + pub size: String, + pub title: String, + pub edition: String, + pub edition_number: String, + pub description: String, + pub authors_text: String, + pub narrators_text: String, + pub series_text: String, + pub source: String, + pub uploaded_at_unix: String, +} + +#[cfg(feature = "server")] +fn split_list(text: &str) -> Vec { + text.lines() + .flat_map(|line| line.split(',')) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +#[cfg(feature = "server")] +fn parse_series(text: &str) -> Result, ServerFnError> { + if text.trim().is_empty() { + return Ok(Vec::new()); + } + + text.lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| { + line.split_once(" #") + .map(|(name, entries)| { + mlm_db::Series::try_from((name.to_string(), entries.to_string())) + }) + .unwrap_or_else(|| mlm_db::Series::try_from((line.to_string(), String::new()))) + .map_err(|e| ServerFnError::new(format!("failed to parse series '{line}': {e}"))) + }) + .collect() +} + +#[cfg(feature = "server")] +fn parse_ids(text: &str) -> Result, ServerFnError> { + let mut ids = std::collections::BTreeMap::new(); + for raw_line in text.lines() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + let Some((key, value)) = line.split_once('=') else { + return Err(ServerFnError::new(format!( + "invalid ids line '{line}', expected key=value" + ))); + }; + let key = key.trim(); + let value = value.trim(); + if key.is_empty() || value.is_empty() { + return Err(ServerFnError::new(format!( + "invalid ids line '{line}', key and value must be non-empty" + ))); + } + ids.insert(key.to_string(), value.to_string()); + } + Ok(ids) +} + +#[cfg(feature = "server")] +fn parse_vip_status( + mode: &str, + temp_date: &str, +) -> Result, ServerFnError> { + let mode = mode.trim().to_lowercase(); + match mode.as_str() { + "" | "none" => Ok(None), + "not_vip" => Ok(Some(mlm_db::VipStatus::NotVip)), + "permanent" => Ok(Some(mlm_db::VipStatus::Permanent)), + "temp" => { + let value = temp_date.trim(); + if value.is_empty() { + return Err(ServerFnError::new( + "vip temp date is required when vip mode is temp", + )); + } + let date_format = time::format_description::parse("[year]-[month]-[day]") + .map_err(|e| ServerFnError::new(format!("invalid vip date format config: {e}")))?; + let date = time::Date::parse(value, &date_format).map_err(|e| { + ServerFnError::new(format!("failed to parse vip temp date '{value}': {e}")) + })?; + Ok(Some(mlm_db::VipStatus::Temp(date))) + } + _ => Err(ServerFnError::new(format!("invalid vip mode '{mode}'"))), + } +} + +#[server] +pub async fn get_torrent_meta_edit_data(id: String) -> Result { + use itertools::Itertools; + + let context = crate::error::get_context()?; + + let torrent = context + .db() + .r_transaction() + .server_err()? + .get() + .primary::(id.clone()) + .server_err()? + .ok_or_server_err("Torrent not found")?; + + let meta = torrent.meta; + let flags = mlm_db::Flags::from_bitfield(meta.flags.map_or(0, |f| f.0)); + let (vip_mode, vip_temp_date) = match meta.vip_status { + None => ("none".to_string(), String::new()), + Some(mlm_db::VipStatus::NotVip) => ("not_vip".to_string(), String::new()), + Some(mlm_db::VipStatus::Permanent) => ("permanent".to_string(), String::new()), + Some(mlm_db::VipStatus::Temp(date)) => ("temp".to_string(), date.to_string()), + }; + + Ok(TorrentMetaEditForm { + torrent_id: id, + ids_text: meta.ids.iter().map(|(k, v)| format!("{k}={v}")).join("\n"), + vip_mode, + vip_temp_date, + category_id: meta + .cat + .map(|cat: mlm_db::OldCategory| cat.as_id().to_string()) + .unwrap_or_default(), + media_type_id: meta.media_type.as_id().to_string(), + main_cat_id: meta + .main_cat + .map(|cat: mlm_db::MainCat| cat.as_id().to_string()) + .unwrap_or_default(), + categories_text: meta + .categories + .iter() + .map(|c| c.as_str()) + .collect::>() + .join("\n"), + tags_text: meta.tags.join("\n"), + language_id: meta + .language + .map(|language: mlm_db::Language| language.to_id().to_string()) + .unwrap_or_default(), + crude_language: flags.crude_language.unwrap_or(false), + violence: flags.violence.unwrap_or(false), + some_explicit: flags.some_explicit.unwrap_or(false), + explicit: flags.explicit.unwrap_or(false), + abridged: flags.abridged.unwrap_or(false), + lgbt: flags.lgbt.unwrap_or(false), + filetypes_text: meta.filetypes.join("\n"), + num_files: meta.num_files.to_string(), + size: meta.size.to_string(), + title: meta.title, + edition: meta + .edition + .as_ref() + .map(|(ed, _): &(String, u64)| ed.clone()) + .unwrap_or_default(), + edition_number: meta + .edition + .as_ref() + .map(|(_, idx): &(String, u64)| idx.to_string()) + .unwrap_or_default(), + description: meta.description, + authors_text: meta.authors.join("\n"), + narrators_text: meta.narrators.join("\n"), + series_text: meta + .series + .iter() + .map(mlm_db::impls::format_serie) + .join("\n"), + source: match meta.source { + mlm_db::MetadataSource::Mam => "mam".to_string(), + mlm_db::MetadataSource::Manual => "manual".to_string(), + mlm_db::MetadataSource::File => "file".to_string(), + mlm_db::MetadataSource::Match => "match".to_string(), + }, + uploaded_at_unix: meta + .uploaded_at + .map(|uploaded_at| uploaded_at.0.unix_timestamp().to_string()) + .unwrap_or_default(), + }) +} + +#[server] +pub async fn update_torrent_meta_edit_data(form: TorrentMetaEditForm) -> Result<(), ServerFnError> { + let context = crate::error::get_context()?; + + let config = context.config().await; + let rw = context.db().rw_async().await.server_err()?; + let torrent = + rw.1.get() + .primary::(form.torrent_id.clone()) + .server_err()? + .ok_or_server_err("Torrent not found")?; + + let ids = parse_ids(&form.ids_text)?; + let category = if form.category_id.trim().is_empty() { + None + } else { + let value = form + .category_id + .trim() + .parse::() + .map_err(|e| ServerFnError::new(format!("invalid category id: {e}")))?; + mlm_db::OldCategory::from_one_id(value) + .ok_or_server_err(&format!("unknown category id {value}"))? + .into() + }; + + let media_type = if form.media_type_id.trim().is_empty() { + if let Some(category) = category.as_ref() { + category.as_main_cat().into() + } else { + torrent.meta.media_type + } + } else { + let value = form + .media_type_id + .trim() + .parse::() + .map_err(|e| ServerFnError::new(format!("invalid media type id: {e}")))?; + mlm_db::MediaType::from_id(value) + .ok_or_server_err(&format!("unknown media type id {value}"))? + }; + + let main_cat = if form.main_cat_id.trim().is_empty() { + None + } else { + let value = form + .main_cat_id + .trim() + .parse::() + .map_err(|e| ServerFnError::new(format!("invalid main category id: {e}")))?; + mlm_db::MainCat::from_id(value) + .ok_or_server_err(&format!("unknown main category id {value}"))? + .into() + }; + + let language = if form.language_id.trim().is_empty() { + None + } else { + let value = form + .language_id + .trim() + .parse::() + .map_err(|e| ServerFnError::new(format!("invalid language id: {e}")))?; + Some( + mlm_db::Language::from_id(value) + .ok_or_server_err(&format!("unknown language id {value}"))?, + ) + }; + + let source = match form.source.trim().to_lowercase().as_str() { + "mam" => mlm_db::MetadataSource::Mam, + "manual" => mlm_db::MetadataSource::Manual, + "file" => mlm_db::MetadataSource::File, + "match" => mlm_db::MetadataSource::Match, + value => return Err(ServerFnError::new(format!("invalid source '{value}'"))), + }; + + let uploaded_at = if form.uploaded_at_unix.trim().is_empty() { + None + } else { + let uploaded_at_unix = + form.uploaded_at_unix.trim().parse::().map_err(|e| { + ServerFnError::new(format!("invalid uploaded_at unix timestamp: {e}")) + })?; + Some( + time::UtcDateTime::from_unix_timestamp(uploaded_at_unix).map_err(|e| { + ServerFnError::new(format!("invalid uploaded_at unix timestamp: {e}")) + })?, + ) + }; + + let edition = if form.edition.trim().is_empty() { + None + } else { + let number = if form.edition_number.trim().is_empty() { + 0 + } else { + form.edition_number + .trim() + .parse::() + .map_err(|e| ServerFnError::new(format!("invalid edition number: {e}")))? + }; + Some((form.edition.trim().to_string(), number)) + }; + + let flags = mlm_db::Flags { + crude_language: Some(form.crude_language), + violence: Some(form.violence), + some_explicit: Some(form.some_explicit), + explicit: Some(form.explicit), + abridged: Some(form.abridged), + lgbt: Some(form.lgbt), + }; + + let categories = split_list(&form.categories_text) + .into_iter() + .map(|raw| { + raw.parse::() + .map_err(|e| ServerFnError::new(format!("invalid category '{raw}': {e}"))) + }) + .collect::, _>>()?; + + let meta = + mlm_db::TorrentMeta { + ids, + vip_status: parse_vip_status(&form.vip_mode, &form.vip_temp_date)?, + cat: category, + media_type, + main_cat, + categories, + tags: split_list(&form.tags_text), + language, + flags: Some(mlm_db::FlagBits::new(flags.as_bitfield())), + filetypes: split_list(&form.filetypes_text), + num_files: form + .num_files + .trim() + .parse::() + .map_err(|e| ServerFnError::new(format!("invalid num_files: {e}")))?, + size: form.size.trim().parse::().map_err(|e| { + ServerFnError::new(format!("invalid size '{}': {e}", form.size.trim())) + })?, + title: form.title.trim().to_string(), + edition, + description: form.description, + authors: split_list(&form.authors_text), + narrators: split_list(&form.narrators_text), + series: parse_series(&form.series_text)?, + source, + uploaded_at: uploaded_at.map(Into::into), + }; + + mlm_core::autograbber::update_torrent_meta( + &config, + context.db(), + rw, + None, + torrent, + meta, + true, + false, + &context.events, + ) + .await + .server_err()?; + + Ok(()) +} + +#[component] +pub fn TorrentEditPage(id: String) -> Element { + let mut status_msg = use_signal(|| None::<(String, bool)>); + let mut form_state = use_signal(|| None::); + let mut loaded_form = use_signal(|| None::); + + let data_res = use_server_future(move || { + let id = id.clone(); + async move { get_torrent_meta_edit_data(id).await } + })?; + + use_effect(move || { + if let Some(Ok(data)) = &*data_res.value().read() + && loaded_form.read().as_ref() != Some(data) + { + loaded_form.set(Some(data.clone())); + form_state.set(Some(data.clone())); + } + }); + + rsx! { + div { class: "torrent-edit-page", + h1 { "Edit Torrent Metadata" } + + if let Some((msg, is_error)) = status_msg.read().as_ref() { + p { class: if *is_error { "error" } else { "loading-indicator" }, "{msg}" } + } + + if let Some(form) = form_state.read().as_ref().cloned() { + form { + class: "column", + onsubmit: move |ev: Event| { + ev.prevent_default(); + let current = form_state.read().clone(); + let Some(payload) = current else { + return; + }; + spawn(async move { + match update_torrent_meta_edit_data(payload).await { + Ok(_) => status_msg.set(Some(("Metadata updated".to_string(), false))), + Err(e) => status_msg.set(Some((format!("Update failed: {e}"), true))), + } + }); + }, + + label { + "Title" + input { + r#type: "text", + value: "{form.title}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.title = ev.value(); + } + }, + } + } + + label { + "Description" + textarea { + rows: "6", + value: "{form.description}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.description = ev.value(); + } + }, + } + } + + label { + "IDs (key=value per line)" + textarea { + rows: "5", + value: "{form.ids_text}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.ids_text = ev.value(); + } + }, + } + } + + div { class: "row", + label { + "VIP Mode" + select { + value: "{form.vip_mode}", + onchange: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.vip_mode = ev.value(); + } + }, + option { value: "none", "None" } + option { value: "not_vip", "Not VIP" } + option { value: "permanent", "Permanent" } + option { value: "temp", "Temporary" } + } + } + label { + "VIP Temp Date (YYYY-MM-DD)" + input { + r#type: "text", + value: "{form.vip_temp_date}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.vip_temp_date = ev.value(); + } + }, + } + } + } + + div { class: "row", + label { + "Category ID" + input { + r#type: "text", + value: "{form.category_id}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.category_id = ev.value(); + } + }, + } + } + label { + "Media Type ID" + input { + r#type: "text", + value: "{form.media_type_id}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.media_type_id = ev.value(); + } + }, + } + } + label { + "Main Category ID" + input { + r#type: "text", + value: "{form.main_cat_id}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.main_cat_id = ev.value(); + } + }, + } + } + label { + "Language ID" + input { + r#type: "text", + value: "{form.language_id}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.language_id = ev.value(); + } + }, + } + } + } + + div { class: "row", + label { + input { + r#type: "checkbox", + checked: form.crude_language, + onchange: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.crude_language = ev.value() == "true"; + } + }, + } + "Crude language" + } + label { + input { + r#type: "checkbox", + checked: form.violence, + onchange: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.violence = ev.value() == "true"; + } + }, + } + "Violence" + } + label { + input { + r#type: "checkbox", + checked: form.some_explicit, + onchange: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.some_explicit = ev.value() == "true"; + } + }, + } + "Some explicit" + } + label { + input { + r#type: "checkbox", + checked: form.explicit, + onchange: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.explicit = ev.value() == "true"; + } + }, + } + "Explicit" + } + label { + input { + r#type: "checkbox", + checked: form.abridged, + onchange: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.abridged = ev.value() == "true"; + } + }, + } + "Abridged" + } + label { + input { + r#type: "checkbox", + checked: form.lgbt, + onchange: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.lgbt = ev.value() == "true"; + } + }, + } + "LGBT" + } + } + + label { + "Categories (newline/comma separated)" + textarea { + rows: "4", + value: "{form.categories_text}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.categories_text = ev.value(); + } + }, + } + } + + label { + "Tags (newline/comma separated)" + textarea { + rows: "4", + value: "{form.tags_text}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.tags_text = ev.value(); + } + }, + } + } + + label { + "Filetypes (newline/comma separated)" + textarea { + rows: "3", + value: "{form.filetypes_text}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.filetypes_text = ev.value(); + } + }, + } + } + + div { class: "row", + label { + "Num Files" + input { + r#type: "text", + value: "{form.num_files}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.num_files = ev.value(); + } + }, + } + } + label { + "Size" + input { + r#type: "text", + value: "{form.size}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.size = ev.value(); + } + }, + } + } + label { + "Uploaded At (unix seconds)" + input { + r#type: "text", + value: "{form.uploaded_at_unix}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.uploaded_at_unix = ev.value(); + } + }, + } + } + } + + div { class: "row", + label { + "Edition" + input { + r#type: "text", + value: "{form.edition}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.edition = ev.value(); + } + }, + } + } + label { + "Edition Number" + input { + r#type: "text", + value: "{form.edition_number}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.edition_number = ev.value(); + } + }, + } + } + label { + "Source" + select { + value: "{form.source}", + onchange: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.source = ev.value(); + } + }, + option { value: "mam", "MaM" } + option { value: "manual", "Manual" } + option { value: "file", "File" } + option { value: "match", "Match" } + } + } + } + + label { + "Authors (newline/comma separated)" + textarea { + rows: "4", + value: "{form.authors_text}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.authors_text = ev.value(); + } + }, + } + } + + label { + "Narrators (newline/comma separated)" + textarea { + rows: "4", + value: "{form.narrators_text}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.narrators_text = ev.value(); + } + }, + } + } + + label { + "Series (one per line, format: Name #1)" + textarea { + rows: "4", + value: "{form.series_text}", + oninput: move |ev| { + if let Some(state) = form_state.write().as_mut() { + state.series_text = ev.value(); + } + }, + } + } + + div { class: "row", + button { r#type: "submit", class: "btn", "Save" } + a { class: "btn", href: "/dioxus/torrents/{form.torrent_id}", "Back to Torrent" } + } + } + } else if let Some(Err(e)) = &*data_res.value().read() { + p { class: "error", "Error: {e}" } + } else { + p { "Loading torrent metadata..." } + } + } + } +} diff --git a/mlm_web_dioxus/src/torrents/components.rs b/mlm_web_dioxus/src/torrents/components.rs new file mode 100644 index 00000000..611d30fe --- /dev/null +++ b/mlm_web_dioxus/src/torrents/components.rs @@ -0,0 +1,859 @@ +use std::collections::BTreeSet; +use std::sync::Arc; + +use dioxus::prelude::*; + +use crate::components::{ + ActiveFilterChip, ActiveFilters, ColumnSelector, ColumnToggleOption, FilterLink, + PageSizeSelector, Pagination, SortHeader, StatusMessage, TorrentGridTable, TorrentTitleLink, + flag_icon, parse_location_query_pairs, set_location_query_string, update_row_selection, +}; + +use super::query::{build_query_url, parse_query_state}; +use super::{ + TorrentsBulkAction, TorrentsData, TorrentsPageColumns, TorrentsPageFilter, TorrentsPageSort, + TorrentsRow, apply_torrents_action, get_torrents_data, +}; + +#[derive(Clone, Copy)] +enum TorrentColumn { + Category, + Categories, + Flags, + Edition, + Authors, + Narrators, + Series, + Language, + Size, + Filetypes, + Linker, + QbitCategory, + Path, + CreatedAt, + UploadedAt, +} + +const COLUMN_OPTIONS: &[(TorrentColumn, &str)] = &[ + (TorrentColumn::Category, "Category"), + (TorrentColumn::Categories, "Categories"), + (TorrentColumn::Flags, "Flags"), + (TorrentColumn::Edition, "Edition"), + (TorrentColumn::Authors, "Authors"), + (TorrentColumn::Narrators, "Narrators"), + (TorrentColumn::Series, "Series"), + (TorrentColumn::Language, "Language"), + (TorrentColumn::Size, "Size"), + (TorrentColumn::Filetypes, "Filetypes"), + (TorrentColumn::Linker, "Linker"), + (TorrentColumn::QbitCategory, "Qbit Category"), + (TorrentColumn::Path, "Path"), + (TorrentColumn::CreatedAt, "Added At"), + (TorrentColumn::UploadedAt, "Uploaded At"), +]; + +macro_rules! impl_torrents_columns { + ($( $variant:ident => $field:ident ),+ $(,)?) => { + impl TorrentsPageColumns { + fn get(self, col: TorrentColumn) -> bool { + match col { + $(TorrentColumn::$variant => self.$field,)+ + } + } + + fn set(&mut self, col: TorrentColumn, enabled: bool) { + match col { + $(TorrentColumn::$variant => self.$field = enabled,)+ + } + } + } + }; +} + +impl_torrents_columns!( + Category => category, + Categories => categories, + Flags => flags, + Edition => edition, + Authors => authors, + Narrators => narrators, + Series => series, + Language => language, + Size => size, + Filetypes => filetypes, + Linker => linker, + QbitCategory => qbit_category, + Path => path, + CreatedAt => created_at, + UploadedAt => uploaded_at, +); + +fn filter_name(filter: TorrentsPageFilter) -> &'static str { + match filter { + TorrentsPageFilter::Kind => "Type", + TorrentsPageFilter::Category => "Category", + TorrentsPageFilter::Categories => "Categories", + TorrentsPageFilter::Flags => "Flags", + TorrentsPageFilter::Title => "Title", + TorrentsPageFilter::Author => "Authors", + TorrentsPageFilter::Narrator => "Narrators", + TorrentsPageFilter::Series => "Series", + TorrentsPageFilter::Language => "Language", + TorrentsPageFilter::Filetype => "Filetypes", + TorrentsPageFilter::Linker => "Linker", + TorrentsPageFilter::QbitCategory => "Qbit Category", + TorrentsPageFilter::Linked => "Linked", + TorrentsPageFilter::LibraryMismatch => "Library mismatch", + TorrentsPageFilter::ClientStatus => "Client status", + TorrentsPageFilter::Abs => "ABS", + TorrentsPageFilter::Query => "Query", + TorrentsPageFilter::Source => "Source", + TorrentsPageFilter::Metadata => "Metadata", + } +} + +/// Column headers row with a select-all checkbox and sortable column headers. +#[component] +fn TorrentsTableHeader( + show: TorrentsPageColumns, + sort: Signal>, + asc: Signal, + mut from: Signal, + row_ids: Vec, + mut selected: Signal>, +) -> Element { + let all_selected = row_ids.iter().all(|id| selected.read().contains(id)); + rsx! { + div { class: "torrents-grid-row", + div { class: "header", + input { + r#type: "checkbox", + checked: all_selected, + onchange: move |ev| { + let mut next = selected.read().clone(); + if ev.value() == "true" { + for id in &row_ids { + next.insert(id.clone()); + } + } else { + for id in &row_ids { + next.remove(id); + } + } + selected.set(next); + }, + } + } + SortHeader { label: "Type", sort_key: TorrentsPageSort::Kind, sort, asc, from } + if show.categories { + div { class: "header", "Categories" } + } + if show.flags { + div { class: "header", "Flags" } + } + SortHeader { label: "Title", sort_key: TorrentsPageSort::Title, sort, asc, from } + if show.edition { + SortHeader { label: "Edition", sort_key: TorrentsPageSort::Edition, sort, asc, from } + } + if show.authors { + SortHeader { label: "Authors", sort_key: TorrentsPageSort::Authors, sort, asc, from } + } + if show.narrators { + SortHeader { label: "Narrators", sort_key: TorrentsPageSort::Narrators, sort, asc, from } + } + if show.series { + SortHeader { label: "Series", sort_key: TorrentsPageSort::Series, sort, asc, from } + } + if show.language { + SortHeader { label: "Language", sort_key: TorrentsPageSort::Language, sort, asc, from } + } + if show.size { + SortHeader { label: "Size", sort_key: TorrentsPageSort::Size, sort, asc, from } + } + if show.filetypes { + div { class: "header", "Filetypes" } + } + if show.linker { + SortHeader { label: "Linker", sort_key: TorrentsPageSort::Linker, sort, asc, from } + } + if show.qbit_category { + SortHeader { + label: "Qbit Category", + sort_key: TorrentsPageSort::QbitCategory, + sort, + asc, + from, + } + } + SortHeader { + label: if show.path { "Path" } else { "Linked" }, + sort_key: TorrentsPageSort::Linked, + sort, + asc, + from, + } + if show.created_at { + SortHeader { label: "Added At", sort_key: TorrentsPageSort::CreatedAt, sort, asc, from } + } + if show.uploaded_at { + SortHeader { + label: "Uploaded At", + sort_key: TorrentsPageSort::UploadedAt, + sort, + asc, + from, + } + } + } + } +} + +/// A single row in the torrents table. +/// +/// Parses URL query params once per row (rather than once per FilterLink cell) +/// and forwards them via `current_params` to avoid redundant parsing. +#[component] +fn TorrentRow( + torrent: TorrentsRow, + show: TorrentsPageColumns, + i: usize, + mut selected: Signal>, + mut last_selected_idx: Signal>, + all_row_ids: Arc>, +) -> Element { + let row_id = torrent.id.clone(); + let row_selected = selected.read().contains(&row_id); + let current_params = parse_location_query_pairs(); + + rsx! { + div { class: "torrents-grid-row", key: "{row_id}", + div { + input { + r#type: "checkbox", + checked: row_selected, + onclick: { + let row_id = row_id.clone(); + move |ev: MouseEvent| { + update_row_selection( + &ev, + selected, + last_selected_idx, + all_row_ids.as_ref(), + &row_id, + i, + ); + } + }, + } + } + div { + FilterLink { + field: TorrentsPageFilter::Kind, + value: torrent.meta.media_type.clone(), + title: Some(torrent.meta.cat_name.clone()), + reset_from: true, + current_params: Some(current_params.clone()), + "{torrent.meta.media_type}" + } + if show.category { + if let Some(cat_id) = torrent.meta.cat_id.clone() { + div { + FilterLink { + field: TorrentsPageFilter::Category, + value: cat_id, + reset_from: true, + current_params: Some(current_params.clone()), + "{torrent.meta.cat_name}" + } + } + } + } + } + if show.categories { + div { + for category in torrent.meta.categories.clone() { + FilterLink { + field: TorrentsPageFilter::Categories, + value: category.clone(), + reset_from: true, + current_params: Some(current_params.clone()), + "{category}" + } + } + } + } + if show.flags { + div { + for flag in torrent.meta.flags.clone() { + if let Some((src, title)) = flag_icon(&flag) { + FilterLink { + field: TorrentsPageFilter::Flags, + value: flag.clone(), + reset_from: true, + current_params: Some(current_params.clone()), + img { class: "flag", src: "{src}", alt: "{title}", title: "{title}" } + } + } + } + } + } + div { + TorrentTitleLink { + detail_id: torrent.id.clone(), + field: TorrentsPageFilter::Title, + value: torrent.meta.title.clone(), + reset_from: true, + current_params: Some(current_params.clone()), + "{torrent.meta.title}" + } + if torrent.client_status.as_deref() == Some("removed_from_tracker") { + span { + class: "warn", + title: "Torrent is removed from tracker but still seeding", + FilterLink { + field: TorrentsPageFilter::ClientStatus, + value: "removed_from_tracker".to_string(), + reset_from: true, + current_params: Some(current_params.clone()), + "⚠" + } + } + } + if torrent.client_status.as_deref() == Some("not_in_client") { + span { title: "Torrent is not seeding", + FilterLink { + field: TorrentsPageFilter::ClientStatus, + value: "not_in_client".to_string(), + reset_from: true, + current_params: Some(current_params.clone()), + "ℹ" + } + } + } + } + if show.edition { + div { "{torrent.meta.edition.clone().unwrap_or_default()}" } + } + if show.authors { + div { + for author in torrent.meta.authors.clone() { + FilterLink { + field: TorrentsPageFilter::Author, + value: author.clone(), + reset_from: true, + current_params: Some(current_params.clone()), + "{author}" + } + } + } + } + if show.narrators { + div { + for narrator in torrent.meta.narrators.clone() { + FilterLink { + field: TorrentsPageFilter::Narrator, + value: narrator.clone(), + reset_from: true, + current_params: Some(current_params.clone()), + "{narrator}" + } + } + } + } + if show.series { + div { + for series in torrent.meta.series.clone() { + FilterLink { + field: TorrentsPageFilter::Series, + value: series.name.clone(), + reset_from: true, + current_params: Some(current_params.clone()), + if series.entries.is_empty() { + "{series.name}" + } else { + "{series.name} #{series.entries}" + } + } + } + } + } + if show.language { + div { + FilterLink { + field: TorrentsPageFilter::Language, + value: torrent.meta.language.clone().unwrap_or_default(), + reset_from: true, + current_params: Some(current_params.clone()), + "{torrent.meta.language.clone().unwrap_or_default()}" + } + } + } + if show.size { + div { "{torrent.meta.size}" } + } + if show.filetypes { + div { + for filetype in torrent.meta.filetypes.clone() { + FilterLink { + field: TorrentsPageFilter::Filetype, + value: filetype.clone(), + reset_from: true, + current_params: Some(current_params.clone()), + "{filetype}" + } + } + } + } + if show.linker { + div { + FilterLink { + field: TorrentsPageFilter::Linker, + value: torrent.linker.clone().unwrap_or_default(), + reset_from: true, + current_params: Some(current_params.clone()), + "{torrent.linker.clone().unwrap_or_default()}" + } + } + } + if show.qbit_category { + div { + FilterLink { + field: TorrentsPageFilter::QbitCategory, + value: torrent.category.clone().unwrap_or_default(), + reset_from: true, + current_params: Some(current_params.clone()), + "{torrent.category.clone().unwrap_or_default()}" + } + } + } + if show.path { + div { + "{torrent.library_path.clone().unwrap_or_default()}" + if let Some(mismatch) = torrent.library_mismatch.clone() { + span { class: "warn", title: "{mismatch.title()}", + FilterLink { + field: TorrentsPageFilter::LibraryMismatch, + value: mismatch.filter_value().to_string(), + reset_from: true, + current_params: Some(current_params.clone()), + "⚠" + } + } + } + } + } else { + div { + if let Some(path) = torrent.library_path.clone() { + span { title: "{path}", + FilterLink { + field: TorrentsPageFilter::Linked, + value: torrent.linked.to_string(), + reset_from: true, + current_params: Some(current_params.clone()), + "{torrent.linked}" + } + } + } else { + FilterLink { + field: TorrentsPageFilter::Linked, + value: torrent.linked.to_string(), + reset_from: true, + current_params: Some(current_params.clone()), + "{torrent.linked}" + } + } + if let Some(mismatch) = torrent.library_mismatch.clone() { + span { class: "warn", title: "{mismatch.title()}", + FilterLink { + field: TorrentsPageFilter::LibraryMismatch, + value: mismatch.filter_value().to_string(), + reset_from: true, + current_params: Some(current_params.clone()), + "⚠" + } + } + } + } + } + if show.created_at { + div { "{torrent.created_at}" } + } + if show.uploaded_at { + div { "{torrent.uploaded_at}" } + } + } + } +} + +#[component] +pub fn TorrentsPage() -> Element { + let _route: crate::app::Route = use_route(); + let initial_state = parse_query_state(); + let initial_request_key = build_query_url( + &initial_state.query, + initial_state.sort, + initial_state.asc, + &initial_state.filters, + initial_state.from, + initial_state.page_size, + initial_state.show, + ); + + let initial_query = initial_state.query.clone(); + let mut query_input = use_signal(move || initial_query.clone()); + let mut submitted_query = use_signal(move || initial_state.query.clone()); + let sort = use_signal(move || initial_state.sort); + let asc = use_signal(move || initial_state.asc); + let filters = use_signal(move || initial_state.filters.clone()); + let mut from = use_signal(move || initial_state.from); + let mut page_size = use_signal(move || initial_state.page_size); + let show = use_signal(move || initial_state.show); + let selected = use_signal(BTreeSet::::new); + let last_selected_idx = use_signal(|| None::); + let status_msg = use_signal(|| None::<(String, bool)>); + let mut cached = use_signal(|| None::); + let loading_action = use_signal(|| false); + let mut last_request_key = use_signal(move || initial_request_key); + + let mut torrents_data = use_server_future(move || async move { + let mut server_filters = filters.read().clone(); + let query = submitted_query.read().trim().to_string(); + if !query.is_empty() { + server_filters.push((TorrentsPageFilter::Query, query)); + } + get_torrents_data( + *sort.read(), + *asc.read(), + server_filters, + Some(*from.read()), + Some(*page_size.read()), + *show.read(), + ) + .await + }) + .ok(); + + let pending = torrents_data + .as_ref() + .map(|resource| resource.pending()) + .unwrap_or(true); + let value = torrents_data.as_ref().map(|resource| resource.value()); + + // Sync signals from the current URL when the browser navigates (back/forward). + { + let route_state = parse_query_state(); + let route_request_key = build_query_url( + &route_state.query, + route_state.sort, + route_state.asc, + &route_state.filters, + route_state.from, + route_state.page_size, + route_state.show, + ); + if *last_request_key.read() != route_request_key { + let mut sort = sort; + let mut asc = asc; + let mut filters_signal = filters; + let mut show = show; + query_input.set(route_state.query.clone()); + submitted_query.set(route_state.query); + sort.set(route_state.sort); + asc.set(route_state.asc); + filters_signal.set(route_state.filters); + from.set(route_state.from); + page_size.set(route_state.page_size); + show.set(route_state.show); + last_request_key.set(route_request_key); + if let Some(resource) = torrents_data.as_mut() { + resource.restart(); + } + } + } + + if let Some(value) = &value + && let Some(Ok(data)) = &*value.read() + { + cached.set(Some(data.clone())); + } + + let data_to_show = if let Some(value) = &value { + match &*value.read() { + Some(Ok(data)) => Some(data.clone()), + _ => cached.read().clone(), + } + } else { + cached.read().clone() + }; + + use_effect(move || { + let query = submitted_query.read().trim().to_string(); + let sort = *sort.read(); + let asc = *asc.read(); + let filters = filters.read().clone(); + let from = *from.read(); + let page_size = *page_size.read(); + let show = *show.read(); + + let query_string = build_query_url(&query, sort, asc, &filters, from, page_size, show); + if *last_request_key.read() != query_string { + last_request_key.set(query_string.clone()); + set_location_query_string(&query_string); + if let Some(resource) = torrents_data.as_mut() { + resource.restart(); + } + } + }); + + let column_options = COLUMN_OPTIONS + .iter() + .map(|(column, label)| { + let checked = show.read().get(*column); + let column = *column; + ColumnToggleOption { + label, + checked, + on_toggle: Callback::new({ + let mut show = show; + move |enabled| { + let mut next = *show.read(); + next.set(column, enabled); + show.set(next); + } + }), + } + }) + .collect::>(); + + let mut active_chips = Vec::new(); + if !submitted_query.read().is_empty() { + active_chips.push(ActiveFilterChip { + label: format!("Query: {}", submitted_query.read()), + on_remove: Callback::new({ + let mut submitted_query = submitted_query; + let mut query_input = query_input; + let mut from = from; + move |_| { + submitted_query.set(String::new()); + query_input.set(String::new()); + from.set(0); + } + }), + }); + } + for (field, value) in filters.read().clone() { + active_chips.push(ActiveFilterChip { + label: format!("{}: {}", filter_name(field), value), + on_remove: Callback::new({ + let value = value.clone(); + let mut filters = filters; + let mut from = from; + move |_| { + filters + .write() + .retain(|(f, v)| !(*f == field && *v == value)); + from.set(0); + } + }), + }); + } + + let clear_all: Option> = if active_chips.is_empty() { + None + } else { + Some(Callback::new({ + let mut filters = filters; + let mut submitted_query = submitted_query; + let mut query_input = query_input; + let mut from = from; + move |_| { + filters.set(Vec::new()); + submitted_query.set(String::new()); + query_input.set(String::new()); + from.set(0); + } + })) + }; + + let all_row_ids = Arc::new( + data_to_show + .as_ref() + .map(|data| { + data.torrents + .iter() + .map(|t| t.id.clone()) + .collect::>() + }) + .unwrap_or_default(), + ); + + let show_snapshot = *show.read(); + + rsx! { + div { class: "torrents-page", + form { + class: "row", + onsubmit: move |ev: Event| { + ev.prevent_default(); + submitted_query.set(query_input.read().trim().to_string()); + from.set(0); + }, + h1 { "Torrents" } + label { + input { + r#type: "submit", + value: "Search", + "aria-hidden": "true", + style: "display: none;", + } + "Search: " + input { + r#type: "text", + name: "query", + value: "{query_input}", + oninput: move |ev| query_input.set(ev.value()), + } + button { + r#type: "button", + onclick: move |_| { + query_input.set(String::new()); + submitted_query.set(String::new()); + from.set(0); + }, + "×" + } + } + div { class: "table_options", + ColumnSelector { options: column_options } + PageSizeSelector { + page_size: *page_size.read(), + options: vec![100, 500, 1000, 5000], + show_all_option: true, + on_change: move |next| { + page_size.set(next); + from.set(0); + }, + } + } + } + + StatusMessage { status_msg } + ActiveFilters { chips: active_chips, on_clear_all: clear_all } + + if let Some(data) = data_to_show { + if data.torrents.is_empty() { + p { + i { "You have no torrents selected by MLM" } + } + } else { + div { + class: "actions actions_torrent", + style: if selected.read().is_empty() { "" } else { "display: flex" }, + for action in [ + TorrentsBulkAction::Refresh, + TorrentsBulkAction::RefreshRelink, + TorrentsBulkAction::Clean, + TorrentsBulkAction::Remove, + ] + { + button { + r#type: "button", + disabled: *loading_action.read(), + onclick: { + let mut loading_action = loading_action; + let mut status_msg = status_msg; + let mut torrents_data = torrents_data; + let mut selected = selected; + move |_| { + let ids: Vec = + selected.read().iter().cloned().collect(); + if ids.is_empty() { + status_msg.set(Some(( + "Select at least one torrent".to_string(), + true, + ))); + return; + } + loading_action.set(true); + status_msg.set(None); + spawn(async move { + match apply_torrents_action(action, ids).await { + Ok(_) => { + status_msg.set(Some(( + action.success_label().to_string(), + false, + ))); + selected.set(BTreeSet::new()); + if let Some(resource) = + torrents_data.as_mut() + { + resource.restart(); + } + } + Err(e) => { + status_msg.set(Some(( + format!( + "{} failed: {e}", + action.label() + ), + true, + ))); + } + } + loading_action.set(false); + }); + } + }, + "{action.label()}" + } + } + } + + TorrentGridTable { + grid_template: show_snapshot.table_grid_template(), + extra_class: None, + pending: pending && cached.read().is_some(), + TorrentsTableHeader { + show: show_snapshot, + sort, + asc, + from, + row_ids: all_row_ids.as_ref().clone(), + selected, + } + for (i, torrent) in data.torrents.iter().enumerate() { + TorrentRow { + key: "{torrent.id}", + torrent: torrent.clone(), + show: show_snapshot, + i, + selected, + last_selected_idx, + all_row_ids: all_row_ids.clone(), + } + } + } + p { class: "faint", + "Showing {data.from} to {data.from + data.torrents.len()} of {data.total}" + } + Pagination { + total: data.total, + from: data.from, + page_size: data.page_size, + on_change: move |new_from| { + from.set(new_from); + }, + } + } + } else if let Some(value) = &value { + if let Some(Err(e)) = &*value.read() { + p { class: "error", "Error: {e}" } + } else { + p { "Loading torrents..." } + } + } else { + p { "Loading torrents..." } + } + } + } +} diff --git a/mlm_web_dioxus/src/torrents/mod.rs b/mlm_web_dioxus/src/torrents/mod.rs new file mode 100644 index 00000000..061fa337 --- /dev/null +++ b/mlm_web_dioxus/src/torrents/mod.rs @@ -0,0 +1,9 @@ +mod components; +mod query; +mod server_fns; +mod types; + +pub use components::TorrentsPage; +pub use server_fns::{apply_torrents_action, get_torrents_data}; +pub(crate) use types::{TorrentsBulkAction, TorrentsPageColumns, TorrentsRow}; +pub use types::{TorrentsData, TorrentsPageFilter, TorrentsPageSort}; diff --git a/mlm_web_dioxus/src/torrents/query.rs b/mlm_web_dioxus/src/torrents/query.rs new file mode 100644 index 00000000..e7037b60 --- /dev/null +++ b/mlm_web_dioxus/src/torrents/query.rs @@ -0,0 +1,256 @@ +use crate::components::{ + PageColumns, build_query_string, encode_query_enum, parse_location_query_pairs, + parse_query_enum, +}; + +use super::{TorrentsPageColumns, TorrentsPageFilter, TorrentsPageSort}; + +type ColumnKey = ( + &'static str, + fn(&TorrentsPageColumns) -> bool, + fn(&mut TorrentsPageColumns, bool), +); + +#[derive(Clone)] +pub(super) struct PageQueryState { + pub(super) query: String, + pub(super) sort: Option, + pub(super) asc: bool, + pub(super) filters: Vec<(TorrentsPageFilter, String)>, + pub(super) from: usize, + pub(super) page_size: usize, + pub(super) show: TorrentsPageColumns, +} + +impl Default for PageQueryState { + fn default() -> Self { + Self { + query: String::new(), + sort: None, + asc: false, + filters: Vec::new(), + from: 0, + page_size: 500, + show: TorrentsPageColumns::default(), + } + } +} + +/// Single source of truth mapping each column to its (query_key, getter, setter). +/// Adding a new column requires only adding one entry here. +const COLUMN_KEYS: &[ColumnKey] = &[ + ( + "category", + |c| c.category, + |c, v| { + c.category = v; + }, + ), + ( + "categories", + |c| c.categories, + |c, v| { + c.categories = v; + }, + ), + ( + "flags", + |c| c.flags, + |c, v| { + c.flags = v; + }, + ), + ( + "edition", + |c| c.edition, + |c, v| { + c.edition = v; + }, + ), + ( + "author", + |c| c.authors, + |c, v| { + c.authors = v; + }, + ), + ( + "narrator", + |c| c.narrators, + |c, v| { + c.narrators = v; + }, + ), + ( + "series", + |c| c.series, + |c, v| { + c.series = v; + }, + ), + ( + "language", + |c| c.language, + |c, v| { + c.language = v; + }, + ), + ( + "size", + |c| c.size, + |c, v| { + c.size = v; + }, + ), + ( + "filetype", + |c| c.filetypes, + |c, v| { + c.filetypes = v; + }, + ), + ( + "linker", + |c| c.linker, + |c, v| { + c.linker = v; + }, + ), + ( + "qbit_category", + |c| c.qbit_category, + |c, v| { + c.qbit_category = v; + }, + ), + ( + "path", + |c| c.path, + |c, v| { + c.path = v; + }, + ), + ( + "created_at", + |c| c.created_at, + |c, v| { + c.created_at = v; + }, + ), + ( + "uploaded_at", + |c| c.uploaded_at, + |c, v| { + c.uploaded_at = v; + }, + ), +]; + +impl PageColumns for TorrentsPageColumns { + fn to_query_value(&self) -> String { + COLUMN_KEYS + .iter() + .filter(|(_, get, _)| get(self)) + .map(|(key, _, _)| *key) + .collect::>() + .join(",") + } + + fn from_query_value(value: &str) -> Self { + let mut cols = TorrentsPageColumns { + category: false, + categories: false, + flags: false, + edition: false, + authors: false, + narrators: false, + series: false, + language: false, + size: false, + filetypes: false, + linker: false, + qbit_category: false, + path: false, + created_at: false, + uploaded_at: false, + }; + for item in value.split(',') { + if let Some((_, _, set)) = COLUMN_KEYS.iter().find(|(key, _, _)| *key == item) { + set(&mut cols, true); + } + } + cols + } +} + +pub(super) fn parse_query_state() -> PageQueryState { + let mut state = PageQueryState::default(); + for (key, value) in parse_location_query_pairs() { + match key.as_str() { + "sort_by" => { + state.sort = parse_query_enum::(&value); + } + "asc" => { + state.asc = value == "true"; + } + "from" => { + if let Ok(v) = value.parse::() { + state.from = v; + } + } + "page_size" => { + if let Ok(v) = value.parse::() { + state.page_size = v; + } + } + "show" => { + state.show = TorrentsPageColumns::from_query_value(&value); + } + "query" => { + state.query = value; + } + _ => { + if let Some(field) = parse_query_enum::(&key) { + state.filters.push((field, value)); + } + } + } + } + state +} + +pub(super) fn build_query_url( + query: &str, + sort: Option, + asc: bool, + filters: &[(TorrentsPageFilter, String)], + from: usize, + page_size: usize, + show: TorrentsPageColumns, +) -> String { + let mut params: Vec<(String, String)> = Vec::new(); + if let Some(sort) = sort.and_then(encode_query_enum) { + params.push(("sort_by".to_string(), sort)); + } + if asc { + params.push(("asc".to_string(), "true".to_string())); + } + if from > 0 { + params.push(("from".to_string(), from.to_string())); + } + if page_size != 500 { + params.push(("page_size".to_string(), page_size.to_string())); + } + if show != TorrentsPageColumns::default() { + params.push(("show".to_string(), show.to_query_value())); + } + if !query.is_empty() { + params.push(("query".to_string(), query.to_string())); + } + for (field, value) in filters { + if let Some(name) = encode_query_enum(*field) { + params.push((name, value.clone())); + } + } + build_query_string(¶ms) +} diff --git a/mlm_web_dioxus/src/torrents/server_fns.rs b/mlm_web_dioxus/src/torrents/server_fns.rs new file mode 100644 index 00000000..7ee6a759 --- /dev/null +++ b/mlm_web_dioxus/src/torrents/server_fns.rs @@ -0,0 +1,529 @@ +use dioxus::prelude::*; + +#[cfg(feature = "server")] +use mlm_core::{ + ContextExt, Torrent as DbTorrent, TorrentKey, + cleaner::clean_torrent, + linker::{refresh_mam_metadata, refresh_metadata_relink}, +}; +#[cfg(feature = "server")] +use mlm_db::{ + ClientStatus, DatabaseExt as _, Flags, Language, LibraryMismatch, MetadataSource, OldCategory, + ids, +}; +#[cfg(feature = "server")] +use std::str::FromStr; +#[cfg(feature = "server")] +use sublime_fuzzy::FuzzySearch; + +#[cfg(feature = "server")] +use crate::utils::format_timestamp_db; + +use super::types::{ + TorrentLibraryMismatch, TorrentsBulkAction, TorrentsData, TorrentsMeta, TorrentsPageColumns, + TorrentsPageFilter, TorrentsPageSort, TorrentsRow, +}; + +#[server] +pub async fn get_torrents_data( + sort: Option, + asc: bool, + filters: Vec<(TorrentsPageFilter, String)>, + from: Option, + page_size: Option, + show: TorrentsPageColumns, +) -> Result { + let context = crate::error::get_context()?; + let db = context.db(); + let abs_url = context + .config() + .await + .audiobookshelf + .as_ref() + .map(|abs| abs.url.clone()); + + let mut from_val = from.unwrap_or(0); + let page_size_val = page_size.unwrap_or(500); + + let r = db + .r_transaction() + .context("r_transaction") + .map_err(|e| ServerFnError::new(e.to_string()))?; + let torrents_iter = r + .scan() + .secondary::(TorrentKey::created_at) + .context("scan") + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let torrents = torrents_iter + .all() + .context("all") + .map_err(|e| ServerFnError::new(e.to_string()))? + .rev(); + + let query = filters + .iter() + .find(|(field, value)| *field == TorrentsPageFilter::Query && !value.is_empty()) + .map(|(_, value)| value.clone()); + + if sort.is_none() && query.is_none() && filters.is_empty() { + let total = r + .len() + .secondary::(TorrentKey::created_at) + .map_err(|e| ServerFnError::new(e.to_string()))? as usize; + if page_size_val > 0 && from_val >= total && total > 0 { + from_val = ((total - 1) / page_size_val) * page_size_val; + } + + let mut rows = Vec::new(); + let limit = if page_size_val == 0 { + usize::MAX + } else { + page_size_val + }; + for torrent in torrents.skip(from_val).take(limit) { + let t = torrent + .context("torrent") + .map_err(|e| ServerFnError::new(e.to_string()))?; + rows.push(convert_torrent_row(&t)); + } + + return Ok(TorrentsData { + torrents: rows, + total, + from: from_val, + page_size: page_size_val, + abs_url, + }); + } + + if sort.is_none() && query.is_none() { + let mut rows = Vec::new(); + let mut total = 0usize; + let limit = if page_size_val == 0 { + usize::MAX + } else { + page_size_val + }; + for torrent in torrents { + let t = torrent + .context("torrent") + .map_err(|e| ServerFnError::new(e.to_string()))?; + if filters + .iter() + .all(|(field, value)| matches_filter(&t, *field, value)) + { + if total >= from_val && rows.len() < limit { + rows.push(convert_torrent_row(&t)); + } + total += 1; + } + } + + return Ok(TorrentsData { + torrents: rows, + total, + from: from_val, + page_size: page_size_val, + abs_url, + }); + } + + let mut filtered_torrents = Vec::new(); + + for torrent in torrents { + let t = torrent + .context("torrent") + .map_err(|e| ServerFnError::new(e.to_string()))?; + + let mut matches = true; + for (field, value) in &filters { + if !matches_filter(&t, *field, value) { + matches = false; + break; + } + } + if !matches { + continue; + } + + let mut score = 0; + if let Some(value) = query.as_deref() { + score += fuzzy_score(value, &t.meta.title); + if show.authors { + for author in &t.meta.authors { + score += fuzzy_score(value, author); + } + } + if show.narrators { + for narrator in &t.meta.narrators { + score += fuzzy_score(value, narrator); + } + } + if show.series { + for s in &t.meta.series { + score += fuzzy_score(value, &s.name); + } + } + if score < 10 { + continue; + } + } + + filtered_torrents.push((t, score)); + } + + if let Some(sort_by) = sort { + filtered_torrents.sort_by(|(a, _), (b, _)| { + let ord = match sort_by { + TorrentsPageSort::Kind => a.meta.media_type.cmp(&b.meta.media_type), + TorrentsPageSort::Category => a + .meta + .cat + .partial_cmp(&b.meta.cat) + .unwrap_or(std::cmp::Ordering::Less), + TorrentsPageSort::Title => a.meta.title.cmp(&b.meta.title), + TorrentsPageSort::Edition => a + .meta + .edition + .as_ref() + .map(|e| e.1) + .cmp(&b.meta.edition.as_ref().map(|e| e.1)) + .then(a.meta.edition.cmp(&b.meta.edition)), + TorrentsPageSort::Authors => a.meta.authors.cmp(&b.meta.authors), + TorrentsPageSort::Narrators => a.meta.narrators.cmp(&b.meta.narrators), + TorrentsPageSort::Series => a + .meta + .series + .cmp(&b.meta.series) + .then(a.meta.media_type.cmp(&b.meta.media_type)), + TorrentsPageSort::Language => a.meta.language.cmp(&b.meta.language), + TorrentsPageSort::Size => a.meta.size.cmp(&b.meta.size), + TorrentsPageSort::Linker => a.linker.cmp(&b.linker), + TorrentsPageSort::QbitCategory => a.category.cmp(&b.category), + TorrentsPageSort::Linked => a.library_path.cmp(&b.library_path), + TorrentsPageSort::CreatedAt => a.created_at.cmp(&b.created_at), + TorrentsPageSort::UploadedAt => a.meta.uploaded_at.cmp(&b.meta.uploaded_at), + }; + if asc { ord } else { ord.reverse() } + }); + } else if query.is_some() { + filtered_torrents.sort_by_key(|(_, score)| -*score); + } + + let total = filtered_torrents.len(); + if page_size_val > 0 && from_val >= total && total > 0 { + from_val = ((total - 1) / page_size_val) * page_size_val; + } + + let mut rows: Vec = filtered_torrents + .into_iter() + .map(|(t, _)| convert_torrent_row(&t)) + .collect(); + + if page_size_val > 0 { + rows = rows + .into_iter() + .skip(from_val) + .take(page_size_val) + .collect(); + } + + Ok(TorrentsData { + torrents: rows, + total, + from: from_val, + page_size: page_size_val, + abs_url, + }) +} + +#[server] +pub async fn apply_torrents_action( + action: TorrentsBulkAction, + torrent_ids: Vec, +) -> Result<(), ServerFnError> { + if torrent_ids.is_empty() { + return Err(ServerFnError::new("No torrents selected")); + } + + let context = crate::error::get_context()?; + + match action { + TorrentsBulkAction::Clean => { + let config = context.config().await; + for id in torrent_ids { + let Some(torrent) = context + .db() + .r_transaction() + .map_err(|e| ServerFnError::new(e.to_string()))? + .get() + .primary::(id) + .map_err(|e| ServerFnError::new(e.to_string()))? + else { + return Err(ServerFnError::new("Could not find torrent")); + }; + clean_torrent(&config, context.db(), torrent, true, &context.events) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + } + } + TorrentsBulkAction::Refresh => { + let config = context.config().await; + let mam = context + .mam() + .map_err(|e| ServerFnError::new(e.to_string()))?; + for id in torrent_ids { + refresh_mam_metadata(&config, context.db(), &mam, id, &context.events) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + } + } + TorrentsBulkAction::RefreshRelink => { + let config = context.config().await; + let mam = context + .mam() + .map_err(|e| ServerFnError::new(e.to_string()))?; + for id in torrent_ids { + refresh_metadata_relink(&config, context.db(), &mam, id, &context.events) + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + } + } + TorrentsBulkAction::Remove => { + let (_guard, rw) = context + .db() + .rw_async() + .await + .map_err(|e| ServerFnError::new(e.to_string()))?; + for id in torrent_ids { + let Some(torrent) = rw + .get() + .primary::(id) + .map_err(|e| ServerFnError::new(e.to_string()))? + else { + return Err(ServerFnError::new("Could not find torrent")); + }; + rw.remove(torrent) + .map_err(|e| ServerFnError::new(e.to_string()))?; + } + rw.commit().map_err(|e| ServerFnError::new(e.to_string()))?; + } + } + + Ok(()) +} + +#[cfg(feature = "server")] +fn matches_filter(t: &DbTorrent, field: TorrentsPageFilter, value: &str) -> bool { + match field { + TorrentsPageFilter::Kind => t.meta.media_type.as_str() == value, + TorrentsPageFilter::Category => { + if value.is_empty() { + t.meta.cat.is_none() + } else if let Some(cat) = &t.meta.cat { + let cats = value + .split(',') + .filter_map(|id| id.parse().ok()) + .filter_map(OldCategory::from_one_id) + .collect::>(); + cats.contains(cat) || cat.as_str() == value + } else { + false + } + } + TorrentsPageFilter::Categories => { + if value.is_empty() { + t.meta.categories.is_empty() + } else { + value + .split(',') + .all(|cat| t.meta.categories.iter().any(|c| c.as_str() == cat.trim())) + } + } + TorrentsPageFilter::Flags => { + if value.is_empty() { + t.meta.flags.is_none_or(|f| f.0 == 0) + } else if let Some(flags) = &t.meta.flags { + let flags = Flags::from_bitfield(flags.0); + match value { + "violence" => flags.violence == Some(true), + "explicit" => flags.explicit == Some(true), + "some_explicit" => flags.some_explicit == Some(true), + "language" => flags.crude_language == Some(true), + "abridged" => flags.abridged == Some(true), + "lgbt" => flags.lgbt == Some(true), + _ => false, + } + } else { + false + } + } + TorrentsPageFilter::Title => t.meta.title == value, + TorrentsPageFilter::Author => { + if value.is_empty() { + t.meta.authors.is_empty() + } else { + t.meta.authors.contains(&value.to_string()) + } + } + TorrentsPageFilter::Narrator => { + if value.is_empty() { + t.meta.narrators.is_empty() + } else { + t.meta.narrators.contains(&value.to_string()) + } + } + TorrentsPageFilter::Series => { + if value.is_empty() { + t.meta.series.is_empty() + } else { + t.meta.series.iter().any(|s| s.name == value) + } + } + TorrentsPageFilter::Language => { + if value.is_empty() { + t.meta.language.is_none() + } else { + t.meta.language == Language::from_str(value).ok() + } + } + TorrentsPageFilter::Filetype => t.meta.filetypes.iter().any(|f| f == value), + TorrentsPageFilter::Linker => { + if value.is_empty() { + t.linker.is_none() + } else { + t.linker.as_deref() == Some(value) + } + } + TorrentsPageFilter::QbitCategory => { + if value.is_empty() { + t.category.is_none() + } else { + t.category.as_deref() == Some(value) + } + } + TorrentsPageFilter::Linked => t.library_path.is_some() == (value == "true"), + TorrentsPageFilter::LibraryMismatch => { + if value.is_empty() { + t.library_mismatch.is_some() + } else { + match t.library_mismatch { + Some(LibraryMismatch::NewLibraryDir(ref path)) => { + value == "new_library" || value == path.to_string_lossy().as_ref() + } + Some(LibraryMismatch::NewPath(ref path)) => { + value == "new_path" || value == path.to_string_lossy().as_ref() + } + Some(LibraryMismatch::NoLibrary) => value == "no_library", + None => false, + } + } + } + TorrentsPageFilter::ClientStatus => match t.client_status { + Some(ClientStatus::NotInClient) => value == "not_in_client", + Some(ClientStatus::RemovedFromTracker) => value == "removed_from_tracker", + None => false, + }, + TorrentsPageFilter::Abs => t.meta.ids.contains_key(ids::ABS) == (value == "true"), + TorrentsPageFilter::Query => true, + TorrentsPageFilter::Source => match value { + "mam" => t.meta.source == MetadataSource::Mam, + "manual" => t.meta.source == MetadataSource::Manual, + "file" => t.meta.source == MetadataSource::File, + "match" => t.meta.source == MetadataSource::Match, + _ => false, + }, + TorrentsPageFilter::Metadata => { + if value.is_empty() { + !t.meta.ids.is_empty() + } else { + t.meta.ids.contains_key(value) + || t.meta + .ids + .iter() + .any(|(key, id)| key == value || id == value) + } + } + } +} + +#[cfg(feature = "server")] +fn convert_torrent_row(t: &DbTorrent) -> TorrentsRow { + let flags = Flags::from_bitfield(t.meta.flags.map_or(0, |f| f.0)); + let flag_values = crate::utils::flags_to_strings(&flags); + + let (cat_name, cat_id) = if let Some(cat) = &t.meta.cat { + (cat.as_str().to_string(), Some(cat.as_id().to_string())) + } else { + ("N/A".to_string(), None) + }; + + let client_status = t.client_status.as_ref().map(|status| match status { + ClientStatus::RemovedFromTracker => "removed_from_tracker".to_string(), + ClientStatus::NotInClient => "not_in_client".to_string(), + }); + + let library_mismatch = t.library_mismatch.as_ref().map(|mismatch| match mismatch { + LibraryMismatch::NewLibraryDir(path) => { + TorrentLibraryMismatch::NewLibraryDir(path.to_string_lossy().to_string()) + } + LibraryMismatch::NewPath(path) => { + TorrentLibraryMismatch::NewPath(path.to_string_lossy().to_string()) + } + LibraryMismatch::NoLibrary => TorrentLibraryMismatch::NoLibrary, + }); + + TorrentsRow { + id: t.id.clone(), + mam_id: t.mam_id, + meta: TorrentsMeta { + title: t.meta.title.clone(), + media_type: t.meta.media_type.as_str().to_string(), + cat_name, + cat_id, + categories: t + .meta + .categories + .iter() + .map(|c| c.as_str().to_string()) + .collect(), + flags: flag_values, + edition: t.meta.edition.as_ref().map(|(edition, _)| edition.clone()), + authors: t.meta.authors.clone(), + narrators: t.meta.narrators.clone(), + series: t + .meta + .series + .iter() + .map(|series| crate::dto::Series { + name: series.name.clone(), + entries: series.entries.to_string(), + }) + .collect(), + language: t.meta.language.map(|l| l.to_str().to_string()), + size: t.meta.size.to_string(), + filetypes: t.meta.filetypes.clone(), + }, + linker: t.linker.clone(), + category: t.category.clone(), + library_path: t + .library_path + .as_ref() + .map(|path| path.to_string_lossy().to_string()), + library_mismatch, + client_status, + linked: t.library_path.is_some(), + created_at: format_timestamp_db(&t.created_at), + uploaded_at: format_timestamp_db(&t.meta.uploaded_at), + abs_id: t.meta.ids.get(ids::ABS).cloned(), + } +} + +#[cfg(feature = "server")] +fn fuzzy_score(query: &str, target: &str) -> isize { + FuzzySearch::new(query, target) + .case_insensitive() + .best_match() + .map_or(0, |m: sublime_fuzzy::Match| m.score()) +} diff --git a/mlm_web_dioxus/src/torrents/types.rs b/mlm_web_dioxus/src/torrents/types.rs new file mode 100644 index 00000000..cc0c757b --- /dev/null +++ b/mlm_web_dioxus/src/torrents/types.rs @@ -0,0 +1,229 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum TorrentsPageSort { + Kind, + Category, + Title, + Edition, + Authors, + Narrators, + Series, + Language, + Size, + Linker, + QbitCategory, + Linked, + CreatedAt, + UploadedAt, +} + +#[derive(Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum TorrentsPageFilter { + Kind, + Category, + Categories, + Flags, + Title, + Author, + Narrator, + Series, + Language, + Filetype, + Linker, + QbitCategory, + Linked, + LibraryMismatch, + ClientStatus, + Abs, + Query, + Source, + Metadata, +} + +#[derive(Clone, Copy, PartialEq, Serialize, Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum TorrentsBulkAction { + Refresh, + RefreshRelink, + Clean, + Remove, +} + +impl TorrentsBulkAction { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Refresh => "refresh metadata", + Self::RefreshRelink => "refresh metadata and relink", + Self::Clean => "clean torrent", + Self::Remove => "remove torrent from MLM", + } + } + + pub(crate) fn success_label(self) -> &'static str { + match self { + Self::Refresh => "Refreshed metadata", + Self::RefreshRelink => "Refreshed metadata and relinked", + Self::Clean => "Cleaned torrents", + Self::Remove => "Removed torrents", + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)] +pub struct TorrentsPageColumns { + pub category: bool, + pub categories: bool, + pub flags: bool, + pub edition: bool, + pub authors: bool, + pub narrators: bool, + pub series: bool, + pub language: bool, + pub size: bool, + pub filetypes: bool, + pub linker: bool, + pub qbit_category: bool, + pub path: bool, + pub created_at: bool, + pub uploaded_at: bool, +} + +impl Default for TorrentsPageColumns { + fn default() -> Self { + Self { + category: false, + categories: false, + flags: false, + edition: false, + authors: true, + narrators: true, + series: true, + language: false, + size: true, + filetypes: true, + linker: false, + qbit_category: false, + path: false, + created_at: true, + uploaded_at: false, + } + } +} + +impl TorrentsPageColumns { + pub(crate) fn table_grid_template(self) -> String { + let mut cols = vec!["30px", if self.category { "130px" } else { "89px" }]; + if self.categories { + cols.push("1fr"); + } + if self.flags { + cols.push("60px"); + } + cols.push("2fr"); + if self.edition { + cols.push("80px"); + } + if self.authors { + cols.push("1fr"); + } + if self.narrators { + cols.push("1fr"); + } + if self.series { + cols.push("1fr"); + } + if self.language { + cols.push("100px"); + } + if self.size { + cols.push("81px"); + } + if self.filetypes { + cols.push("100px"); + } + if self.linker { + cols.push("130px"); + } + if self.qbit_category { + cols.push("100px"); + } + cols.push(if self.path { "2fr" } else { "72px" }); + if self.created_at { + cols.push("157px"); + } + if self.uploaded_at { + cols.push("157px"); + } + cols.join(" ") + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub enum TorrentLibraryMismatch { + NewLibraryDir(String), + NewPath(String), + NoLibrary, +} + +impl TorrentLibraryMismatch { + pub(crate) fn filter_value(&self) -> &'static str { + match self { + Self::NewLibraryDir(_) => "new_library", + Self::NewPath(_) => "new_path", + Self::NoLibrary => "no_library", + } + } + + pub(crate) fn title(&self) -> String { + match self { + Self::NewLibraryDir(path) => format!("Wanted library dir: {path}"), + Self::NewPath(path) => format!("Wanted library path: {path}"), + Self::NoLibrary => "No longer wanted in library".to_string(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TorrentsMeta { + pub title: String, + pub media_type: String, + pub cat_name: String, + pub cat_id: Option, + pub categories: Vec, + pub flags: Vec, + pub edition: Option, + pub authors: Vec, + pub narrators: Vec, + pub series: Vec, + pub language: Option, + pub size: String, + pub filetypes: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct TorrentsRow { + pub id: String, + pub mam_id: Option, + pub meta: TorrentsMeta, + pub linker: Option, + pub category: Option, + pub library_path: Option, + pub library_mismatch: Option, + pub client_status: Option, + pub linked: bool, + pub created_at: String, + pub uploaded_at: String, + pub abs_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq)] +pub struct TorrentsData { + pub torrents: Vec, + pub total: usize, + pub from: usize, + pub page_size: usize, + pub abs_url: Option, +} diff --git a/mlm_web_dioxus/src/utils.rs b/mlm_web_dioxus/src/utils.rs new file mode 100644 index 00000000..67e6f6c3 --- /dev/null +++ b/mlm_web_dioxus/src/utils.rs @@ -0,0 +1,97 @@ +#[cfg(feature = "server")] +use time::UtcOffset; +#[cfg(feature = "server")] +use time::macros::format_description; + +#[cfg(feature = "server")] +const DATETIME_FORMAT: &[time::format_description::BorrowedFormatItem<'static>] = + format_description!("[year]-[month]-[day] [hour]:[minute]:[second]"); + +#[cfg(feature = "server")] +pub fn format_timestamp(ts: &mlm_core::Timestamp) -> String { + ts.0.to_offset(UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC)) + .replace_nanosecond(0) + .unwrap_or_else(|_| ts.0.into()) + .format(DATETIME_FORMAT) + .unwrap_or_default() +} + +#[cfg(feature = "server")] +pub(crate) trait DbTimeValue { + fn as_timestamp(&self) -> Option<&mlm_db::Timestamp>; +} + +#[cfg(feature = "server")] +impl DbTimeValue for &T { + fn as_timestamp(&self) -> Option<&mlm_db::Timestamp> { + (*self).as_timestamp() + } +} + +#[cfg(feature = "server")] +impl DbTimeValue for mlm_db::Timestamp { + fn as_timestamp(&self) -> Option<&mlm_db::Timestamp> { + Some(self) + } +} + +#[cfg(feature = "server")] +impl DbTimeValue for Option { + fn as_timestamp(&self) -> Option<&mlm_db::Timestamp> { + self.as_ref() + } +} + +#[cfg(feature = "server")] +pub(crate) fn format_timestamp_db(ts: &T) -> String { + let Some(ts) = ts.as_timestamp() else { + return String::new(); + }; + let dt = + ts.0.to_offset(UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC)); + dt.replace_nanosecond(0) + .unwrap_or(dt) + .format(DATETIME_FORMAT) + .unwrap_or_default() +} + +#[cfg(feature = "server")] +pub fn format_datetime(dt: &time::OffsetDateTime) -> String { + dt.to_offset(UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC)) + .replace_nanosecond(0) + .unwrap_or(*dt) + .format(DATETIME_FORMAT) + .unwrap_or_default() +} + +#[cfg(feature = "server")] +pub fn flags_to_strings(flags: &mlm_db::Flags) -> Vec { + [ + (flags.crude_language, "language"), + (flags.violence, "violence"), + (flags.some_explicit, "some_explicit"), + (flags.explicit, "explicit"), + (flags.abridged, "abridged"), + (flags.lgbt, "lgbt"), + ] + .into_iter() + .filter(|(val, _)| *val == Some(true)) + .map(|(_, name)| name.to_string()) + .collect() +} + +pub fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} diff --git a/package.json b/package.json index 0d8c9ec5..df8a9dd8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184", "devDependencies": { + "@playwright/test": "^1.58.2", "typescript": "^5.8.3" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..4d0f1bea --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + globalSetup: './tests/e2e/setup.ts', + globalTeardown: './tests/e2e/teardown.ts', + timeout: 30_000, + use: { + baseURL: 'http://localhost:3998', + headless: true, + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'firefox', use: { browserName: 'firefox' } }, + { name: 'webkit', use: { browserName: 'webkit' } }, + ], + // Allow initial server startup time + expect: { timeout: 15_000 }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20eacc12..6996166b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,35 @@ importers: .: devDependencies: + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 typescript: specifier: ^5.8.3 version: 5.8.3 packages: + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + typescript@5.8.3: resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} @@ -21,4 +44,19 @@ packages: snapshots: + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + + fsevents@2.3.2: + optional: true + + playwright-core@1.58.2: {} + + playwright@1.58.2: + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + typescript@5.8.3: {} diff --git a/server/Cargo.toml b/server/Cargo.toml index 10183a0a..bdf5ae4a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -20,6 +20,7 @@ mlm_core = { path = "../mlm_core" } mlm_db = { path = "../mlm_db" } mlm_mam = { path = "../mlm_mam" } mlm_web_askama = { path = "../mlm_web_askama" } +mlm_web_dioxus = { path = "../mlm_web_dioxus", features = ["server"] } native_db = { git = "https://github.com/StirlingMouse/native_db.git", branch = "0.8.x" } once_cell = "1.21.3" qbit = { git = "https://github.com/StirlingMouse/qbittorrent-webui-api.git" } diff --git a/server/assets/icons/mediatypes/Audiobook.png b/server/assets/icons/mediatypes/Audiobook.png new file mode 100644 index 00000000..e5e9eaf5 Binary files /dev/null and b/server/assets/icons/mediatypes/Audiobook.png differ diff --git a/server/assets/icons/mediatypes/AudiobookF.png b/server/assets/icons/mediatypes/AudiobookF.png new file mode 100644 index 00000000..f4fb266f Binary files /dev/null and b/server/assets/icons/mediatypes/AudiobookF.png differ diff --git a/server/assets/icons/mediatypes/AudiobookNF.png b/server/assets/icons/mediatypes/AudiobookNF.png new file mode 100644 index 00000000..635d72fb Binary files /dev/null and b/server/assets/icons/mediatypes/AudiobookNF.png differ diff --git a/server/assets/icons/mediatypes/Comics.png b/server/assets/icons/mediatypes/Comics.png new file mode 100644 index 00000000..6254d1e7 Binary files /dev/null and b/server/assets/icons/mediatypes/Comics.png differ diff --git a/server/assets/icons/mediatypes/ComicsF.png b/server/assets/icons/mediatypes/ComicsF.png new file mode 100644 index 00000000..84d06921 Binary files /dev/null and b/server/assets/icons/mediatypes/ComicsF.png differ diff --git a/server/assets/icons/mediatypes/ComicsNF.png b/server/assets/icons/mediatypes/ComicsNF.png new file mode 100644 index 00000000..93af7c60 Binary files /dev/null and b/server/assets/icons/mediatypes/ComicsNF.png differ diff --git a/server/assets/icons/mediatypes/EBook.png b/server/assets/icons/mediatypes/EBook.png new file mode 100644 index 00000000..eef7c763 Binary files /dev/null and b/server/assets/icons/mediatypes/EBook.png differ diff --git a/server/assets/icons/mediatypes/EBookF.png b/server/assets/icons/mediatypes/EBookF.png new file mode 100644 index 00000000..c4eaf6c0 Binary files /dev/null and b/server/assets/icons/mediatypes/EBookF.png differ diff --git a/server/assets/icons/mediatypes/EBookNF.png b/server/assets/icons/mediatypes/EBookNF.png new file mode 100644 index 00000000..f566c809 Binary files /dev/null and b/server/assets/icons/mediatypes/EBookNF.png differ diff --git a/server/assets/icons/mediatypes/Manga.png b/server/assets/icons/mediatypes/Manga.png new file mode 100644 index 00000000..3de454ea Binary files /dev/null and b/server/assets/icons/mediatypes/Manga.png differ diff --git a/server/assets/icons/mediatypes/MangaF.png b/server/assets/icons/mediatypes/MangaF.png new file mode 100644 index 00000000..ae0ae1c5 Binary files /dev/null and b/server/assets/icons/mediatypes/MangaF.png differ diff --git a/server/assets/icons/mediatypes/MangaNF.png b/server/assets/icons/mediatypes/MangaNF.png new file mode 100644 index 00000000..4e157377 Binary files /dev/null and b/server/assets/icons/mediatypes/MangaNF.png differ diff --git a/server/assets/icons/mediatypes/Music.png b/server/assets/icons/mediatypes/Music.png new file mode 100644 index 00000000..e9111a02 Binary files /dev/null and b/server/assets/icons/mediatypes/Music.png differ diff --git a/server/assets/icons/mediatypes/MusicF.png b/server/assets/icons/mediatypes/MusicF.png new file mode 100644 index 00000000..72b8a397 Binary files /dev/null and b/server/assets/icons/mediatypes/MusicF.png differ diff --git a/server/assets/icons/mediatypes/MusicNF.png b/server/assets/icons/mediatypes/MusicNF.png new file mode 100644 index 00000000..0cc8b349 Binary files /dev/null and b/server/assets/icons/mediatypes/MusicNF.png differ diff --git a/server/assets/icons/mediatypes/Periodicals.png b/server/assets/icons/mediatypes/Periodicals.png new file mode 100644 index 00000000..8e32debc Binary files /dev/null and b/server/assets/icons/mediatypes/Periodicals.png differ diff --git a/server/assets/icons/mediatypes/PeriodicalsAudio.png b/server/assets/icons/mediatypes/PeriodicalsAudio.png new file mode 100644 index 00000000..c83db49e Binary files /dev/null and b/server/assets/icons/mediatypes/PeriodicalsAudio.png differ diff --git a/server/assets/icons/mediatypes/PeriodicalsAudioF.png b/server/assets/icons/mediatypes/PeriodicalsAudioF.png new file mode 100644 index 00000000..05236c20 Binary files /dev/null and b/server/assets/icons/mediatypes/PeriodicalsAudioF.png differ diff --git a/server/assets/icons/mediatypes/PeriodicalsAudioNF.png b/server/assets/icons/mediatypes/PeriodicalsAudioNF.png new file mode 100644 index 00000000..6498edfa Binary files /dev/null and b/server/assets/icons/mediatypes/PeriodicalsAudioNF.png differ diff --git a/server/assets/icons/mediatypes/PeriodicalsF.png b/server/assets/icons/mediatypes/PeriodicalsF.png new file mode 100644 index 00000000..5600daa8 Binary files /dev/null and b/server/assets/icons/mediatypes/PeriodicalsF.png differ diff --git a/server/assets/icons/mediatypes/PeriodicalsNF.png b/server/assets/icons/mediatypes/PeriodicalsNF.png new file mode 100644 index 00000000..6efddcf3 Binary files /dev/null and b/server/assets/icons/mediatypes/PeriodicalsNF.png differ diff --git a/server/assets/icons/mediatypes/Radio.png b/server/assets/icons/mediatypes/Radio.png new file mode 100644 index 00000000..4194b6e8 Binary files /dev/null and b/server/assets/icons/mediatypes/Radio.png differ diff --git a/server/assets/icons/mediatypes/RadioF.png b/server/assets/icons/mediatypes/RadioF.png new file mode 100644 index 00000000..e530bf1b Binary files /dev/null and b/server/assets/icons/mediatypes/RadioF.png differ diff --git a/server/assets/icons/mediatypes/RadioNF.png b/server/assets/icons/mediatypes/RadioNF.png new file mode 100644 index 00000000..aceefe5d Binary files /dev/null and b/server/assets/icons/mediatypes/RadioNF.png differ diff --git a/server/assets/style.css b/server/assets/style.css index 49b35cfa..7f618cfb 100644 --- a/server/assets/style.css +++ b/server/assets/style.css @@ -1,14 +1,14 @@ body { font-family: Arial, sans-serif; - --color-1: #2a2438; - --color-2: #352f44; - --color-3: #5c5470; - --color-4: #dbd8e3; - - --background: var(--color-1); - --above: var(--color-2); - --text-faint: var(--color-3); - --text: var(--color-4); + --color-1: #2a2438; + --color-2: #352f44; + --color-3: #5c5470; + --color-4: #dbd8e3; + + --background: var(--color-1); + --above: var(--color-2); + --text-faint: var(--color-3); + --text: var(--color-4); --accent: hsl(331.8, 91.3%, 45%); --accent-above: hsl(331.8, 91.3%, 55%); --warn: #e08067; @@ -17,6 +17,12 @@ body { color: var(--text); } +#main>nav, +.links.links { + display: flex; + gap: 4px; +} + a { color: currentColor; text-decoration-color: transparent; @@ -24,7 +30,8 @@ a { &:hover { text-decoration-color: currentColor; } - &:focus-ring { + + &:focus-visible { text-decoration-color: currentColor; } } @@ -35,7 +42,7 @@ ul { padding-left: 1em; } -nav > a { +nav>a { padding: 4px; background: var(--above); } @@ -71,13 +78,31 @@ button { color: var(--warn); } + &.link { + display: inline; + padding: 0; + background: transparent; + text-align: left; + + &&:hover { + background: transparent; + text-decoration: underline; + } + + &:has(+ .link)::after { + content: ", "; + display: inline-block; + padding-right: 4px; + } + } + &.icon, &:has(> img:only-child) { padding: 4px; width: 28px; height: 28px; background: transparent; - + img { width: 20px; height: 20px; @@ -87,13 +112,14 @@ button { &&:hover { background: var(--color-3); } - &&:focus-ring { + + &&:focus-visible { background: var(--color-3); } } form { - + textarea { appearance: none; padding: 4px 8px; @@ -109,6 +135,7 @@ form { outline: 2px solid var(--accent); } } + input[type=text] { appearance: none; padding: 4px 8px; @@ -124,6 +151,7 @@ form { outline: 2px solid var(--accent); } } + input[type=number] { appearance: none; padding: 4px 8px; @@ -156,14 +184,15 @@ form { cursor: pointer; &::after { - content: "⨯"; - transform: translateY(-2px); + content: "⨯"; + transform: translateY(-2px); } &:hover { background: var(--color-3); } - &:focus-ring { + + &:focus-visible { background: var(--color-3); } } @@ -207,7 +236,7 @@ summary { display: flex; gap: 4px; - & > div { + &>div { display: flex; gap: 4px; flex-wrap: wrap; @@ -217,6 +246,7 @@ summary { padding: 2px 4px; background: var(--above); border-radius: 2px; + &:has(:checked) { background: var(--accent); } @@ -227,6 +257,85 @@ summary { } } +.column_selector { + position: relative; +} + +.column_selector_dropdown { + position: relative; +} + +.column_selector_backdrop { + position: fixed; + inset: 0; + z-index: 39; + background: transparent; +} + +.column_selector_trigger { + position: relative; + z-index: 41; + padding: 2px 8px; + border: none; + border-radius: 2px; + color: var(--text); + background: var(--above); + white-space: nowrap; +} + +.column_selector_trigger:hover, +.column_selector_trigger:focus-visible { + background: var(--color-3); +} + +.column_selector_menu { + position: absolute; + z-index: 40; + top: calc(100% + 8px); + left: 0; + display: grid; + gap: 4px; + min-width: 230px; + max-height: min(55vh, 340px); + overflow: auto; + padding: 8px; + border: 1px solid var(--color-3); + border-radius: 6px; + background: var(--background); + box-shadow: 0 8px 20px color-mix(in srgb, black 32%, transparent); + transform-origin: top left; + animation: column-menu-in 150ms ease-out; +} + +.column_selector_option { + display: grid; + grid-template-columns: min-content 1fr; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 4px; +} + +.column_selector_option input { + display: block; +} + +.column_selector_option:hover { + background: var(--color-3); +} + +@keyframes column-menu-in { + from { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + .pagination { position: sticky; bottom: 0; @@ -239,10 +348,11 @@ summary { border-top: 1px solid currentColor; background: var(--background); - > div { + >div { display: flex; gap: 4px; } + a { display: flex; align-items: center; @@ -257,21 +367,25 @@ summary { text-decoration: none; background: var(--color-3); } - &:focus-ring { + + &:focus-visible { text-decoration: none; background: var(--color-3); } } + .active { background: var(--accent); &:hover { background: var(--accent-above); } - &:focus-ring { + + &:focus-visible { background: var(--accent-above); } } + .disabled { color: var(--color-3); background: var(--above) !important; @@ -281,14 +395,15 @@ summary { .table { display: grid; --alternate: var(--above); - word-break: break-word; + overflow-wrap: break-word; - & > .header, & > div { + &>.header, + &>div { display: block; padding: 4px; } - & > .header { + &>.header { position: sticky; top: 0; font-weight: bold; @@ -299,47 +414,52 @@ summary { .table2 { --alternate: var(--above); - word-break: break-word; + overflow-wrap: break-word; &.MaMTorrentsTable { margin: 0 -8px; } - &.MaMTorrentsTable > div { + &.MaMTorrentsTable>div { grid-template-columns: 72px 54px 1fr 32px 84px 130px 64px; - & > div { + &>div { padding: 8px 4px; } - & > div:nth-child(1n+4) { + + &>div:nth-child(1n+4) { text-align: center; } - & > div:first-of-type { + + &>div:first-of-type { padding-left: 12px; } - & > div:last-of-type { + + &>div:last-of-type { text-align: right; padding-right: 12px; } } - & > div { + &>div { display: grid; &&&:first-of-type { align-items: end; background: var(--background); } + &:nth-child(even) { background: var(--alternate); } - & > .header, & > div { + &>.header, + &>div { display: block; padding: 4px; } - & > .header { + &>.header { position: sticky; top: 0; font-weight: bold; @@ -348,11 +468,83 @@ summary { } } - &:not(.nohover) > div:hover { + &:not(.nohover)>div:hover { background: var(--color-3); } } +.TorrentsTable>.torrents-grid-row { + grid-template-columns: var(--torrents-grid); +} + +.TorrentsTable.is-refreshing { + position: relative; +} + +.TorrentsTable.is-refreshing>.torrents-grid-row { + animation: stale-table-fade 500ms ease 500ms forwards; +} + +.stale-refresh-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + opacity: 0; + animation: stale-overlay-reveal 1ms linear 1000ms forwards; +} + +.stale-refresh-spinner { + width: 36px; + height: 36px; + border: 3px solid var(--color-3); + border-top-color: var(--accent); + border-radius: 50%; + animation: stale-spinner-spin 900ms linear infinite; +} + +@keyframes stale-table-fade { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} + +@keyframes stale-overlay-reveal { + to { + opacity: 1; + } +} + +@keyframes stale-spinner-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .column_selector_menu, + .TorrentsTable.is-refreshing>.torrents-grid-row, + .stale-refresh-overlay, + .stale-refresh-spinner { + animation: none !important; + transition-duration: 0s !important; + } + + .column_selector_menu { + transform: none; + } + + .stale-refresh-overlay { + opacity: 1; + } +} + .list_item { display: grid; grid-template-columns: auto 1fr; @@ -362,12 +554,15 @@ summary { img { width: 64px; } + h3 { margin: 0; } + p { margin: 0.5em 0; } + .author { margin-top: 0; font-style: italic; @@ -381,9 +576,11 @@ summary { .faint { opacity: 0.8; } + .missing { color: var(--warn); } + .warn { color: var(--warn); } @@ -394,12 +591,15 @@ summary { h3 { margin-bottom: 0; } + h4 { margin-bottom: 0; } + .string { color: #b5bd68; } + .num { color: #de935f; } @@ -423,7 +623,260 @@ summary { border-radius: 4px; background-color: #aa86b72e; - & + & { + &+& { margin-left: 4px; } } + +.loading-indicator { + display: inline-block; + padding: 4px 8px; + margin-bottom: 8px; + font-style: italic; + color: var(--text-faint); + background: var(--above); + border-radius: 2px; +} + +.torrent-detail-grid { + display: grid; + grid-template-columns: 1fr 2fr; + grid-template-areas: + "side main" + "side description" + "below below"; + gap: 1em; +} + +.torrent-side { + grid-area: side; +} + +.torrent-main { + grid-area: main; +} + +.torrent-description { + grid-area: description; +} + +.torrent-below { + grid-area: below; +} + +.metadata-table { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.5em; +} + +.metadata-table dt { + font-weight: bold; +} + +.metadata-table dd { + margin: 0; +} + +.pill { + display: inline-block; + padding: 0.2em 0.5em; + margin: 0.2em; + background: var(--above); + border-radius: 4px; +} + +.torrent-detail-page .btn { + display: inline-block; + border: 1px solid var(--color-3); + text-decoration: none; +} + +.torrent-detail-page .option_group { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + align-items: center; +} + +.torrent-detail-page .option_group label { + display: flex; + align-items: center; + gap: 0.3em; +} + +.torrent-detail-page .option_group input { + display: inline; +} + +@media (max-width: 768px) { + .torrent-detail-grid { + grid-template-columns: 1fr; + grid-template-areas: + "main" + "side" + "description" + "below"; + } +} + +.search-page .search-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 8px; +} + +.search-page .search-controls input[type="text"], +.search-page .search-controls input[type="number"] { + width: max(220px, 20vw); +} + +.Torrents { + --alternate: var(--above); + overflow-wrap: break-word; + + &.Torrents { + margin: 0 -8px; + } + + .TorrentRow { + grid-template-areas: "category icons main download files uploaded stats"; + grid-template-columns: 64px 54px 1fr 32px 84px 130px 72px; + gap: 4px; + padding: 4px 8px; + + @media (max-width: 750px) { + grid-template-areas: "category main main" "icons main main" "icons files stats" "icons uploaded uploaded"; + grid-template-columns: 64px 1fr auto; + grid-template-rows: auto auto auto auto; + } + + &>div { + padding: 4px; + overflow: hidden; + } + + &>div:nth-child(n+2):nth-child(-n+7) { + display: flex; + flex-direction: column; + gap: 4px; + } + + &>div:nth-child(n+4):nth-child(-n+7) { + @media (max-width: 750px) { + flex-direction: row; + } + } + + &>div:nth-child(1n+4) { + text-align: center; + } + + &>div:last-of-type { + .icon-row { + justify-content: end; + } + } + + .media-icon { + width: 48px; + } + } + + &>div { + display: grid; + + &&&:first-of-type { + /* align-items: end; */ + background: var(--background); + } + + &:nth-child(even) { + background: var(--alternate); + } + + &>.header, + &>div { + display: block; + padding: 4px; + } + + &>.header { + position: sticky; + top: 0; + font-weight: bold; + border-bottom: 1px solid currentColor; + background: var(--background); + } + } + + &:not(.nohover)>div:hover { + background: var(--color-3); + } +} + +.media-icon { + width: 48px; + height: 48px; +} + +.CategoryPills { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.CategoryPill { + display: inline-block; + padding: 2px 4px; + border: 2px solid var(--color-3); + border-radius: 10px; + + &.old { + background-color: var(--color-3); + } +} + +.TorrentIcons { + display: flex; + flex-wrap: wrap; + gap: 4px; + + img { + width: 20px; + height: 20px; + } +} + +.icon-row { + display: flex; + align-items: center; + gap: var(--spacing, 4px); + + &>span:has(svg) { + display: contents; + } +} + + +.status-message { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin-bottom: 8px; + border-radius: 4px; + border: 1px solid transparent; +} + +.status-message.error { + color: var(--warn); + border-color: var(--warn); +} + +.status-message.success { + color: #6fba6f; + border-color: #6fba6f; +} diff --git a/server/src/bin/create_test_db.rs b/server/src/bin/create_test_db.rs new file mode 100644 index 00000000..15c43148 --- /dev/null +++ b/server/src/bin/create_test_db.rs @@ -0,0 +1,255 @@ +/// Creates a test database with fake data for e2e Playwright tests. +/// Usage: create_test_db +use mlm_db::{ + DuplicateTorrent, ErroredTorrent, ErroredTorrentId, Event, EventType, MODELS, MainCat, + MediaType, MetadataSource, SelectedTorrent, Series, SeriesEntries, SeriesEntry, Size, + Timestamp, Torrent, TorrentCost, TorrentMeta, Uuid, migrate, +}; +use native_db::Builder; +use std::collections::BTreeMap; +use std::env; +use std::path::PathBuf; +use time::Duration; + +fn title_search(title: &str) -> String { + title.to_lowercase() +} + +fn main() -> anyhow::Result<()> { + let path: PathBuf = env::args() + .nth(1) + .expect("Usage: create_test_db ") + .into(); + + if path.exists() { + std::fs::remove_file(&path)?; + } + + let db = Builder::new().create(&MODELS, &path)?; + migrate(&db)?; + + let rw = db.rw_transaction()?; + let base_timestamp = Timestamp::now(); + let timestamp_with_offset = + |offset_seconds: i64| Timestamp::from(base_timestamp.0 + Duration::seconds(offset_seconds)); + + // 35 torrents with varied metadata for pagination, sorting, and filter tests + let authors_pool: &[&[&str]] = &[ + &["Brandon Sanderson"], + &["Patrick Rothfuss"], + &["Robin Hobb"], + &["Terry Pratchett", "Neil Gaiman"], + &["N.K. Jemisin"], + &["Joe Abercrombie"], + &["Ursula K. Le Guin"], + ]; + let narrators_pool: &[&str] = &[ + "Michael Kramer", + "Kate Reading", + "Tim Gerard Reynolds", + "Nick Podehl", + ]; + let series_pool: &[Option<(&str, f32)>] = &[ + Some(("The Stormlight Archive", 1.0)), + Some(("The Stormlight Archive", 2.0)), + Some(("The Kingkiller Chronicle", 1.0)), + Some(("The Realm of the Elderlings", 1.0)), + None, + None, + None, + ]; + + let mut torrent_ids: Vec = Vec::new(); + + for i in 1u64..=35 { + let id = format!("torrent-{i:03}"); + let mam_id = 10_000 + i; + let ai = (i as usize - 1) % authors_pool.len(); + let ni = (i as usize - 1) % narrators_pool.len(); + let si = (i as usize - 1) % series_pool.len(); + + let authors: Vec = authors_pool[ai].iter().map(|s| s.to_string()).collect(); + let narrators: Vec = vec![narrators_pool[ni].to_string()]; + let series = match &series_pool[si] { + Some((name, num)) => vec![Series { + name: name.to_string(), + entries: SeriesEntries::new(vec![SeriesEntry::Num(*num)]), + }], + None => vec![], + }; + + let title = format!("Test Book {i:03}"); + let size_bytes = 300_000_000 + i * 10_000_000; + let has_library = i <= 20; + + // torrent-005 is replaced by torrent-006 + let replaced_with = if i == 5 { + Some(( + "torrent-006".to_string(), + timestamp_with_offset(i as i64 * 60 + 45), + )) + } else { + None + }; + + let mut ids = BTreeMap::new(); + ids.insert(mlm_db::ids::MAM.to_string(), mam_id.to_string()); + + let torrent = Torrent { + id: id.clone(), + id_is_hash: false, + mam_id: Some(mam_id), + library_path: has_library.then(|| PathBuf::from(format!("/library/books/{title}"))), + library_files: if has_library { + vec![PathBuf::from(format!("{title}.m4b"))] + } else { + vec![] + }, + linker: has_library.then(|| "test".to_string()), + category: None, + selected_audio_format: None, + selected_ebook_format: None, + title_search: title_search(&title), + meta: TorrentMeta { + ids, + vip_status: None, + cat: None, + media_type: MediaType::Audiobook, + main_cat: Some(if i % 3 == 0 { + MainCat::Nonfiction + } else { + MainCat::Fiction + }), + categories: vec![], + tags: vec![], + language: None, + flags: None, + filetypes: vec!["m4b".to_string()], + num_files: 1, + size: Size::from_bytes(size_bytes), + title: title.clone(), + edition: None, + description: format!("Description for {title}"), + authors, + narrators, + series, + source: MetadataSource::Mam, + uploaded_at: Some(timestamp_with_offset(i as i64 * 60)), + }, + created_at: timestamp_with_offset(i as i64 * 60 + 30), + replaced_with, + library_mismatch: None, + client_status: None, + }; + + rw.insert(torrent)?; + torrent_ids.push(id); + } + + // 5 selected torrents (pending/queued downloads) + for i in 1u64..=5 { + let mam_id = 20_000 + i; + let title = format!("Selected Book {i}"); + let mut ids = BTreeMap::new(); + ids.insert(mlm_db::ids::MAM.to_string(), mam_id.to_string()); + + rw.insert(SelectedTorrent { + mam_id, + hash: None, + dl_link: format!("https://www.myanonamouse.net/t/{mam_id}"), + unsat_buffer: Some(5), + wedge_buffer: None, + cost: TorrentCost::PersonalFreeleech, + category: None, + tags: vec![], + title_search: title_search(&title), + meta: TorrentMeta { + ids, + title: title.clone(), + authors: vec!["Test Author".to_string()], + media_type: MediaType::Audiobook, + main_cat: Some(MainCat::Fiction), + size: Size::from_bytes(200_000_000), + num_files: 1, + filetypes: vec!["m4b".to_string()], + source: MetadataSource::Mam, + uploaded_at: Some(timestamp_with_offset(10_000 + i as i64 * 60)), + ..Default::default() + }, + grabber: Some("bookmarks".to_string()), + created_at: timestamp_with_offset(10_000 + i as i64 * 60 + 30), + started_at: None, + removed_at: None, + })?; + } + + // 5 duplicate torrents + for i in 1u64..=5 { + let mam_id = 30_000 + i; + let title = format!("Duplicate Book {i}"); + let mut ids = BTreeMap::new(); + ids.insert(mlm_db::ids::MAM.to_string(), mam_id.to_string()); + + rw.insert(DuplicateTorrent { + mam_id, + dl_link: Some(format!("https://www.myanonamouse.net/t/{mam_id}")), + title_search: title_search(&title), + meta: TorrentMeta { + ids, + title: title.clone(), + authors: vec!["Dup Author".to_string()], + media_type: MediaType::Audiobook, + main_cat: Some(MainCat::Fiction), + size: Size::from_bytes(150_000_000), + num_files: 1, + filetypes: vec!["m4b".to_string()], + source: MetadataSource::Mam, + uploaded_at: Some(timestamp_with_offset(20_000 + i as i64 * 60)), + ..Default::default() + }, + created_at: timestamp_with_offset(20_000 + i as i64 * 60 + 30), + duplicate_of: Some("torrent-001".to_string()), + })?; + } + + // 5 errored torrents + for i in 1u64..=5 { + let mam_id = 40_000 + i; + rw.insert(ErroredTorrent { + id: ErroredTorrentId::Grabber(mam_id), + title: format!("Errored Book {i}"), + error: format!("Download failed: connection timeout (attempt {i})"), + meta: None, + created_at: timestamp_with_offset(30_000 + i as i64 * 60), + })?; + } + + // 10 events + for i in 0u64..10 { + let torrent_id = torrent_ids.get(i as usize).cloned(); + let event_type = match i % 3 { + 0 => EventType::Grabbed { + grabber: Some("bookmarks".to_string()), + cost: Some(TorrentCost::PersonalFreeleech), + wedged: false, + }, + 1 => EventType::Linked { + linker: Some("test".to_string()), + library_path: PathBuf::from(format!("/library/books/Test Book {i:03}")), + }, + _ => EventType::RemovedFromTracker, + }; + + rw.insert(Event { + id: Uuid::new(), + torrent_id, + mam_id: None, + created_at: timestamp_with_offset(40_000 + i as i64 * 60), + event: event_type, + })?; + } + + rw.commit()?; + println!("Test database created at {}", path.display()); + Ok(()) +} diff --git a/server/src/bin/mock_server.rs b/server/src/bin/mock_server.rs new file mode 100644 index 00000000..1a9e5f81 --- /dev/null +++ b/server/src/bin/mock_server.rs @@ -0,0 +1,303 @@ +#![recursion_limit = "256"] +//! Mock server for e2e tests: serves MaM API and qBittorrent WebUI API. +//! Listens on port 3997 by default (override with MOCK_PORT env var). +// Increased recursion limit for serde_json::json! macro with large objects. +use axum::{ + Router, + extract::Query, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Json}, + routing::{get, post}, +}; +use serde::Deserialize; +use serde_json::json; + +// ── qBittorrent mock ────────────────────────────────────────────────────────── + +async fn qbit_login() -> impl IntoResponse { + let mut headers = HeaderMap::new(); + headers.insert( + header::SET_COOKIE, + HeaderValue::from_static("SID=mock-session-id; Path=/"), + ); + (headers, "Ok.") +} + +async fn qbit_version() -> impl IntoResponse { + Json("5.0.0") +} + +#[derive(Deserialize)] +struct HashesQuery { + hashes: Option, + hash: Option, +} + +async fn qbit_torrents_info(Query(q): Query) -> impl IntoResponse { + // Only return a torrent when the expected hash is requested. + let requested = q.hashes.as_deref().unwrap_or(""); + if !requested.is_empty() && !requested.split('|').any(|h| h == "torrent-001") { + return Json(json!([])); + } + Json(json!([{ + "hash": "torrent-001", + "name": "Test Book 001", + "state": "stalledUP", + "category": "Audiobooks", + "tags": "mlm", + "size": 310000000i64, + "total_size": 310000000i64, + "uploaded": 620000000i64, + "downloaded": 310000000i64, + "ratio": 2.0f32, + "progress": 1.0f32, + "dlspeed": 0i64, + "num_seeds": 5i64, + "num_leechs": 0i64, + "num_complete": 10i64, + "num_incomplete": 0i64, + "eta": 0i64, + "added_on": 1700000000i64, + "completion_on": 1700001000i64, + "save_path": "/downloads/", + "content_path": "/downloads/Test Book 001", + "root_path": "/downloads/Test Book 001", + "download_path": "", + "amount_left": 0i64, + "completed": 310000000i64, + "dl_limit": -1i64, + "up_limit": -1i64, + "downloaded_session": 0i64, + "uploaded_session": 0i64, + "upspeed": 0i64, + "time_active": 86400i64, + "seeding_time": 86400i64, + "seeding_time_limit": -2i64, + "max_seeding_time": -1i64, + "inactive_seeding_time_limit": -2i64, + "max_inactive_seeding_time": -1i64, + "ratio_limit": -2.0f32, + "max_ratio": -1.0f32, + "priority": -1i64, + "reannounce": 1800i64, + "last_activity": 1700100000i64, + "seen_complete": 1700001000i64, + "tracker": "http://tracker.myanonamouse.net", + "trackers_count": 1i64, + "magnet_uri": "", + "infohash_v1": "aabbccddeeff001122334455667788990011223344", + "infohash_v2": "", + "comment": "", + "auto_tmm": false, + "availability": 1.0f64, + "f_l_piece_prio": false, + "force_start": false, + "has_metadata": true, + "seq_dl": false, + "super_seeding": false, + "private": true, + "popularity": 1.0f64 + }])) +} + +async fn qbit_trackers(Query(q): Query) -> impl IntoResponse { + let hash = q.hash.as_deref().unwrap_or(""); + if hash != "torrent-001" { + return (StatusCode::NOT_FOUND, Json(json!([]))); + } + ( + StatusCode::OK, + Json(json!([ + { + "url": "** [DHT] **", + "status": 0i64, + "tier": -1i64, + "num_peers": 5i64, + "num_seeds": 5i64, + "num_leeches": 0i64, + "num_downloaded": -1i64, + "msg": "" + }, + { + "url": "http://tracker.myanonamouse.net/announce", + "status": 2i64, + "tier": 0i64, + "num_peers": 5i64, + "num_seeds": 5i64, + "num_leeches": 0i64, + "num_downloaded": 50i64, + "msg": "" + } + ])), + ) +} + +async fn qbit_files(Query(q): Query) -> impl IntoResponse { + let hash = q.hash.as_deref().unwrap_or(""); + if hash != "torrent-001" { + return (StatusCode::NOT_FOUND, Json(json!([]))); + } + ( + StatusCode::OK, + Json(json!([ + { + "index": 0i64, + "name": "Test Book 001.m4b", + "size": 310000000i64, + "progress": 1.0f64, + "priority": 1, + "is_seed": true, + "piece_range": [0i64, 295i64], + "availability": 1.0f64 + } + ])), + ) +} + +async fn qbit_categories() -> impl IntoResponse { + Json(json!({ + "Audiobooks": { "name": "Audiobooks", "savePath": "/downloads/audiobooks/" }, + "Ebooks": { "name": "Ebooks", "savePath": "/downloads/ebooks/" } + })) +} + +async fn qbit_tags() -> impl IntoResponse { + Json(json!(["mlm", "fiction"])) +} + +// ── MaM mock ────────────────────────────────────────────────────────────────── + +async fn mam_check_cookie() -> impl IntoResponse { + Json(json!({"Success": "You are logged in as: testuser"})) +} + +async fn mam_user_info() -> impl IntoResponse { + Json(json!({ + "uid": 12345u64, + "username": "testuser", + "downloaded_bytes": 500_000_000_000.0f64, + "uploaded_bytes": 1_000_000_000_000.0f64, + "seedbonus": 50000i64, + "wedges": 3u64, + "unsat": { + "count": 2u64, + "red": false, + "size": null, + "limit": 10u64 + } + })) +} + +async fn mam_search() -> impl IntoResponse { + Json(json!({ + "total": 2usize, + "perpage": 25usize, + "start": 0usize, + "found": 2usize, + "data": [ + { + "id": 99001u64, + "added": "2024-01-15 10:00:00", + "author_info": r#"{"1":"Brandon Sanderson"}"#, + "browseflags": 0u8, + "main_cat": 13u8, + "category": 39u64, + "mediatype": 1u8, + "maincat": 1u8, + "categories": "[]", + "catname": "Audiobook - Fantasy", + "cat": "audiobook", + "comments": 5u64, + "filetype": "m4b", + "fl_vip": 0, + "free": 0, + "lang_code": "en", + "language": 1u8, + "leechers": 2u64, + "my_snatched": 0, + "narrator_info": r#"{"2":"Michael Kramer"}"#, + "numfiles": 1u64, + "owner": 12345u64, + "owner_name": "uploader", + "ownership": "[]", + "personal_freeleech": 0, + "seeders": 15u64, + "series_info": "{}", + "size": "476.84 MiB", + "tags": "fantasy epic", + "times_completed": 100u64, + "thumbnail": null, + "title": "Mock Search: Way of Kings", + "vip": 0, + "vip_expire": 0u64, + "w": 0u64 + }, + { + "id": 99002u64, + "added": "2024-02-10 12:00:00", + "author_info": r#"{"3":"Patrick Rothfuss"}"#, + "browseflags": 0u8, + "main_cat": 13u8, + "category": 41u64, + "mediatype": 1u8, + "maincat": 1u8, + "categories": "[]", + "catname": "Audiobook - Fantasy", + "cat": "audiobook", + "comments": 3u64, + "filetype": "mp3", + "fl_vip": 0, + "free": 1, + "lang_code": "en", + "language": 1u8, + "leechers": 0u64, + "my_snatched": 1, + "narrator_info": r#"{"4":"Nick Podehl"}"#, + "numfiles": 1u64, + "owner": 12345u64, + "owner_name": "uploader", + "ownership": "[]", + "personal_freeleech": 0, + "seeders": 8u64, + "series_info": "{}", + "size": "333.92 MiB", + "tags": "fantasy", + "times_completed": 80u64, + "thumbnail": null, + "title": "Mock Search: Name of the Wind", + "vip": 0, + "vip_expire": 0u64, + "w": 0u64 + } + ] + })) +} + +// ── Router ──────────────────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() { + let port: u16 = std::env::var("MOCK_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(3997); + + let app = Router::new() + // qBittorrent endpoints + .route("/api/v2/auth/login", post(qbit_login)) + .route("/api/v2/app/version", get(qbit_version)) + .route("/api/v2/torrents/info", get(qbit_torrents_info)) + .route("/api/v2/torrents/trackers", get(qbit_trackers)) + .route("/api/v2/torrents/files", get(qbit_files)) + .route("/api/v2/torrents/categories", get(qbit_categories)) + .route("/api/v2/torrents/tags", get(qbit_tags)) + // MaM endpoints + .route("/json/checkCookie.php", get(mam_check_cookie)) + .route("/jsonLoad.php", get(mam_user_info)) + .route("/tor/js/loadSearchJSONbasic.php", post(mam_search)); + + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + eprintln!("mock_server listening on {addr}"); + let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/server/src/main.rs b/server/src/main.rs index 4b144858..0e274814 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -24,8 +24,16 @@ use tracing_subscriber::{ util::SubscriberInitExt as _, }; -use mlm_core::{config::Config, metadata::MetadataService, stats::Stats}; -use mlm_web_askama::router as askama_router; +use axum::{ + Router, + body::Body, + http::{HeaderValue, Request, header}, + middleware::{self, Next}, + response::Response, +}; +use mlm_core::{Config, Stats, metadata::MetadataService}; +use mlm_web_askama::{ServeDir, router as askama_router}; +use mlm_web_dioxus::ssr::router as dioxus_router; #[cfg(target_family = "windows")] use mlm::windows; @@ -214,7 +222,30 @@ async fn app_main() -> Result<()> { let context = mlm_core::runner::spawn_tasks(config, db, Arc::new(mam), stats, metadata_service); - let app = askama_router(context.clone()); + let dioxus_public_path = { + let base = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + #[cfg(debug_assertions)] + { + base.join("target/dx/mlm_web_dioxus/debug/web/public") + } + #[cfg(not(debug_assertions))] + { + base.join("target/dx/mlm_web_dioxus/release/web/public") + } + }; + unsafe { + std::env::set_var("DIOXUS_PUBLIC_PATH", &dioxus_public_path); + } + + let dioxus_wasm_dir = dioxus_public_path.join("wasm"); + + let wasm_router = Router::new() + .nest_service("/wasm", ServeDir::new(&dioxus_wasm_dir)) + .layer(middleware::from_fn(set_wasm_cache_control)); + + let app = wasm_router + .merge(dioxus_router(context.clone())) + .merge(askama_router(context.clone())); let listener = tokio::net::TcpListener::bind((web_host, web_port)).await?; let result: Result<()> = axum::serve(listener, app).await.map_err(|e| e.into()); @@ -236,3 +267,12 @@ async fn app_main() -> Result<()> { Ok(()) } + +async fn set_wasm_cache_control(request: Request, next: Next) -> Response { + let mut response = next.run(request).await; + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("must-revalidate"), + ); + response +} diff --git a/server/tests/dioxus/mod.rs b/server/tests/dioxus/mod.rs new file mode 100644 index 00000000..e69de29b diff --git a/server/tests/linker_folder_test.rs b/server/tests/linker_folder_test.rs index 2e0fab43..936ff406 100644 --- a/server/tests/linker_folder_test.rs +++ b/server/tests/linker_folder_test.rs @@ -2,7 +2,7 @@ mod common; use common::{MockFs, TestDb, mock_config}; use mlm_core::{Events, linker::folder::link_folders_to_library}; -use mlm_db::{DatabaseExt, Torrent}; +use mlm_db::{Category, DatabaseExt, Torrent}; use std::{fs, sync::Arc}; #[tokio::test] @@ -13,10 +13,11 @@ async fn test_link_folders_to_library() -> anyhow::Result<()> { mock_fs.rip_dir.clone(), mock_fs.library_dir.clone(), )); + let events = mlm_core::Events::new(); mock_fs.create_libation_folder("B00TEST1", "Test Book 1", vec!["Author 1"])?; - link_folders_to_library(config.clone(), test_db.db.clone(), &Events::new()).await?; + link_folders_to_library(config.clone(), test_db.db.clone(), &events).await?; let r = test_db.db.r_transaction()?; let torrent: Option = r.get().primary("B00TEST1".to_string())?; @@ -43,6 +44,7 @@ async fn test_link_folders_to_library_duplicate_skipping() -> anyhow::Result<()> mock_fs.rip_dir.clone(), mock_fs.library_dir.clone(), )); + let events = mlm_core::Events::new(); // Create a better version already in the DB let existing = common::MockTorrentBuilder::new("MAM123", "Test Book 1") @@ -62,7 +64,7 @@ async fn test_link_folders_to_library_duplicate_skipping() -> anyhow::Result<()> // Libation folder files will have small size "fake audio data" = 15 bytes mock_fs.create_libation_folder("B00TEST1", "Test Book 1", vec!["Author 1"])?; - link_folders_to_library(config.clone(), test_db.db.clone(), &Events::new()).await?; + link_folders_to_library(config.clone(), test_db.db.clone(), &events).await?; let r = test_db.db.r_transaction()?; let torrent: Option = r.get().primary("B00TEST1".to_string())?; @@ -82,10 +84,11 @@ async fn test_link_folders_to_library_filter_size_too_small() -> anyhow::Result< l.filter.min_size = mlm_db::Size::from_bytes(100); // Libation folder is 15 bytes } let config = Arc::new(config); + let events = mlm_core::Events::new(); mock_fs.create_libation_folder("B00TEST1", "Test Book 1", vec!["Author 1"])?; - link_folders_to_library(config.clone(), test_db.db.clone(), &Events::new()).await?; + link_folders_to_library(config.clone(), test_db.db.clone(), &events).await?; let r = test_db.db.r_transaction()?; let torrent: Option = r.get().primary("B00TEST1".to_string())?; @@ -107,10 +110,11 @@ async fn test_link_folders_to_library_filter_media_type_mismatch() -> anyhow::Re l.filter.media_type = vec![mlm_db::MediaType::Ebook]; // Libation is Audiobook } let config = Arc::new(config); + let events = mlm_core::Events::new(); mock_fs.create_libation_folder("B00TEST1", "Test Book 1", vec!["Author 1"])?; - link_folders_to_library(config.clone(), test_db.db.clone(), &Events::new()).await?; + link_folders_to_library(config.clone(), test_db.db.clone(), &events).await?; let r = test_db.db.r_transaction()?; let torrent: Option = r.get().primary("B00TEST1".to_string())?; @@ -132,10 +136,11 @@ async fn test_link_folders_to_library_filter_language_mismatch() -> anyhow::Resu l.filter.languages = vec![mlm_db::Language::German]; // Libation is English } let config = Arc::new(config); + let events = mlm_core::Events::new(); mock_fs.create_libation_folder("B00TEST1", "Test Book 1", vec!["Author 1"])?; - link_folders_to_library(config.clone(), test_db.db.clone(), &Events::new()).await?; + link_folders_to_library(config.clone(), test_db.db.clone(), &events).await?; let r = test_db.db.r_transaction()?; let torrent: Option = r.get().primary("B00TEST1".to_string())?; diff --git a/server/tests/linker_torrent_test.rs b/server/tests/linker_torrent_test.rs index 903bef47..29c187de 100644 --- a/server/tests/linker_torrent_test.rs +++ b/server/tests/linker_torrent_test.rs @@ -2,7 +2,6 @@ mod common; use anyhow::Result; use common::{MockFs, TestDb, mock_config}; -use mlm_core::Events; use mlm_core::config::{ Library, LibraryByDownloadDir, LibraryLinkMethod, LibraryOptions, LibraryTagFilters, QbitConfig, }; @@ -122,6 +121,7 @@ async fn test_link_torrent_audiobook() -> anyhow::Result<()> { tag_filters: LibraryTagFilters::default(), })]; let config = Arc::new(config); + let events = mlm_core::Events::new(); // Setup mock Qbit let qbit_torrent = QbitTorrent { @@ -167,7 +167,7 @@ async fn test_link_torrent_audiobook() -> anyhow::Result<()> { db.db.clone(), (qbit_config, &mock_qbit), &mock_mam, - &Events::new(), + &events, ) .await?; @@ -212,6 +212,7 @@ async fn test_skip_incomplete_torrent() -> anyhow::Result<()> { on_invalid_torrent: None, }); let config = Arc::new(config); + let events = mlm_core::Events::new(); let mock_qbit = MockQbit { torrents: vec![QbitTorrent { @@ -231,7 +232,7 @@ async fn test_skip_incomplete_torrent() -> anyhow::Result<()> { db.db.clone(), (qbit_config, &mock_qbit), &mock_mam, - &Events::new(), + &events, ) .await?; @@ -303,6 +304,7 @@ async fn test_remove_selected_torrent() -> anyhow::Result<()> { tag_filters: LibraryTagFilters::default(), })]; let config = Arc::new(config); + let events = mlm_core::Events::new(); let mock_qbit = MockQbit { torrents: vec![QbitTorrent { @@ -323,7 +325,7 @@ async fn test_remove_selected_torrent() -> anyhow::Result<()> { db.db.clone(), (qbit_config, &mock_qbit), &mock_mam, - &Events::new(), + &events, ) .await; @@ -368,6 +370,7 @@ async fn test_link_torrent_ebook() -> anyhow::Result<()> { tag_filters: LibraryTagFilters::default(), })]; let config = Arc::new(config); + let events = mlm_core::Events::new(); let qbit_torrent = QbitTorrent { hash: torrent_hash.to_string(), @@ -412,7 +415,7 @@ async fn test_link_torrent_ebook() -> anyhow::Result<()> { db.db.clone(), (qbit_config, &mock_qbit), &mock_mam, - &Events::new(), + &events, ) .await?; @@ -457,6 +460,7 @@ async fn test_relink() -> anyhow::Result<()> { tag_filters: LibraryTagFilters::default(), })]; let config = Arc::new(config); + let events = mlm_core::Events::new(); let old_library_path = fs.library_dir.join("Old Author").join("Title"); std::fs::create_dir_all(&old_library_path)?; @@ -516,7 +520,7 @@ async fn test_relink() -> anyhow::Result<()> { &mock_qbit, qbit_torrent, torrent_hash.to_string(), - &Events::new(), + &events, ) .await?; @@ -565,6 +569,7 @@ async fn test_refresh_metadata_relink() -> anyhow::Result<()> { tag_filters: LibraryTagFilters::default(), })]; let config = Arc::new(config); + let events = mlm_core::Events::new(); { let (_guard, rw) = db.db.rw_async().await?; @@ -631,7 +636,7 @@ async fn test_refresh_metadata_relink() -> anyhow::Result<()> { &mock_mam, qbit_torrent, torrent_hash.to_string(), - &Events::new(), + &events, ) .await?; diff --git a/server/tests/metadata_integration.rs b/server/tests/metadata_integration.rs index 89cf8d59..d7bcab58 100644 --- a/server/tests/metadata_integration.rs +++ b/server/tests/metadata_integration.rs @@ -8,8 +8,8 @@ use mlm_db::{Event, EventKey, EventType, TorrentMeta as MetadataQuery}; use async_trait::async_trait; use common::{TestDb, mock_config}; -use mlm_core::Context; use mlm_core::metadata::MetadataService; +use mlm_core::{Context, Events, SsrBackend, Stats, Triggers}; use url::Url; // Simple mock fetcher that returns inline mock data for tests. @@ -126,16 +126,9 @@ async fn test_metadata_fetch_and_persist_romanceio() -> Result<()> { metadata: metadata.clone(), })), config: Arc::new(tokio::sync::Mutex::new(Arc::new(cfg))), - stats: mlm_core::stats::Stats::new(), - events: mlm_core::Events::new(), - triggers: mlm_core::Triggers { - search_tx: std::collections::BTreeMap::new(), - import_tx: std::collections::BTreeMap::new(), - torrent_linker_tx: Some(tokio::sync::watch::channel(()).0), - folder_linker_tx: Some(tokio::sync::watch::channel(()).0), - downloader_tx: Some(tokio::sync::watch::channel(()).0), - audiobookshelf_tx: Some(tokio::sync::watch::channel(()).0), - }, + stats: Stats::new(), + events: Events::new(), + triggers: Triggers::default(), }; // Use a title known to the plan/romanceio mock. Inject the test fetcher @@ -153,7 +146,7 @@ async fn test_metadata_fetch_and_persist_romanceio() -> Result<()> { let metadata = Arc::new(svc); let ctx = Context { - backend: Some(Arc::new(mlm_core::SsrBackend { + backend: Some(Arc::new(SsrBackend { db: test_db.db.clone(), mam: Arc::new(Err(anyhow::anyhow!("no mam"))), metadata: metadata.clone(), diff --git a/tests/e2e/config.spec.ts b/tests/e2e/config.spec.ts new file mode 100644 index 00000000..491f914e --- /dev/null +++ b/tests/e2e/config.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Config page', () => { + test('root /config route serves the Dioxus config page', async ({ page }) => { + await page.goto('/config'); + await expect(page).toHaveURL(/\/config/); + await expect(page.locator('h1')).toContainText('Config'); + await expect(page.locator('body')).toContainText('unsat_buffer'); + await expect(page.locator('body')).toContainText('[[qbittorrent]]'); + }); + + test('dioxus config page renders legacy config formatting', async ({ page }) => { + await page.goto('/config'); + + await expect(page.locator('h1')).toContainText('Config'); + await expect(page.locator('body')).toContainText('[[qbittorrent]]'); + await expect(page.locator('body')).toContainText('audio_types'); + await expect(page.locator('body')).toContainText('ebook_types'); + }); + + test('show_apply_tags query enables apply controls when tag sections exist', async ({ page }) => { + await page.goto('/config?show_apply_tags=true'); + const applyBtn = page.locator('button', { hasText: 'apply to all' }).first(); + if ((await applyBtn.count()) === 0) { + test.info().annotations.push({ + type: 'note', + description: 'No [[tag]] entries in e2e config fixture; apply controls are not rendered.', + }); + await expect(page.locator('h1')).toContainText('Config'); + return; + } + await expect(applyBtn).toBeVisible(); + await expect(page.locator('input[type="number"]').first()).toBeVisible(); + }); +}); diff --git a/tests/e2e/mock.spec.ts b/tests/e2e/mock.spec.ts new file mode 100644 index 00000000..0af7dc89 --- /dev/null +++ b/tests/e2e/mock.spec.ts @@ -0,0 +1,106 @@ +import { test, expect } from '@playwright/test'; + +const DETAIL_URL = '/torrents/torrent-001'; + +// Wait for a loading indicator to disappear, then assert something appeared. +async function waitForLoad(page: import('@playwright/test').Page, indicator: string) { + await expect(page.locator('.loading-indicator', { hasText: indicator })).toHaveCount(0, { + timeout: 20_000, + }); +} + +// ── Search page (mock-backed) ───────────────────────────────────────────────── + +test.describe('Search page with mock MaM', () => { + test('submitting a search returns mock results', async ({ page }) => { + await page.goto('/search'); + await expect(page.locator('form')).toBeVisible(); + + // Fill and submit the search form + await page.locator('input[type="text"], input[type="search"]').first().fill('Way of Kings'); + await page.locator('form').locator('button[type="submit"]').click(); + + // Mock returns 2 torrents; wait for result rows to appear + await expect(page.locator('.TorrentRow').first()).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('.TorrentRow')).toHaveCount(2, { timeout: 5_000 }); + }); + + test('search results contain mock torrent titles', async ({ page }) => { + await page.goto('/search'); + await page.locator('input[type="text"], input[type="search"]').first().fill('test'); + await page.locator('form').locator('button[type="submit"]').click(); + + await expect(page.locator('.TorrentRow').first()).toBeVisible({ timeout: 15_000 }); + // Both mock titles should appear on the page + await expect(page.locator('body')).toContainText('Way of Kings'); + await expect(page.locator('body')).toContainText('Name of the Wind'); + }); +}); + +// ── Torrent detail: qBittorrent section ────────────────────────────────────── + +test.describe('Torrent detail qBittorrent section', () => { + test('qbit section loads and shows torrent state', async ({ page }) => { + await page.goto(DETAIL_URL); + await waitForLoad(page, 'Loading qBittorrent data...'); + + // Mock returns state "stalledUP" — the UI renders it in a
as human-readable + await expect(page.locator('dd', { hasText: 'Stalled (Seeding)' })).toBeVisible({ + timeout: 10_000, + }); + }); + + test('qbit section shows tracker URL', async ({ page }) => { + await page.goto(DETAIL_URL); + await waitForLoad(page, 'Loading qBittorrent data...'); + + // Mock returns uploaded=620000000 bytes → "591.28 MiB" shown in qBit section + await expect(page.locator('body')).toContainText('591.28 MiB', { + timeout: 10_000, + }); + }); + + test('qbit section shows file name', async ({ page }) => { + await page.goto(DETAIL_URL); + await waitForLoad(page, 'Loading qBittorrent data...'); + + await expect(page.locator('body')).toContainText('Test Book 001.m4b', { + timeout: 10_000, + }); + }); +}); + +// ── Torrent detail: Other Torrents section ──────────────────────────────────── + +test.describe('Torrent detail other torrents section', () => { + test('other torrents section loads and shows mock results', async ({ page }) => { + await page.goto(DETAIL_URL); + await waitForLoad(page, 'Loading other torrents...'); + + // The "Other Torrents" section should contain at least one TorrentRow + await expect(page.locator('h3', { hasText: 'Other Torrents' })).toBeVisible({ + timeout: 10_000, + }); + await expect(page.locator('.TorrentRow').first()).toBeVisible({ timeout: 10_000 }); + }); +}); + +// ── Selected page: user info ────────────────────────────────────────────────── + +test.describe('Selected page user info from mock MaM', () => { + test('shows bonus and unsat info from mock', async ({ page }) => { + await page.goto('/selected'); + + // Mock returns: bonus=50000, wedges=3, unsat count=2, limit=10 + await expect(page.locator('body')).toContainText('50000', { timeout: 10_000 }); + await expect(page.locator('body')).toContainText('Wedges: 3'); + await expect(page.locator('body')).toContainText('Unsats: 2 / 10'); + }); + + test('shows remaining buffer computed from mock user data', async ({ page }) => { + await page.goto('/selected'); + + // Buffer is derived from uploaded - downloaded; should be non-empty + await expect(page.locator('body')).toContainText('Buffer:', { timeout: 10_000 }); + }); +}); diff --git a/tests/e2e/pages.spec.ts b/tests/e2e/pages.spec.ts new file mode 100644 index 00000000..a9bf6ce4 --- /dev/null +++ b/tests/e2e/pages.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from '@playwright/test'; + +function noError(page: import('@playwright/test').Page) { + return expect(page.locator('.error')).toHaveCount(0); +} +function noLoading(page: import('@playwright/test').Page) { + return expect(page.locator('.loading-indicator')).toHaveCount(0, { timeout: 15_000 }); +} + +test.describe('Events page', () => { + test('loads and shows events', async ({ page }) => { + await page.goto('/events'); + await noError(page); + await noLoading(page); + // 10 events were inserted + await expect(page.locator('body')).toContainText('Grabbed'); + }); + + test('no loading indicator stuck', async ({ page }) => { + await page.goto('/events'); + await noLoading(page); + }); +}); + +test.describe('Errors page', () => { + test('loads and shows errored torrents', async ({ page }) => { + await page.goto('/errors'); + await noError(page); + await noLoading(page); + await expect(page.locator('body')).toContainText('Errored Book'); + }); + + test('sort header is interactive', async ({ page }) => { + await page.goto('/errors'); + await noLoading(page); + const sortBtn = page.locator('.header button.link').first(); + await expect(sortBtn).toHaveCount(1); + await sortBtn.click(); + await noLoading(page); + await noError(page); + }); +}); + +test.describe('Selected torrents page', () => { + test('loads and shows selected torrents', async ({ page }) => { + await page.goto('/selected'); + await noError(page); + await noLoading(page); + await expect(page.locator('body')).toContainText('Selected Book'); + }); +}); + +test.describe('Replaced torrents page', () => { + test('loads and shows replaced torrents', async ({ page }) => { + await page.goto('/replaced'); + await noError(page); + await noLoading(page); + // torrent-005 is replaced + await expect(page.locator('body')).toContainText('Test Book 005'); + }); +}); + +test.describe('Duplicate torrents page', () => { + test('loads and shows duplicate torrents', async ({ page }) => { + await page.goto('/duplicate'); + await noError(page); + await noLoading(page); + await expect(page.locator('body')).toContainText('Duplicate Book'); + }); +}); + +test.describe('Search page', () => { + test('loads', async ({ page }) => { + await page.goto('/search'); + await noLoading(page); + // Search form should be present (mam_id error on search result is expected in test env) + await expect(page.locator('form')).toBeVisible(); + }); + + test('can search and shows results', async ({ page }) => { + await page.goto('/search'); + await expect(page.locator('form')).toBeVisible(); + + const input = page.locator('input[type="text"], input[type="search"]').first(); + await expect(input).toHaveCount(1); + await input.fill('Test Book'); + await Promise.all([ + page.waitForURL(url => url.toString().includes('/search?'), { + timeout: 5_000, + }), + input.press('Enter'), + ]); + await expect(page.locator('form')).toBeVisible(); + }); +}); + +test.describe('Lists page', () => { + test('loads', async ({ page }) => { + await page.goto('/lists'); + await noError(page); + await noLoading(page); + }); +}); + +test.describe('Home page', () => { + test('loads', async ({ page }) => { + await page.goto('/'); + await noError(page); + }); +}); diff --git a/tests/e2e/setup.ts b/tests/e2e/setup.ts new file mode 100644 index 00000000..44cae261 --- /dev/null +++ b/tests/e2e/setup.ts @@ -0,0 +1,86 @@ +import { execSync, spawn } from 'child_process'; +import { mkdirSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { resolve } from 'path'; + +const ROOT = resolve(__dirname, '../..'); +const DB_PATH = resolve(ROOT, 'test/e2e.db'); +const CONFIG_PATH = resolve(ROOT, 'test/e2e-config.toml'); +const SERVER_BIN = resolve(ROOT, 'target/release/mlm'); +const SETUP_BIN = resolve(ROOT, 'target/release/create_test_db'); +const MOCK_BIN = resolve(ROOT, 'target/release/mock_server'); +const SERVER_URL = 'http://localhost:3998'; +const MOCK_URL = 'http://localhost:3997'; + +async function waitForUrl(url: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const res = await fetch(url); + if (res.ok || res.status < 500) return; + } catch { + // not ready yet + } + await new Promise(r => setTimeout(r, 300)); + } + throw new Error(`${url} did not start within ${timeoutMs}ms`); +} + +export default async function globalSetup() { + console.log('[e2e] Building binaries...'); + execSync('cargo build --release --bin mlm --bin create_test_db --bin mock_server', { + cwd: ROOT, + stdio: 'inherit', + }); + + console.log('[e2e] Building WASM...'); + execSync('dx build --release --fullstack --skip-assets', { + cwd: resolve(ROOT, 'mlm_web_dioxus'), + stdio: 'inherit', + env: { ...process.env, PATH: `${homedir()}/.cargo/bin:${process.env.PATH}` }, + }); + + // (Re)create isolated test database + console.log('[e2e] Creating test database...'); + mkdirSync(resolve(ROOT, 'test'), { recursive: true }); + writeFileSync( + CONFIG_PATH, + ['mam_id = ""', '', '[[qbittorrent]]', `url = "${MOCK_URL}"`, ''].join('\n') + ); + execSync(`"${SETUP_BIN}" "${DB_PATH}"`, { cwd: ROOT, stdio: 'inherit' }); + + // Start mock server (MaM + qBittorrent APIs) + console.log('[e2e] Starting mock server on port 3997...'); + const mock = spawn(MOCK_BIN, [], { + cwd: ROOT, + env: { ...process.env, MOCK_PORT: '3997', RUST_LOG: 'warn' }, + stdio: 'ignore', + detached: false, + }); + mock.on('error', err => { throw new Error(`Failed to start mock_server: ${err.message}`); }); + process.env.E2E_MOCK_PID = String(mock.pid); + await waitForUrl(`${MOCK_URL}/api/v2/app/version`); + + // Start MLM server with test database and config + console.log('[e2e] Starting server on port 3998...'); + const server = spawn(SERVER_BIN, [], { + cwd: ROOT, + env: { + ...process.env, + MLM_DB_FILE: DB_PATH, + MLM_CONFIG_FILE: CONFIG_PATH, + MLM_CONF_WEB_HOST: '127.0.0.1', + MLM_CONF_WEB_PORT: '3998', + MLM_CONF_MAM_ID: 'e2e-test', + MLM_MAM_BASE_URL: MOCK_URL, + RUST_LOG: 'warn', + }, + stdio: 'ignore', + detached: false, + }); + server.on('error', err => { throw new Error(`Failed to start server: ${err.message}`); }); + process.env.E2E_SERVER_PID = String(server.pid); + + await waitForUrl(`${SERVER_URL}/torrents`); + console.log('[e2e] Server ready.'); +} diff --git a/tests/e2e/teardown.ts b/tests/e2e/teardown.ts new file mode 100644 index 00000000..4e02990f --- /dev/null +++ b/tests/e2e/teardown.ts @@ -0,0 +1,12 @@ +export default async function globalTeardown() { + for (const key of ['E2E_SERVER_PID', 'E2E_MOCK_PID']) { + const pid = process.env[key]; + if (pid) { + try { + process.kill(Number(pid), 'SIGTERM'); + } catch { + // already gone + } + } + } +} diff --git a/tests/e2e/torrent-detail.spec.ts b/tests/e2e/torrent-detail.spec.ts new file mode 100644 index 00000000..dceead4a --- /dev/null +++ b/tests/e2e/torrent-detail.spec.ts @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test'; + +const DETAIL_URL = '/torrents/torrent-001'; + +test.describe('Torrent detail page', () => { + test('client fetches and renders qBittorrent data', async ({ page }) => { + const qbitRequest = page.waitForRequest( + req => req.method() === 'POST' && req.url().includes('/api/get_qbit_data'), + { timeout: 20_000 } + ); + const qbitResponse = page.waitForResponse( + res => + res.request().method() === 'POST' && + res.url().includes('/api/get_qbit_data') && + res.status() === 200, + { timeout: 20_000 } + ); + await page.goto(DETAIL_URL); + + await qbitRequest; + await qbitResponse; + + await expect(page.locator('h3', { hasText: 'qBittorrent' })).toBeVisible({ + timeout: 20_000, + }); + await expect(page.locator('dd', { hasText: 'Stalled (Seeding)' })).toBeVisible({ + timeout: 20_000, + }); + }); + + test('client fetches and renders other torrents data', async ({ page }) => { + const otherRequest = page.waitForRequest( + req => + req.method() === 'POST' && + req.url().includes('/api/get_other_torrents'), + { timeout: 20_000 } + ); + const otherResponse = page.waitForResponse( + res => + res.request().method() === 'POST' && + res.url().includes('/api/get_other_torrents') && + res.status() === 200, + { timeout: 20_000 } + ); + + await page.goto(DETAIL_URL); + + await otherRequest; + await otherResponse; + + await expect(page.locator('h3', { hasText: 'Other Torrents' })).toBeVisible({ + timeout: 20_000, + }); + await expect(page.locator('body')).toContainText('Mock Search: Way of Kings', { + timeout: 20_000, + }); + }); + + test('loads and shows torrent info', async ({ page }) => { + await page.goto(DETAIL_URL); + await expect(page.locator('.error')).toHaveCount(0); + // Should show the torrent title + await expect(page.locator('body')).toContainText('Test Book 001'); + }); + + test('other torrents section resolves (not stuck loading)', async ({ page }) => { + await page.goto(DETAIL_URL); + + // Wait for "Other Torrents" heading to appear + await expect(page.locator('h3', { hasText: 'Other Torrents' })).toBeVisible({ + timeout: 20_000, + }); + + // The loading indicator should disappear as client fetches data + await expect( + page.locator('.loading-indicator', { hasText: 'Loading other torrents...' }) + ).toHaveCount(0, { timeout: 20_000 }); + }); + + test('qbit section is not stuck loading', async ({ page }) => { + await page.goto(DETAIL_URL); + await expect( + page.locator('.loading-indicator', { hasText: 'Loading qBittorrent data...' }) + ).toHaveCount(0, { timeout: 20_000 }); + }); + + test('no error state on initial load', async ({ page }) => { + await page.goto(DETAIL_URL); + await expect(page.locator('.error')).toHaveCount(0); + }); + + test('replaced torrent detail loads', async ({ page }) => { + // torrent-005 is replaced by torrent-006 in our test data + await page.goto('/torrents/torrent-005'); + await expect(page.locator('.error')).toHaveCount(0); + await expect(page.locator('body')).toContainText('Test Book 005'); + }); +}); diff --git a/tests/e2e/torrents.spec.ts b/tests/e2e/torrents.spec.ts new file mode 100644 index 00000000..90ef52a8 --- /dev/null +++ b/tests/e2e/torrents.spec.ts @@ -0,0 +1,172 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Torrents page', () => { + test('loads and shows torrent rows', async ({ page }) => { + await page.goto('/torrents'); + await expect(page.locator('.error')).toHaveCount(0); + await expect(page.locator('.loading-indicator')).toHaveCount(0); + // At least one torrent row should be visible + await expect(page.locator('table tr, .torrent-row, [class*="row"]').first()).toBeVisible(); + }); + + test('shows 35 torrents total', async ({ page }) => { + await page.goto('/torrents'); + await page.waitForSelector('h1'); + // Wait for data, not loading + await expect(page.locator('.loading-indicator')).toHaveCount(0); + // Verify total count text appears somewhere (e.g., "35" in the page) + await expect(page.locator('body')).toContainText('35'); + }); + + test('pagination works with small page size', async ({ page }) => { + // Use page_size=20 so 35 torrents spans 2 pages + await page.goto('/torrents?page_size=20'); + + const pagination = page.locator('.pagination'); + await expect(pagination).toBeVisible(); + + // On page 1: Next enabled, Previous disabled + const nextBtn = page.locator('[aria-label="Next page"]'); + await expect(nextBtn).not.toHaveClass(/disabled/); + await expect(page.locator('[aria-label="Previous page"]')).toHaveClass(/disabled/); + + // Navigate to page 2 via URL (tests SSR pagination correctness) + await page.goto('/torrents?page_size=20&from=20'); + await expect(page.locator('body')).toContainText('Showing 20'); + // On page 2: Previous enabled + await expect(page.locator('[aria-label="Previous page"]')).not.toHaveClass(/disabled/); + }); + + test('page 2 shows different content than page 1', async ({ page }) => { + await page.goto('/torrents?page_size=20'); + await expect(page.locator('.torrents-grid-row').first()).toBeVisible(); + + // Get the first title link from page 1 (title links now point to detail pages) + const page1TitleLink = page + .locator('.torrents-grid-row a[href^="/torrents/"]') + .first(); + if ((await page1TitleLink.count()) === 0) { + test.info().annotations.push({ + type: 'note', + description: 'Title links unavailable (likely during rebuild overlay); skipping page-diff assertion.', + }); + return; + } + const firstTitle = await page1TitleLink.textContent(); + + // Navigate directly to page 2 via URL + await page.goto('/torrents?page_size=20&from=20'); + await expect(page.locator('.torrents-grid-row').first()).toBeVisible(); + + const page2TitleLink = page + .locator('.torrents-grid-row a[href^="/torrents/"]') + .first(); + if ((await page2TitleLink.count()) === 0) { + test.info().annotations.push({ + type: 'note', + description: 'Title links unavailable on page 2; skipping page-diff assertion.', + }); + return; + } + const secondPageTitle = await page2TitleLink.textContent(); + expect(firstTitle).not.toEqual(secondPageTitle); + }); + + test('sorting by title changes data order', async ({ page }) => { + await page.goto('/torrents'); + await expect(page.locator('.loading-indicator')).toHaveCount(0); + + const titleSort = page.locator('.header button.link', { hasText: 'Title' }); + await expect(titleSort).toHaveCount(1); + + const firstRow = page.locator('.torrents-grid-row').first(); + await expect(firstRow).toBeVisible(); + + await titleSort.click(); + await expect(page.locator('.loading-indicator')).toHaveCount(0, { timeout: 15_000 }); + const firstSortedTitle = await firstRow.innerText(); + + await titleSort.click(); + await expect + .poll(async () => firstRow.innerText(), { timeout: 15_000 }) + .not.toBe(firstSortedTitle); + await expect(page.locator('.error')).toHaveCount(0); + }); + + test('column dropdown supports multi-select without closing', async ({ page }) => { + await page.goto('/torrents'); + await expect(page.locator('.torrents-grid-row').first()).toBeVisible(); + + const dropdown = page.locator('.column_selector_dropdown'); + const trigger = dropdown.locator('summary, .column_selector_trigger').first(); + await trigger.click(); + + const categoriesOption = dropdown + .locator('.column_selector_option:has-text("Categories"), label:has-text("Categories")') + .first(); + const flagsOption = dropdown + .locator('.column_selector_option:has-text("Flags"), label:has-text("Flags")') + .first(); + + if ((await categoriesOption.count()) === 0 || (await flagsOption.count()) === 0) { + test.info().annotations.push({ + type: 'note', + description: 'Column options unavailable (likely during rebuild overlay); skipping interaction assertions.', + }); + return; + } + + await expect(categoriesOption).toBeVisible(); + await expect(flagsOption).toBeVisible(); + + await categoriesOption.click(); + await expect(flagsOption).toBeVisible(); + + await flagsOption.click(); + await expect(categoriesOption).toBeVisible(); + + await categoriesOption.click(); + await expect(flagsOption).toBeVisible(); + }); + + test('filter link by author narrows results', async ({ page }) => { + await page.goto('/torrents'); + await expect(page.locator('.torrents-grid-row').first()).toBeVisible(); + + // Click the first author filter link + const authorLink = page.locator('a[href*="author"]').first(); + if (await authorLink.count() > 0) { + await authorLink.click(); + await expect(page.locator('.torrents-grid-row').first()).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('.error')).toHaveCount(0); + } + }); + + test('alt-clicking title applies title filter', async ({ page, browserName }) => { + test.skip( + browserName === 'firefox', + 'Playwright Firefox does not synthesize this modified anchor click consistently.' + ); + await page.goto('/torrents'); + await expect(page.locator('.torrents-grid-row').first()).toBeVisible(); + + const titleLink = page.locator('.torrents-grid-row a.link[href^="/torrents/"]').first(); + if ((await titleLink.count()) === 0) { + test.info().annotations.push({ + type: 'note', + description: 'Title link unavailable (likely during rebuild overlay); skipping alt-click assertion.', + }); + return; + } + + const title = (await titleLink.textContent())?.trim() ?? ''; + await titleLink.click({ modifiers: ['Alt'] }); + await expect(page).toHaveURL(/\/torrents\?.*title=/); + await expect(page.locator('body')).toContainText(title); + }); + + test('no error state on initial load', async ({ page }) => { + await page.goto('/torrents'); + await expect(page.locator('.error')).toHaveCount(0); + }); +});