From 16a22b8712ea634844a98734244e5a53623eb017 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 19:46:16 +0100 Subject: [PATCH 1/3] 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 + } } -- 2.47.3 From 3ac6ca2da6c95d7941a905149de8d087482a0184 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 19:54:20 +0100 Subject: [PATCH 2/3] fix(dl): address Transmission review --- Cargo.lock | 181 ++++++++++++++++++++++++++++++++++ crates/arr-dl/Cargo.toml | 3 + crates/arr-dl/src/lib.rs | 205 +++++++++++++++++++++++++++++++++++---- 3 files changed, 368 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7e079ea..93af2eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -59,6 +68,7 @@ dependencies = [ "thiserror", "tokio", "url", + "wiremock", ] [[package]] @@ -86,6 +96,16 @@ version = "0.1.0" name = "arr-probe" version = "0.1.0" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "atoi" version = "2.0.0" @@ -269,6 +289,24 @@ dependencies = [ "typenum", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "der" version = "0.7.10" @@ -378,6 +416,12 @@ dependencies = [ "spin", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -393,6 +437,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -437,6 +496,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "futures-sink" version = "0.3.34" @@ -455,8 +525,10 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -501,6 +573,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "h2" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -533,6 +624,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -605,6 +702,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.11.0" @@ -615,9 +718,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -974,6 +1079,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1235,6 +1350,35 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "reqwest" version = "0.12.28" @@ -1898,6 +2042,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -2434,6 +2592,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/crates/arr-dl/Cargo.toml b/crates/arr-dl/Cargo.toml index 277fc2b..0a00a4c 100644 --- a/crates/arr-dl/Cargo.toml +++ b/crates/arr-dl/Cargo.toml @@ -15,5 +15,8 @@ thiserror = { workspace = true } tokio = { workspace = true } url = { workspace = true } +[dev-dependencies] +wiremock = { workspace = true } + [lints] workspace = true diff --git a/crates/arr-dl/src/lib.rs b/crates/arr-dl/src/lib.rs index 56d2d00..df8edaa 100644 --- a/crates/arr-dl/src/lib.rs +++ b/crates/arr-dl/src/lib.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use base64::Engine as _; use reqwest::{StatusCode, Url}; @@ -11,6 +12,8 @@ use tokio::sync::RwLock; const SESSION_HEADER: &str = "x-transmission-session-id"; const MAX_SESSION_NEGOTIATIONS: usize = 4; +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// A magnet URI or the bytes of a `.torrent` file. #[derive(Clone, Debug, Eq, PartialEq)] @@ -49,6 +52,7 @@ pub enum TorrentState { Downloading, QueuedToSeed, Seeding, + Unknown(u8), } /// Current torrent state, read directly from Transmission. @@ -68,6 +72,10 @@ pub struct Torrent { pub enum Error { #[error("invalid Transmission RPC URL: {0}")] InvalidUrl(#[from] url::ParseError), + #[error("download directory is not valid UTF-8")] + NonUtf8DownloadDir, + #[error("could not build Transmission HTTP client: {0}")] + BuildClient(reqwest::Error), #[error("Transmission request failed: {0}")] Http(#[from] reqwest::Error), #[error("Transmission did not return a session ID after {0} attempts")] @@ -96,7 +104,11 @@ impl TransmissionClient { pub fn new(endpoint: &str) -> Result { Ok(Self { endpoint: Url::parse(endpoint)?, - http: reqwest::Client::new(), + http: reqwest::Client::builder() + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(Error::BuildClient)?, session_id: Arc::new(RwLock::new(None)), }) } @@ -108,12 +120,20 @@ impl TransmissionClient { /// Returns an error for transport failures or rejected/malformed RPC /// responses. pub async fn add_torrent(&self, request: AddTorrent) -> Result { + let download_dir = request + .download_dir + .to_str() + .ok_or(Error::NonUtf8DownloadDir)? + .to_owned(); + let label = request.label; + let seed_ratio_limit = request.seed_ratio_limit; + let seed_idle_limit = request.seed_idle_limit_minutes; let mut arguments = json!({ - "download-dir": request.download_dir, - "labels": [request.label], - "seedRatioLimit": request.seed_ratio_limit, + "download-dir": download_dir, + "labels": [label], + "seedRatioLimit": seed_ratio_limit, "seedRatioMode": 1, - "seedIdleLimit": request.seed_idle_limit_minutes, + "seedIdleLimit": seed_idle_limit, "seedIdleMode": 1, }); @@ -138,6 +158,26 @@ impl TransmissionClient { let torrent: RpcAddedTorrent = serde_json::from_value(torrent) .map_err(|error| Error::InvalidResponse(error.to_string()))?; + if was_duplicate { + self.call( + "torrent-set", + json!({ + "ids": [torrent.id], + "labels": [label], + "seedRatioLimit": seed_ratio_limit, + "seedRatioMode": 1, + "seedIdleLimit": seed_idle_limit, + "seedIdleMode": 1, + }), + ) + .await?; + self.call( + "torrent-set-location", + json!({"ids": [torrent.id], "location": download_dir, "move": false}), + ) + .await?; + } + Ok(AddedTorrent { id: torrent.id, name: torrent.name, @@ -170,11 +210,7 @@ impl TransmissionClient { 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() + Ok(response.torrents.into_iter().map(Torrent::from).collect()) } /// Remove one torrent, optionally deleting its downloaded data. @@ -265,10 +301,8 @@ struct RpcTorrent { labels: Vec, } -impl TryFrom for Torrent { - type Error = Error; - - fn try_from(value: RpcTorrent) -> Result { +impl From for Torrent { + fn from(value: RpcTorrent) -> Self { let state = match value.status { 0 => TorrentState::Stopped, 1 => TorrentState::QueuedToVerify, @@ -277,14 +311,10 @@ impl TryFrom for Torrent { 4 => TorrentState::Downloading, 5 => TorrentState::QueuedToSeed, 6 => TorrentState::Seeding, - status => { - return Err(Error::InvalidResponse(format!( - "unknown torrent status {status}" - ))); - } + status => TorrentState::Unknown(status), }; - Ok(Self { + Self { id: value.id, name: value.name, hash: value.hash, @@ -292,6 +322,139 @@ impl TryFrom for Torrent { progress: value.progress, download_dir: value.download_dir, labels: value.labels, - }) + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use serde_json::json; + use wiremock::matchers::{body_partial_json, method}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + use super::{AddTorrent, Error, TorrentSource, TorrentState, TransmissionClient}; + + #[tokio::test] + async fn retries_the_session_handshake() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(409).insert_header("x-transmission-session-id", "session"), + ) + .with_priority(1) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(body_partial_json(json!({"method": "torrent-get"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "result": "success", + "arguments": {"torrents": []} + }))) + .with_priority(2) + .expect(1) + .mount(&server) + .await; + + let client = TransmissionClient::new(&server.uri()).expect("client"); + assert!(client.list_torrents().await.expect("list").is_empty()); + } + + #[tokio::test] + async fn duplicate_reapplies_label_location_and_seed_limits() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(body_partial_json(json!({"method": "torrent-add"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "result": "success", + "arguments": {"torrent-duplicate": { + "id": 7, "name": "duplicate", "hashString": "abc" + }} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(body_partial_json(json!({ + "method": "torrent-set", + "arguments": { + "ids": [7], "labels": ["movies-main"], + "seedRatioLimit": 1.5, "seedIdleLimit": 60 + } + }))) + .respond_with(success()) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(body_partial_json(json!({ + "method": "torrent-set-location", + "arguments": {"ids": [7], "location": "/downloads", "move": false} + }))) + .respond_with(success()) + .expect(1) + .mount(&server) + .await; + + let client = TransmissionClient::new(&server.uri()).expect("client"); + let added = client + .add_torrent(AddTorrent { + source: TorrentSource::Magnet("magnet:?xt=urn:btih:abc".into()), + label: "movies-main".into(), + download_dir: PathBuf::from("/downloads"), + seed_ratio_limit: 1.5, + seed_idle_limit_minutes: 60, + }) + .await + .expect("duplicate"); + + assert!(added.was_duplicate); + } + + #[tokio::test] + #[cfg(unix)] + async fn rejects_non_utf8_download_directory() { + use std::os::unix::ffi::OsStringExt as _; + + let client = TransmissionClient::new("http://127.0.0.1:1").expect("client"); + let error = client + .add_torrent(AddTorrent { + source: TorrentSource::Magnet("magnet:?xt=urn:btih:abc".into()), + label: "movies-main".into(), + download_dir: PathBuf::from(std::ffi::OsString::from_vec(vec![0xff])), + seed_ratio_limit: 1.5, + seed_idle_limit_minutes: 60, + }) + .await + .expect_err("invalid path"); + + assert!(matches!(error, Error::NonUtf8DownloadDir)); + } + + #[tokio::test] + async fn preserves_unknown_status_without_hiding_the_list() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "result": "success", + "arguments": {"torrents": [{ + "id": 1, "name": "future", "hashString": "abc", "status": 99, + "percentDone": 0.5, "downloadDir": "/downloads", "labels": [] + }]} + }))) + .expect(1) + .mount(&server) + .await; + + let client = TransmissionClient::new(&server.uri()).expect("client"); + let torrents = client.list_torrents().await.expect("list"); + assert_eq!(torrents[0].state, TorrentState::Unknown(99)); + } + + fn success() -> ResponseTemplate { + ResponseTemplate::new(200).set_body_json(json!({"result": "success", "arguments": {}})) } } -- 2.47.3 From daadf8abf3e5c9caf134495e65dc0435209799a6 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 19:56:54 +0100 Subject: [PATCH 3/3] fix(dl): move duplicate torrent data --- crates/arr-dl/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/arr-dl/src/lib.rs b/crates/arr-dl/src/lib.rs index df8edaa..f373614 100644 --- a/crates/arr-dl/src/lib.rs +++ b/crates/arr-dl/src/lib.rs @@ -173,7 +173,7 @@ impl TransmissionClient { .await?; self.call( "torrent-set-location", - json!({"ids": [torrent.id], "location": download_dir, "move": false}), + json!({"ids": [torrent.id], "location": download_dir, "move": true}), ) .await?; } @@ -392,7 +392,7 @@ mod tests { Mock::given(method("POST")) .and(body_partial_json(json!({ "method": "torrent-set-location", - "arguments": {"ids": [7], "location": "/downloads", "move": false} + "arguments": {"ids": [7], "location": "/downloads", "move": true} }))) .respond_with(success()) .expect(1) -- 2.47.3