From 0ac35d5f1cab83453d495776d6db5763ffe3dace Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 19:41:57 +0100 Subject: [PATCH 1/3] feat(indexer): enumerate Prowlarr caps --- Cargo.lock | 751 +++++++++++++++++- crates/arr-indexer/Cargo.toml | 8 + crates/arr-indexer/src/lib.rs | 256 ++++++ .../arr-indexer/tests/fixtures/alpha-caps.xml | 9 + .../arr-indexer/tests/fixtures/indexers.json | 4 + .../tests/fixtures/text-only-caps.xml | 9 + 6 files changed, 1020 insertions(+), 17 deletions(-) create mode 100644 crates/arr-indexer/tests/fixtures/alpha-caps.xml create mode 100644 crates/arr-indexer/tests/fixtures/indexers.json create mode 100644 crates/arr-indexer/tests/fixtures/text-only-caps.xml diff --git a/Cargo.lock b/Cargo.lock index 6ac3610..b3f6e1c 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" @@ -53,6 +62,14 @@ version = "0.1.0" [[package]] name = "arr-indexer" version = "0.1.0" +dependencies = [ + "quick-xml", + "reqwest", + "serde", + "thiserror", + "tokio", + "wiremock", +] [[package]] name = "arr-meta" @@ -66,6 +83,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" @@ -75,6 +102,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 +178,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 +227,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" @@ -217,6 +276,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" @@ -326,6 +403,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" @@ -341,6 +424,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" @@ -385,6 +483,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" @@ -403,8 +512,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", @@ -429,8 +540,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 +553,30 @@ 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]] +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]] @@ -476,6 +611,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" @@ -509,6 +650,112 @@ 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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[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", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "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 +894,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 +988,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 +1032,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -807,6 +1066,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" @@ -923,6 +1192,72 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + +[[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 +1281,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 +1302,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 +1314,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 +1347,87 @@ 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" +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 +1441,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 +1467,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 +1582,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1110,7 +1593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1137,7 +1620,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 +1783,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.7", "rsa", "serde", "sha1", @@ -1339,7 +1822,7 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.7", "serde", "serde_json", "sha2", @@ -1421,6 +1904,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 +2009,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 +2030,65 @@ 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 = "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 +2121,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 +2160,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" @@ -1623,6 +2196,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 +2230,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 +2272,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 +2376,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 +2403,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 +2434,113 @@ 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 = "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-indexer/Cargo.toml b/crates/arr-indexer/Cargo.toml index 083d65f..db37235 100644 --- a/crates/arr-indexer/Cargo.toml +++ b/crates/arr-indexer/Cargo.toml @@ -7,6 +7,14 @@ repository.workspace = true publish = false [dependencies] +quick-xml.workspace = true +reqwest.workspace = true +serde.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tokio.workspace = true +wiremock.workspace = true [lints] workspace = true diff --git a/crates/arr-indexer/src/lib.rs b/crates/arr-indexer/src/lib.rs index 48ac917..fcde9ca 100644 --- a/crates/arr-indexer/src/lib.rs +++ b/crates/arr-indexer/src/lib.rs @@ -1 +1,257 @@ //! arr-indexer — see DESIGN.md. + +use std::collections::BTreeSet; + +use reqwest::Client; +use serde::Deserialize; +use thiserror::Error; + +/// A Prowlarr indexer and the Torznab capabilities it advertises. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Indexer { + /// Stable Prowlarr ID, retained on releases for tracker-specific rules. + pub id: i64, + pub name: String, + pub capabilities: Capabilities, +} + +/// Search operations supported by one Torznab indexer. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Capabilities { + pub search: SearchMode, + pub movie: SearchMode, + pub tv: SearchMode, +} + +/// One Torznab search operation and the parameters it accepts. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct SearchMode { + pub available: bool, + pub supported_parameters: BTreeSet, +} + +impl SearchMode { + /// Returns whether this operation supports an ID-based lookup. + #[must_use] + pub fn supports_id_search(&self) -> bool { + self.supported_parameters + .iter() + .any(|parameter| parameter.ends_with("id")) + } + + /// Returns whether the indexer accepts a particular Torznab parameter. + #[must_use] + pub fn supports_parameter(&self, parameter: &str) -> bool { + self.supported_parameters.contains(parameter) + } +} + +/// Client for Prowlarr's indexer and per-indexer Torznab endpoints. +#[derive(Clone, Debug)] +pub struct ProwlarrClient { + base_url: String, + api_key: String, + client: Client, +} + +impl ProwlarrClient { + /// Creates a client using Prowlarr's API key for its REST and Torznab APIs. + #[must_use] + pub fn new(base_url: impl Into, api_key: impl Into) -> Self { + Self { + base_url: base_url.into().trim_end_matches('/').to_owned(), + api_key: api_key.into(), + client: Client::new(), + } + } + + /// Enumerates Prowlarr indexers and loads Torznab capabilities for each. + /// + /// Indexers are requested individually so later search and seeding logic + /// retains the tracker identity instead of using Prowlarr's aggregate API. + /// + /// # Errors + /// + /// Returns an error when Prowlarr rejects a request or a caps response is + /// not valid Torznab XML. + pub async fn indexers(&self) -> Result, Error> { + let response = self + .client + .get(format!("{}/api/v1/indexer", self.base_url)) + .header("X-Api-Key", &self.api_key) + .send() + .await? + .error_for_status()?; + let indexers: Vec = response.json().await?; + + let mut result = Vec::with_capacity(indexers.len()); + for indexer in indexers { + let capabilities = self.capabilities(indexer.id).await?; + result.push(Indexer { + id: indexer.id, + name: indexer.name, + capabilities, + }); + } + + Ok(result) + } + + async fn capabilities(&self, indexer_id: i64) -> Result { + let response = self + .client + .get(format!("{}/{indexer_id}/api", self.base_url)) + .query(&[("apikey", self.api_key.as_str()), ("t", "caps")]) + .send() + .await? + .error_for_status()?; + let body = response.text().await?; + Ok(parse_capabilities(&body)?) + } +} + +/// Errors returned while discovering Prowlarr indexers. +#[derive(Debug, Error)] +pub enum Error { + #[error("Prowlarr request failed: {0}")] + Request(#[from] reqwest::Error), + #[error("invalid Torznab capabilities response: {0}")] + Capabilities(#[from] quick_xml::DeError), +} + +#[derive(Deserialize)] +struct ProwlarrIndexer { + id: i64, + name: String, +} + +#[derive(Deserialize)] +struct TorznabCapabilities { + #[serde(default)] + searching: Option, +} + +#[derive(Deserialize)] +struct TorznabSearching { + #[serde(default)] + search: Option, + #[serde(default, rename = "tv-search")] + tv: Option, + #[serde(default, rename = "movie-search")] + movie: Option, +} + +#[derive(Deserialize)] +struct TorznabSearchMode { + #[serde(default, rename = "@available")] + available: Option, + #[serde(default, rename = "@supportedParams")] + supported_parameters: Option, +} + +fn parse_capabilities(body: &str) -> Result { + let capabilities: TorznabCapabilities = quick_xml::de::from_str(body)?; + let searching = capabilities.searching; + + Ok(Capabilities { + search: searching + .as_ref() + .and_then(|modes| modes.search.as_ref()) + .map_or_else(SearchMode::default, SearchMode::from), + movie: searching + .as_ref() + .and_then(|modes| modes.movie.as_ref()) + .map_or_else(SearchMode::default, SearchMode::from), + tv: searching + .as_ref() + .and_then(|modes| modes.tv.as_ref()) + .map_or_else(SearchMode::default, SearchMode::from), + }) +} + +impl From<&TorznabSearchMode> for SearchMode { + fn from(mode: &TorznabSearchMode) -> Self { + let supported_parameters = mode + .supported_parameters + .as_deref() + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|parameter| !parameter.is_empty()) + .map(str::to_owned) + .collect(); + + Self { + available: mode.available.as_deref().is_some_and(is_available), + supported_parameters, + } + } +} + +fn is_available(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "yes" | "true" | "1" + ) +} + +#[cfg(test)] +mod tests { + use super::ProwlarrClient; + use wiremock::{ + matchers::{header, method, path, query_param}, + Mock, MockServer, ResponseTemplate, + }; + + const API_KEY: &str = "test-api-key"; + + #[tokio::test] + async fn enumerates_indexers_with_recorded_capabilities() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/api/v1/indexer")) + .and(header("X-Api-Key", API_KEY)) + .respond_with(ResponseTemplate::new(200).set_body_raw( + include_str!("../tests/fixtures/indexers.json"), + "application/json", + )) + .mount(&server) + .await; + mount_capabilities(&server, 3, include_str!("../tests/fixtures/alpha-caps.xml")).await; + mount_capabilities( + &server, + 17, + include_str!("../tests/fixtures/text-only-caps.xml"), + ) + .await; + + let indexers = ProwlarrClient::new(server.uri(), API_KEY) + .indexers() + .await + .expect("recorded Prowlarr responses are valid"); + + assert_eq!(indexers.len(), 2); + assert_eq!(indexers[0].id, 3); + assert_eq!(indexers[0].name, "Alpha Tracker"); + assert!(indexers[0].capabilities.movie.available); + assert!(indexers[0].capabilities.movie.supports_id_search()); + assert!(indexers[0].capabilities.tv.supports_parameter("season")); + + assert_eq!(indexers[1].id, 17); + assert_eq!(indexers[1].name, "Text Only"); + assert!(indexers[1].capabilities.search.available); + assert!(!indexers[1].capabilities.movie.supports_id_search()); + assert!(!indexers[1].capabilities.tv.available); + } + + async fn mount_capabilities(server: &MockServer, id: i64, body: &str) { + Mock::given(method("GET")) + .and(path(format!("/{id}/api"))) + .and(query_param("apikey", API_KEY)) + .and(query_param("t", "caps")) + .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/xml")) + .mount(server) + .await; + } +} diff --git a/crates/arr-indexer/tests/fixtures/alpha-caps.xml b/crates/arr-indexer/tests/fixtures/alpha-caps.xml new file mode 100644 index 0000000..07a9bc7 --- /dev/null +++ b/crates/arr-indexer/tests/fixtures/alpha-caps.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/crates/arr-indexer/tests/fixtures/indexers.json b/crates/arr-indexer/tests/fixtures/indexers.json new file mode 100644 index 0000000..6e46628 --- /dev/null +++ b/crates/arr-indexer/tests/fixtures/indexers.json @@ -0,0 +1,4 @@ +[ + { "id": 3, "name": "Alpha Tracker", "enable": true }, + { "id": 17, "name": "Text Only", "enable": true } +] diff --git a/crates/arr-indexer/tests/fixtures/text-only-caps.xml b/crates/arr-indexer/tests/fixtures/text-only-caps.xml new file mode 100644 index 0000000..bd6480e --- /dev/null +++ b/crates/arr-indexer/tests/fixtures/text-only-caps.xml @@ -0,0 +1,9 @@ + + + + + + + + + -- 2.47.3 From af8c74e39ad8466731be7f4b98534771f5397991 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 19:50:04 +0100 Subject: [PATCH 2/3] fix(indexer): harden capability discovery --- crates/arr-indexer/src/lib.rs | 148 ++++++++++++++---- .../tests/fixtures/broken-caps.xml | 2 + .../arr-indexer/tests/fixtures/indexers.json | 4 +- .../tests/fixtures/text-only-caps.xml | 4 +- 4 files changed, 128 insertions(+), 30 deletions(-) create mode 100644 crates/arr-indexer/tests/fixtures/broken-caps.xml diff --git a/crates/arr-indexer/src/lib.rs b/crates/arr-indexer/src/lib.rs index fcde9ca..d97b455 100644 --- a/crates/arr-indexer/src/lib.rs +++ b/crates/arr-indexer/src/lib.rs @@ -1,7 +1,8 @@ //! arr-indexer — see DESIGN.md. -use std::collections::BTreeSet; +use std::{collections::BTreeSet, time::Duration}; +use quick_xml::events::Event; use reqwest::Client; use serde::Deserialize; use thiserror::Error; @@ -13,6 +14,7 @@ pub struct Indexer { pub id: i64, pub name: String, pub capabilities: Capabilities, + pub capabilities_error: Option, } /// Search operations supported by one Torznab indexer. @@ -34,9 +36,13 @@ impl SearchMode { /// Returns whether this operation supports an ID-based lookup. #[must_use] pub fn supports_id_search(&self) -> bool { - self.supported_parameters - .iter() - .any(|parameter| parameter.ends_with("id")) + self.available + && self.supported_parameters.iter().any(|parameter| { + matches!( + parameter.as_str(), + "imdbid" | "tmdbid" | "tvdbid" | "tvmazeid" | "rid" | "traktid" + ) + }) } /// Returns whether the indexer accepts a particular Torznab parameter. @@ -56,13 +62,21 @@ pub struct ProwlarrClient { impl ProwlarrClient { /// Creates a client using Prowlarr's API key for its REST and Torznab APIs. - #[must_use] - pub fn new(base_url: impl Into, api_key: impl Into) -> Self { - Self { + /// + /// # Errors + /// + /// Returns an error when the HTTP client cannot be created. + pub fn new(base_url: impl Into, api_key: impl Into) -> Result { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|_| Error::Client)?; + + Ok(Self { base_url: base_url.into().trim_end_matches('/').to_owned(), api_key: api_key.into(), - client: Client::new(), - } + client, + }) } /// Enumerates Prowlarr indexers and loads Torznab capabilities for each. @@ -80,49 +94,75 @@ impl ProwlarrClient { .get(format!("{}/api/v1/indexer", self.base_url)) .header("X-Api-Key", &self.api_key) .send() - .await? - .error_for_status()?; - let indexers: Vec = response.json().await?; + .await + .map_err(|_| Error::Request)? + .error_for_status() + .map_err(|_| Error::Request)?; + let indexers: Vec = response.json().await.map_err(|_| Error::Response)?; let mut result = Vec::with_capacity(indexers.len()); - for indexer in indexers { - let capabilities = self.capabilities(indexer.id).await?; + for indexer in indexers.into_iter().filter(|indexer| indexer.enabled) { + let (capabilities, capabilities_error) = match self.capabilities(indexer.id).await { + Ok(capabilities) => (capabilities, None), + Err(error) => (Capabilities::default(), Some(error)), + }; result.push(Indexer { id: indexer.id, name: indexer.name, capabilities, + capabilities_error, }); } Ok(result) } - async fn capabilities(&self, indexer_id: i64) -> Result { + async fn capabilities(&self, indexer_id: i64) -> Result { let response = self .client .get(format!("{}/{indexer_id}/api", self.base_url)) .query(&[("apikey", self.api_key.as_str()), ("t", "caps")]) .send() - .await? - .error_for_status()?; - let body = response.text().await?; - Ok(parse_capabilities(&body)?) + .await + .map_err(|_| CapabilityError::Request)? + .error_for_status() + .map_err(|_| CapabilityError::Request)?; + let body = response + .text() + .await + .map_err(|_| CapabilityError::Request)?; + parse_capabilities(&body) } } /// Errors returned while discovering Prowlarr indexers. #[derive(Debug, Error)] pub enum Error { - #[error("Prowlarr request failed: {0}")] - Request(#[from] reqwest::Error), - #[error("invalid Torznab capabilities response: {0}")] - Capabilities(#[from] quick_xml::DeError), + #[error("could not create Prowlarr HTTP client")] + Client, + #[error("Prowlarr indexer request failed")] + Request, + #[error("could not parse Prowlarr indexer response")] + Response, +} + +/// A failed capabilities request for an otherwise usable Prowlarr indexer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CapabilityError { + Request, + InvalidResponse, + Torznab { + code: Option, + description: String, + }, } #[derive(Deserialize)] struct ProwlarrIndexer { id: i64, name: String, + #[serde(rename = "enable")] + enabled: bool, } #[derive(Deserialize)] @@ -131,6 +171,14 @@ struct TorznabCapabilities { searching: Option, } +#[derive(Deserialize)] +struct TorznabError { + #[serde(default, rename = "@code")] + code: Option, + #[serde(default, rename = "@description")] + description: String, +} + #[derive(Deserialize)] struct TorznabSearching { #[serde(default)] @@ -149,8 +197,18 @@ struct TorznabSearchMode { supported_parameters: Option, } -fn parse_capabilities(body: &str) -> Result { - let capabilities: TorznabCapabilities = quick_xml::de::from_str(body)?; +fn parse_capabilities(body: &str) -> Result { + if response_root(body)? == "error" { + let error: TorznabError = + quick_xml::de::from_str(body).map_err(|_| CapabilityError::InvalidResponse)?; + return Err(CapabilityError::Torznab { + code: error.code, + description: error.description, + }); + } + + let capabilities: TorznabCapabilities = + quick_xml::de::from_str(body).map_err(|_| CapabilityError::InvalidResponse)?; let searching = capabilities.searching; Ok(Capabilities { @@ -169,6 +227,23 @@ fn parse_capabilities(body: &str) -> Result { }) } +fn response_root(body: &str) -> Result { + let mut reader = quick_xml::Reader::from_str(body); + loop { + match reader + .read_event() + .map_err(|_| CapabilityError::InvalidResponse)? + { + Event::Start(element) | Event::Empty(element) => { + return String::from_utf8(element.name().as_ref().to_owned()) + .map_err(|_| CapabilityError::InvalidResponse); + } + Event::Eof => return Err(CapabilityError::InvalidResponse), + _ => {} + } + } +} + impl From<&TorznabSearchMode> for SearchMode { fn from(mode: &TorznabSearchMode) -> Self { let supported_parameters = mode @@ -197,7 +272,7 @@ fn is_available(value: &str) -> bool { #[cfg(test)] mod tests { - use super::ProwlarrClient; + use super::{Capabilities, CapabilityError, ProwlarrClient}; use wiremock::{ matchers::{header, method, path, query_param}, Mock, MockServer, ResponseTemplate, @@ -225,13 +300,20 @@ mod tests { include_str!("../tests/fixtures/text-only-caps.xml"), ) .await; + mount_capabilities( + &server, + 23, + include_str!("../tests/fixtures/broken-caps.xml"), + ) + .await; let indexers = ProwlarrClient::new(server.uri(), API_KEY) + .expect("HTTP client can be created") .indexers() .await .expect("recorded Prowlarr responses are valid"); - assert_eq!(indexers.len(), 2); + assert_eq!(indexers.len(), 3); assert_eq!(indexers[0].id, 3); assert_eq!(indexers[0].name, "Alpha Tracker"); assert!(indexers[0].capabilities.movie.available); @@ -241,8 +323,20 @@ mod tests { assert_eq!(indexers[1].id, 17); assert_eq!(indexers[1].name, "Text Only"); assert!(indexers[1].capabilities.search.available); + assert!(!indexers[1].capabilities.search.supports_id_search()); assert!(!indexers[1].capabilities.movie.supports_id_search()); assert!(!indexers[1].capabilities.tv.available); + assert!(!indexers[1].capabilities.tv.supports_id_search()); + + assert_eq!(indexers[2].id, 23); + assert_eq!(indexers[2].capabilities, Capabilities::default()); + assert_eq!( + indexers[2].capabilities_error, + Some(CapabilityError::Torznab { + code: Some(100), + description: "Indexer unavailable".to_owned(), + }) + ); } async fn mount_capabilities(server: &MockServer, id: i64, body: &str) { diff --git a/crates/arr-indexer/tests/fixtures/broken-caps.xml b/crates/arr-indexer/tests/fixtures/broken-caps.xml new file mode 100644 index 0000000..195f0bf --- /dev/null +++ b/crates/arr-indexer/tests/fixtures/broken-caps.xml @@ -0,0 +1,2 @@ + + diff --git a/crates/arr-indexer/tests/fixtures/indexers.json b/crates/arr-indexer/tests/fixtures/indexers.json index 6e46628..106a129 100644 --- a/crates/arr-indexer/tests/fixtures/indexers.json +++ b/crates/arr-indexer/tests/fixtures/indexers.json @@ -1,4 +1,6 @@ [ { "id": 3, "name": "Alpha Tracker", "enable": true }, - { "id": 17, "name": "Text Only", "enable": true } + { "id": 17, "name": "Text Only", "enable": true }, + { "id": 23, "name": "Broken Tracker", "enable": true }, + { "id": 42, "name": "Disabled Tracker", "enable": false } ] diff --git a/crates/arr-indexer/tests/fixtures/text-only-caps.xml b/crates/arr-indexer/tests/fixtures/text-only-caps.xml index bd6480e..7fcc0b7 100644 --- a/crates/arr-indexer/tests/fixtures/text-only-caps.xml +++ b/crates/arr-indexer/tests/fixtures/text-only-caps.xml @@ -2,8 +2,8 @@ - - + + -- 2.47.3 From 2e1b0a60f87ceddd7669a66f62ed2beea36438fd Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sat, 22 Aug 2026 20:00:51 +0100 Subject: [PATCH 3/3] fix(indexer): preserve request status --- crates/arr-indexer/src/lib.rs | 55 +++++++++++++++---- .../arr-indexer/tests/fixtures/indexers.json | 1 + 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/crates/arr-indexer/src/lib.rs b/crates/arr-indexer/src/lib.rs index d97b455..a09133e 100644 --- a/crates/arr-indexer/src/lib.rs +++ b/crates/arr-indexer/src/lib.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeSet, time::Duration}; use quick_xml::events::Event; -use reqwest::Client; +use reqwest::{Client, StatusCode}; use serde::Deserialize; use thiserror::Error; @@ -95,9 +95,13 @@ impl ProwlarrClient { .header("X-Api-Key", &self.api_key) .send() .await - .map_err(|_| Error::Request)? + .map_err(|error| Error::Request { + status: error.status(), + })? .error_for_status() - .map_err(|_| Error::Request)?; + .map_err(|error| Error::Request { + status: error.status(), + })?; let indexers: Vec = response.json().await.map_err(|_| Error::Response)?; let mut result = Vec::with_capacity(indexers.len()); @@ -124,13 +128,19 @@ impl ProwlarrClient { .query(&[("apikey", self.api_key.as_str()), ("t", "caps")]) .send() .await - .map_err(|_| CapabilityError::Request)? + .map_err(|error| CapabilityError::Request { + status: error.status(), + })? .error_for_status() - .map_err(|_| CapabilityError::Request)?; + .map_err(|error| CapabilityError::Request { + status: error.status(), + })?; let body = response .text() .await - .map_err(|_| CapabilityError::Request)?; + .map_err(|error| CapabilityError::Request { + status: error.status(), + })?; parse_capabilities(&body) } } @@ -140,17 +150,20 @@ impl ProwlarrClient { pub enum Error { #[error("could not create Prowlarr HTTP client")] Client, - #[error("Prowlarr indexer request failed")] - Request, + #[error("Prowlarr indexer request failed (status: {status:?})")] + Request { status: Option }, #[error("could not parse Prowlarr indexer response")] Response, } /// A failed capabilities request for an otherwise usable Prowlarr indexer. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, Error, PartialEq)] pub enum CapabilityError { - Request, + #[error("capabilities request failed (status: {status:?})")] + Request { status: Option }, + #[error("capabilities response was not valid Torznab XML")] InvalidResponse, + #[error("tracker returned Torznab error {code:?}: {description}")] Torznab { code: Option, description: String, @@ -273,6 +286,7 @@ fn is_available(value: &str) -> bool { #[cfg(test)] mod tests { use super::{Capabilities, CapabilityError, ProwlarrClient}; + use reqwest::StatusCode; use wiremock::{ matchers::{header, method, path, query_param}, Mock, MockServer, ResponseTemplate, @@ -306,6 +320,7 @@ mod tests { include_str!("../tests/fixtures/broken-caps.xml"), ) .await; + mount_failure(&server, 29).await; let indexers = ProwlarrClient::new(server.uri(), API_KEY) .expect("HTTP client can be created") @@ -313,7 +328,7 @@ mod tests { .await .expect("recorded Prowlarr responses are valid"); - assert_eq!(indexers.len(), 3); + assert_eq!(indexers.len(), 4); assert_eq!(indexers[0].id, 3); assert_eq!(indexers[0].name, "Alpha Tracker"); assert!(indexers[0].capabilities.movie.available); @@ -337,6 +352,14 @@ mod tests { description: "Indexer unavailable".to_owned(), }) ); + + assert_eq!(indexers[3].id, 29); + assert_eq!( + indexers[3].capabilities_error, + Some(CapabilityError::Request { + status: Some(StatusCode::SERVICE_UNAVAILABLE), + }) + ); } async fn mount_capabilities(server: &MockServer, id: i64, body: &str) { @@ -348,4 +371,14 @@ mod tests { .mount(server) .await; } + + async fn mount_failure(server: &MockServer, id: i64) { + Mock::given(method("GET")) + .and(path(format!("/{id}/api"))) + .and(query_param("apikey", API_KEY)) + .and(query_param("t", "caps")) + .respond_with(ResponseTemplate::new(503)) + .mount(server) + .await; + } } diff --git a/crates/arr-indexer/tests/fixtures/indexers.json b/crates/arr-indexer/tests/fixtures/indexers.json index 106a129..68fd643 100644 --- a/crates/arr-indexer/tests/fixtures/indexers.json +++ b/crates/arr-indexer/tests/fixtures/indexers.json @@ -2,5 +2,6 @@ { "id": 3, "name": "Alpha Tracker", "enable": true }, { "id": 17, "name": "Text Only", "enable": true }, { "id": 23, "name": "Broken Tracker", "enable": true }, + { "id": 29, "name": "Unavailable Tracker", "enable": true }, { "id": 42, "name": "Disabled Tracker", "enable": false } ] -- 2.47.3