From 16a22b8712ea634844a98734244e5a53623eb017 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 19:46:16 +0100 Subject: [PATCH] feat(dl): add Transmission RPC client --- Cargo.lock | 579 ++++++++++++++++++++++++++++++++++++-- Cargo.toml | 2 + crates/arr-dl/Cargo.toml | 7 + crates/arr-dl/src/lib.rs | 298 +++++++++++++++++++- crates/arr-e2e/Cargo.toml | 5 + crates/arr-e2e/src/lib.rs | 62 +++- 6 files changed, 930 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ac3610..324cfa9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,10 +45,24 @@ dependencies = [ [[package]] name = "arr-dl" version = "0.1.0" +dependencies = [ + "base64", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "url", +] [[package]] name = "arr-e2e" version = "0.1.0" +dependencies = [ + "arr-dl", + "tokio", + "uuid", +] [[package]] name = "arr-indexer" @@ -75,6 +89,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" @@ -145,6 +165,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.45" @@ -177,6 +214,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -429,8 +475,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -440,8 +488,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -509,6 +560,104 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -647,6 +796,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + [[package]] name = "itoa" version = "1.0.18" @@ -735,6 +890,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "md-5" version = "0.10.6" @@ -773,7 +934,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -923,6 +1084,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -946,7 +1163,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -956,7 +1184,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -968,6 +1196,21 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -986,6 +1229,58 @@ dependencies = [ "bitflags", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsa" version = "0.9.10" @@ -999,13 +1294,19 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "1.1.4" @@ -1019,6 +1320,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1099,7 +1435,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1110,7 +1446,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1137,7 +1473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1300,7 +1636,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.7", "rsa", "serde", "sha1", @@ -1339,7 +1675,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.7", "serde", "serde_json", "sha2", @@ -1421,6 +1757,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1517,6 +1862,16 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -1528,6 +1883,51 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -1560,6 +1960,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -1593,6 +1999,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -1611,6 +2023,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f053576934f05a761a402421fbbe3d425d9366f75f978806a037b3ca481abecc" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -1623,6 +2047,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1648,6 +2081,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.127" @@ -1680,6 +2123,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" @@ -1755,7 +2227,16 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -1773,13 +2254,29 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -1788,42 +2285,90 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "writeable" version = "0.6.4" diff --git a/Cargo.toml b/Cargo.toml index a04b997..0ef8df5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ axum = "0.8" tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] } tower-http = { version = "0.6", features = ["trace"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +base64 = "0.22" # Persistence sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "macros", "migrate", "chrono", "json"] } @@ -51,6 +52,7 @@ clap = { version = "4.5", features = ["derive", "env"] } thiserror = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" uuid = { version = "1", features = ["v4", "serde"] } # Test-only diff --git a/crates/arr-dl/Cargo.toml b/crates/arr-dl/Cargo.toml index b3683fa..277fc2b 100644 --- a/crates/arr-dl/Cargo.toml +++ b/crates/arr-dl/Cargo.toml @@ -7,6 +7,13 @@ repository.workspace = true publish = false [dependencies] +base64 = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +url = { workspace = true } [lints] workspace = true diff --git a/crates/arr-dl/src/lib.rs b/crates/arr-dl/src/lib.rs index b31c26d..56d2d00 100644 --- a/crates/arr-dl/src/lib.rs +++ b/crates/arr-dl/src/lib.rs @@ -1 +1,297 @@ -//! arr-dl — see DESIGN.md. +//! Transmission RPC client for download lifecycle operations. + +use std::path::PathBuf; +use std::sync::Arc; + +use base64::Engine as _; +use reqwest::{StatusCode, Url}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::sync::RwLock; + +const SESSION_HEADER: &str = "x-transmission-session-id"; +const MAX_SESSION_NEGOTIATIONS: usize = 4; + +/// A magnet URI or the bytes of a `.torrent` file. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TorrentSource { + Magnet(String), + Metainfo(Vec), +} + +/// Settings applied atomically when a torrent is added. +#[derive(Clone, Debug, PartialEq)] +pub struct AddTorrent { + pub source: TorrentSource, + pub label: String, + pub download_dir: PathBuf, + pub seed_ratio_limit: f64, + /// Transmission expresses its idle limit in minutes. + pub seed_idle_limit_minutes: u64, +} + +/// The identity returned by Transmission for an added or existing torrent. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AddedTorrent { + pub id: i64, + pub name: String, + pub hash: String, + pub was_duplicate: bool, +} + +/// Transmission's torrent activity state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TorrentState { + Stopped, + QueuedToVerify, + Verifying, + QueuedToDownload, + Downloading, + QueuedToSeed, + Seeding, +} + +/// Current torrent state, read directly from Transmission. +#[derive(Clone, Debug, PartialEq)] +pub struct Torrent { + pub id: i64, + pub name: String, + pub hash: String, + pub state: TorrentState, + pub progress: f64, + pub download_dir: PathBuf, + pub labels: Vec, +} + +/// A Transmission RPC failure. +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("invalid Transmission RPC URL: {0}")] + InvalidUrl(#[from] url::ParseError), + #[error("Transmission request failed: {0}")] + Http(#[from] reqwest::Error), + #[error("Transmission did not return a session ID after {0} attempts")] + SessionNegotiation(usize), + #[error("Transmission RPC failed: {0}")] + Rpc(String), + #[error("invalid Transmission response: {0}")] + InvalidResponse(String), +} + +/// Client for the unauthenticated Transmission JSON-RPC endpoint. +#[derive(Clone, Debug)] +pub struct TransmissionClient { + endpoint: Url, + http: reqwest::Client, + session_id: Arc>>, +} + +impl TransmissionClient { + /// Create a client for a full RPC URL, such as + /// `http://host:9091/transmission/rpc`. + /// + /// # Errors + /// + /// Returns an error when `endpoint` is not a valid URL. + pub fn new(endpoint: &str) -> Result { + Ok(Self { + endpoint: Url::parse(endpoint)?, + http: reqwest::Client::new(), + session_id: Arc::new(RwLock::new(None)), + }) + } + + /// Add a torrent with its label, destination, and tracker seed limits. + /// + /// # Errors + /// + /// Returns an error for transport failures or rejected/malformed RPC + /// responses. + pub async fn add_torrent(&self, request: AddTorrent) -> Result { + let mut arguments = json!({ + "download-dir": request.download_dir, + "labels": [request.label], + "seedRatioLimit": request.seed_ratio_limit, + "seedRatioMode": 1, + "seedIdleLimit": request.seed_idle_limit_minutes, + "seedIdleMode": 1, + }); + + match request.source { + TorrentSource::Magnet(uri) => arguments["filename"] = json!(uri), + TorrentSource::Metainfo(bytes) => { + arguments["metainfo"] = + json!(base64::engine::general_purpose::STANDARD.encode(bytes)); + } + } + + let arguments = self.call("torrent-add", arguments).await?; + let (torrent, was_duplicate) = if let Some(value) = arguments.get("torrent-added") { + (value.clone(), false) + } else if let Some(value) = arguments.get("torrent-duplicate") { + (value.clone(), true) + } else { + return Err(Error::InvalidResponse( + "torrent-add omitted torrent-added and torrent-duplicate".into(), + )); + }; + let torrent: RpcAddedTorrent = serde_json::from_value(torrent) + .map_err(|error| Error::InvalidResponse(error.to_string()))?; + + Ok(AddedTorrent { + id: torrent.id, + name: torrent.name, + hash: torrent.hash, + was_duplicate, + }) + } + + /// List torrents with their current state and completion progress. + /// + /// No torrent state is cached: every result is authoritative from + /// Transmission, including the first call after process startup. + /// + /// # Errors + /// + /// Returns an error for transport failures or rejected/malformed RPC + /// responses. + pub async fn list_torrents(&self) -> Result, Error> { + let arguments = self + .call( + "torrent-get", + json!({ + "fields": [ + "id", "name", "hashString", "status", "percentDone", + "downloadDir", "labels" + ] + }), + ) + .await?; + let response: RpcTorrentList = serde_json::from_value(arguments) + .map_err(|error| Error::InvalidResponse(error.to_string()))?; + + response + .torrents + .into_iter() + .map(Torrent::try_from) + .collect() + } + + /// Remove one torrent, optionally deleting its downloaded data. + /// + /// # Errors + /// + /// Returns an error for transport failures or rejected RPC responses. + pub async fn remove_torrent(&self, id: i64, delete_data: bool) -> Result<(), Error> { + self.call( + "torrent-remove", + json!({"ids": [id], "delete-local-data": delete_data}), + ) + .await?; + Ok(()) + } + + async fn call(&self, method: &str, arguments: Value) -> Result { + let body = RpcRequest { method, arguments }; + + for _ in 0..MAX_SESSION_NEGOTIATIONS { + let session_id = self.session_id.read().await.clone(); + let mut request = self.http.post(self.endpoint.clone()).json(&body); + if let Some(session_id) = session_id { + request = request.header(SESSION_HEADER, session_id); + } + + let response = request.send().await?; + if response.status() == StatusCode::CONFLICT { + let session_id = response + .headers() + .get(SESSION_HEADER) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| { + Error::InvalidResponse("409 response omitted session ID".into()) + })?; + *self.session_id.write().await = Some(session_id.to_owned()); + continue; + } + + let response = response.error_for_status()?.json::().await?; + if response.result != "success" { + return Err(Error::Rpc(response.result)); + } + return Ok(response.arguments); + } + + Err(Error::SessionNegotiation(MAX_SESSION_NEGOTIATIONS)) + } +} + +#[derive(Debug, Serialize)] +struct RpcRequest<'a> { + method: &'a str, + arguments: Value, +} + +#[derive(Debug, Deserialize)] +struct RpcResponse { + result: String, + arguments: Value, +} + +#[derive(Debug, Deserialize)] +struct RpcAddedTorrent { + id: i64, + name: String, + #[serde(rename = "hashString")] + hash: String, +} + +#[derive(Debug, Deserialize)] +struct RpcTorrentList { + torrents: Vec, +} + +#[derive(Debug, Deserialize)] +struct RpcTorrent { + id: i64, + name: String, + #[serde(rename = "hashString")] + hash: String, + status: u8, + #[serde(rename = "percentDone")] + progress: f64, + #[serde(rename = "downloadDir")] + download_dir: PathBuf, + #[serde(default)] + labels: Vec, +} + +impl TryFrom for Torrent { + type Error = Error; + + fn try_from(value: RpcTorrent) -> Result { + let state = match value.status { + 0 => TorrentState::Stopped, + 1 => TorrentState::QueuedToVerify, + 2 => TorrentState::Verifying, + 3 => TorrentState::QueuedToDownload, + 4 => TorrentState::Downloading, + 5 => TorrentState::QueuedToSeed, + 6 => TorrentState::Seeding, + status => { + return Err(Error::InvalidResponse(format!( + "unknown torrent status {status}" + ))); + } + }; + + Ok(Self { + id: value.id, + name: value.name, + hash: value.hash, + state, + progress: value.progress, + download_dir: value.download_dir, + labels: value.labels, + }) + } +} diff --git a/crates/arr-e2e/Cargo.toml b/crates/arr-e2e/Cargo.toml index 7d5b576..3f2fe6d 100644 --- a/crates/arr-e2e/Cargo.toml +++ b/crates/arr-e2e/Cargo.toml @@ -8,5 +8,10 @@ publish = false [dependencies] +[dev-dependencies] +arr-dl = { workspace = true } +tokio = { workspace = true } +uuid = { workspace = true } + [lints] workspace = true diff --git a/crates/arr-e2e/src/lib.rs b/crates/arr-e2e/src/lib.rs index a9f88f7..f509fc0 100644 --- a/crates/arr-e2e/src/lib.rs +++ b/crates/arr-e2e/src/lib.rs @@ -5,9 +5,61 @@ #[cfg(test)] mod tests { - /// Placeholder, same reason as the one in `arr-core`: `cargo nextest` - /// fails a zero-test run, and `--no-tests=pass` would let a broken filter - /// go green later. Replaced by the real harness in the e2e issue. - #[test] - fn e2e_crate_builds() {} + use std::path::PathBuf; + + use arr_dl::{AddTorrent, TorrentSource, TransmissionClient}; + + #[tokio::test] + async fn transmission_add_list_and_remove() { + let endpoint = std::env::var("TRANSMISSION_RPC_URL") + .unwrap_or_else(|_| "http://127.0.0.1:9091/transmission/rpc".into()); + let client = TransmissionClient::new(&endpoint).expect("valid endpoint"); + let name = format!("arr-e2e-{}", uuid::Uuid::new_v4()); + let metainfo = torrent_with_name(&name); + let download_dir = PathBuf::from("/tmp/arr-e2e"); + let request = || AddTorrent { + source: TorrentSource::Metainfo(metainfo.clone()), + label: "movies-main".into(), + download_dir: download_dir.clone(), + seed_ratio_limit: 1.5, + seed_idle_limit_minutes: 60, + }; + + let first = client.add_torrent(request()).await.expect("add torrent"); + assert!(!first.was_duplicate); + + let torrents = client.list_torrents().await.expect("list torrents"); + let listed = torrents + .iter() + .find(|torrent| torrent.id == first.id) + .expect("added torrent is authoritative in list"); + assert_eq!(listed.name, name); + assert_eq!(listed.hash, first.hash); + assert_eq!(listed.download_dir, download_dir); + assert_eq!(listed.labels, ["movies-main"]); + assert!((0.0..=1.0).contains(&listed.progress)); + + client + .remove_torrent(first.id, false) + .await + .expect("remove without data"); + + let second = client.add_torrent(request()).await.expect("add again"); + client + .remove_torrent(second.id, true) + .await + .expect("remove with data"); + + let torrents = client.list_torrents().await.expect("list after remove"); + assert!(torrents.iter().all(|torrent| torrent.id != second.id)); + } + + fn torrent_with_name(name: &str) -> Vec { + let piece_hash = [0_u8; 20]; + let mut bytes = format!("d4:infod6:lengthi1e4:name{}:{name}", name.len()).into_bytes(); + bytes.extend_from_slice(b"12:piece lengthi16384e6:pieces20:"); + bytes.extend_from_slice(&piece_hash); + bytes.extend_from_slice(b"ee"); + bytes + } }