├── .github └── workflows │ ├── advisories.yml │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── ATTRIBUTION.md ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── about.hbs ├── about.toml ├── deny.toml ├── src ├── filter.rs ├── main.rs ├── media_server.rs ├── media_server │ ├── embyfin.rs │ └── plex.rs ├── process.rs ├── sonarr.rs ├── util.rs └── util │ └── once.rs └── unraid └── my-prefetcharr.xml /.github/workflows/advisories.yml: -------------------------------------------------------------------------------- 1 | name: advisories 2 | 3 | on: 4 | schedule: 5 | - cron: "5 23 * * *" 6 | env: 7 | CARGO_TERM_COLOR: always 8 | 9 | jobs: 10 | cargo-deny: 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | ref: main 16 | - uses: EmbarkStudios/cargo-deny-action@v2 17 | with: 18 | command: check advisories 19 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | 6 | env: 7 | CARGO_TERM_COLOR: always 8 | RUSTFLAGS: "-Dwarnings" 9 | 10 | jobs: 11 | tests: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Run rustfmt 18 | run: cargo fmt --check 19 | - name: Run Clippy 20 | run: cargo clippy --all-targets --all-features 21 | - name: Build 22 | run: cargo build --verbose 23 | - name: Run tests 24 | run: cargo test --verbose 25 | 26 | cargo-deny: 27 | runs-on: ubuntu-22.04 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: EmbarkStudios/cargo-deny-action@v2 31 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'releases/[0-9]+.[0-9]+.[0-9]+' 7 | 8 | jobs: 9 | docker: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | - name: Set up QEMU 15 | uses: docker/setup-qemu-action@v3 16 | - name: Set up Docker Buildx 17 | uses: docker/setup-buildx-action@v3 18 | - name: Login to Docker Hub 19 | uses: docker/login-action@v3 20 | with: 21 | username: ${{ secrets.DOCKERHUB_USERNAME }} 22 | password: ${{ secrets.DOCKERHUB_TOKEN }} 23 | - name: Build and push 24 | uses: docker/build-push-action@v5 25 | with: 26 | context: . 27 | platforms: linux/amd64,linux/arm64 28 | push: true 29 | tags: phueber/prefetcharr:latest 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.0.0] - 2025-03-18 9 | 10 | ## Removed 11 | 12 | - Remove deprecated `--jellyfin-url` and `JELLYFIN_API_KEY` options. 13 | 14 | 15 | ## [0.10.0] - 2025-02-16 16 | 17 | ## Added 18 | 19 | - Optional allow list for media server libraries. 20 | 21 | ## Changed 22 | 23 | - TLS root certificates are no longer compiled in but provided by the platform. 24 | 25 | 26 | ## [0.9.0] - 2025-01-04 27 | 28 | ## Added 29 | 30 | - Add an optional setting to retry the initial connection probing. This is 31 | always set by the Dockerfile. 32 | 33 | 34 | ## [0.8.2] - 2024-12-01 35 | 36 | ## Fixed 37 | 38 | - Use the correct episode count from Sonarr to not consider monitored episodes 39 | as downloaded. 40 | 41 | ## Added 42 | 43 | - Add a Docker template for Unraid. ([@f0rc3d](https://github.com/f0rc3d)) 44 | 45 | 46 | ## [0.8.1] - 2024-10-31 47 | 48 | ## Fixed 49 | 50 | - Plex sessions were not detected since 0.8.0. 51 | 52 | 53 | ## [0.8.0] - 2024-10-20 54 | 55 | ### Added 56 | 57 | - Optional allow list for media server users. ([@aksiksi](https://github.com/aksiksi)) 58 | 59 | 60 | ## [0.7.4] - 2024-10-07 61 | 62 | ### Changed 63 | 64 | - Update dependencies (`futures-util` got yanked for soundness issues) 65 | 66 | 67 | ## [0.7.3] - 2024-05-27 68 | 69 | ### Fixed 70 | 71 | - Fix an issue where the second season is not requested under specific 72 | circumstances. 73 | 74 | ### Changed 75 | 76 | - Update dependencies. 77 | 78 | 79 | ## [0.7.2] - 2024-04-30 80 | 81 | ### Added 82 | 83 | - Add license text files. 84 | 85 | ### Changed 86 | 87 | - Update dependencies. 88 | 89 | 90 | ## [0.7.1] - 2024-04-17 91 | 92 | ### Fixed 93 | 94 | - Fix an authentication issue with Sonarr. 95 | 96 | 97 | ## [0.7.0] - 2024-04-14 98 | 99 | ### Added 100 | 101 | - Trigger on pilot episodes. 102 | - Probe all connections at startup. 103 | 104 | ### Changed 105 | 106 | - Better reporting of errors returned by Sonarr or the media server. 107 | - Put all API keys into request headers instead of URL queries and never log 108 | them. 109 | 110 | 111 | ## [0.6.0] - 2024-04-06 112 | 113 | ### Added 114 | 115 | - Docker images are published to Docker Hub as `phueber/prefetcharr:latest`. 116 | 117 | ### Changed 118 | 119 | - Always log to `stderr`, regardless of the `--log-dir` flag. 120 | - Only log with ANSI colors if `stderr` is a terminal. 121 | - Use rustls instead of openssl. 122 | - Reduce binary and docker image size. 123 | 124 | 125 | ## [0.5.1] - 2024-04-03 126 | 127 | ### Changed 128 | 129 | - Set the default log level to `INFO`. 130 | 131 | ### Fixed 132 | 133 | - Skip over malformed series from Sonarr but search the rest. 134 | - Treat the `statistics` field of a season in Sonarr as optional. 135 | 136 | 137 | ## [0.5.0] - 2024-04-02 138 | 139 | ### Added 140 | 141 | - Add Plex support. 142 | - Log version. 143 | 144 | ### Changed 145 | 146 | - Search series by name if tvdb id is not available. 147 | 148 | 149 | ## [0.4.0] - 2024-03-15 150 | 151 | ### Added 152 | 153 | - Specify a minimum supported rust version. 154 | - Add Emby support. 155 | 156 | ### Changed 157 | 158 | - Update dependendencies. 159 | - CLI interface changed to accomodate Emby. 160 | 161 | ### Deprecated 162 | 163 | - `--jellyfin-url`, `--jellyfin-api-key` and `JELLYFIN_API_KEY` 164 | 165 | 166 | ## [0.3.0] - 2024-01-07 167 | 168 | ### Added 169 | 170 | - New command-line option `--remaining-episodes` to control when the next 171 | season is searched. 172 | - Set the new "monitor new seasons" option of Sonarr v4 when watching the 173 | last season. 174 | 175 | ### Changed 176 | 177 | - Dockerfile exposes all command-line arguments via environment variables. 178 | 179 | 180 | ## [0.2.0] - 2024-01-03 181 | 182 | ### Changed 183 | 184 | - Process a given season only once. Remember this for seven days or until the 185 | program is restarted. 186 | - Do not ignore seasons that are already monitored. 187 | - Make sure the series is monitored when monitoring a season. 188 | - Ignore seasons that were downloaded already. 189 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "addr2line" 7 | version = "0.24.2" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 10 | dependencies = [ 11 | "gimli", 12 | ] 13 | 14 | [[package]] 15 | name = "adler2" 16 | version = "2.0.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" 19 | 20 | [[package]] 21 | name = "aho-corasick" 22 | version = "1.1.3" 23 | source = "registry+https://github.com/rust-lang/crates.io-index" 24 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 25 | dependencies = [ 26 | "memchr", 27 | ] 28 | 29 | [[package]] 30 | name = "anstream" 31 | version = "0.6.18" 32 | source = "registry+https://github.com/rust-lang/crates.io-index" 33 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 34 | dependencies = [ 35 | "anstyle", 36 | "anstyle-parse", 37 | "anstyle-query", 38 | "anstyle-wincon", 39 | "colorchoice", 40 | "is_terminal_polyfill", 41 | "utf8parse", 42 | ] 43 | 44 | [[package]] 45 | name = "anstyle" 46 | version = "1.0.10" 47 | source = "registry+https://github.com/rust-lang/crates.io-index" 48 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 49 | 50 | [[package]] 51 | name = "anstyle-parse" 52 | version = "0.2.6" 53 | source = "registry+https://github.com/rust-lang/crates.io-index" 54 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 55 | dependencies = [ 56 | "utf8parse", 57 | ] 58 | 59 | [[package]] 60 | name = "anstyle-query" 61 | version = "1.1.2" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 64 | dependencies = [ 65 | "windows-sys 0.59.0", 66 | ] 67 | 68 | [[package]] 69 | name = "anstyle-wincon" 70 | version = "3.0.7" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 73 | dependencies = [ 74 | "anstyle", 75 | "once_cell", 76 | "windows-sys 0.59.0", 77 | ] 78 | 79 | [[package]] 80 | name = "anyhow" 81 | version = "1.0.97" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "dcfed56ad506cb2c684a14971b8861fdc3baaaae314b9e5f9bb532cbe3ba7a4f" 84 | 85 | [[package]] 86 | name = "ascii-canvas" 87 | version = "3.0.0" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" 90 | dependencies = [ 91 | "term", 92 | ] 93 | 94 | [[package]] 95 | name = "assert-json-diff" 96 | version = "2.0.2" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" 99 | dependencies = [ 100 | "serde", 101 | "serde_json", 102 | ] 103 | 104 | [[package]] 105 | name = "async-attributes" 106 | version = "1.1.2" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" 109 | dependencies = [ 110 | "quote", 111 | "syn 1.0.109", 112 | ] 113 | 114 | [[package]] 115 | name = "async-channel" 116 | version = "1.9.0" 117 | source = "registry+https://github.com/rust-lang/crates.io-index" 118 | checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" 119 | dependencies = [ 120 | "concurrent-queue", 121 | "event-listener 2.5.3", 122 | "futures-core", 123 | ] 124 | 125 | [[package]] 126 | name = "async-channel" 127 | version = "2.3.1" 128 | source = "registry+https://github.com/rust-lang/crates.io-index" 129 | checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" 130 | dependencies = [ 131 | "concurrent-queue", 132 | "event-listener-strategy", 133 | "futures-core", 134 | "pin-project-lite", 135 | ] 136 | 137 | [[package]] 138 | name = "async-executor" 139 | version = "1.13.1" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" 142 | dependencies = [ 143 | "async-task", 144 | "concurrent-queue", 145 | "fastrand", 146 | "futures-lite", 147 | "slab", 148 | ] 149 | 150 | [[package]] 151 | name = "async-global-executor" 152 | version = "2.4.1" 153 | source = "registry+https://github.com/rust-lang/crates.io-index" 154 | checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" 155 | dependencies = [ 156 | "async-channel 2.3.1", 157 | "async-executor", 158 | "async-io", 159 | "async-lock", 160 | "blocking", 161 | "futures-lite", 162 | "once_cell", 163 | ] 164 | 165 | [[package]] 166 | name = "async-io" 167 | version = "2.4.0" 168 | source = "registry+https://github.com/rust-lang/crates.io-index" 169 | checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" 170 | dependencies = [ 171 | "async-lock", 172 | "cfg-if", 173 | "concurrent-queue", 174 | "futures-io", 175 | "futures-lite", 176 | "parking", 177 | "polling", 178 | "rustix", 179 | "slab", 180 | "tracing", 181 | "windows-sys 0.59.0", 182 | ] 183 | 184 | [[package]] 185 | name = "async-lock" 186 | version = "3.4.0" 187 | source = "registry+https://github.com/rust-lang/crates.io-index" 188 | checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" 189 | dependencies = [ 190 | "event-listener 5.4.0", 191 | "event-listener-strategy", 192 | "pin-project-lite", 193 | ] 194 | 195 | [[package]] 196 | name = "async-object-pool" 197 | version = "0.1.5" 198 | source = "registry+https://github.com/rust-lang/crates.io-index" 199 | checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c" 200 | dependencies = [ 201 | "async-std", 202 | ] 203 | 204 | [[package]] 205 | name = "async-process" 206 | version = "2.3.0" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" 209 | dependencies = [ 210 | "async-channel 2.3.1", 211 | "async-io", 212 | "async-lock", 213 | "async-signal", 214 | "async-task", 215 | "blocking", 216 | "cfg-if", 217 | "event-listener 5.4.0", 218 | "futures-lite", 219 | "rustix", 220 | "tracing", 221 | ] 222 | 223 | [[package]] 224 | name = "async-signal" 225 | version = "0.2.10" 226 | source = "registry+https://github.com/rust-lang/crates.io-index" 227 | checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" 228 | dependencies = [ 229 | "async-io", 230 | "async-lock", 231 | "atomic-waker", 232 | "cfg-if", 233 | "futures-core", 234 | "futures-io", 235 | "rustix", 236 | "signal-hook-registry", 237 | "slab", 238 | "windows-sys 0.59.0", 239 | ] 240 | 241 | [[package]] 242 | name = "async-std" 243 | version = "1.13.0" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "c634475f29802fde2b8f0b505b1bd00dfe4df7d4a000f0b36f7671197d5c3615" 246 | dependencies = [ 247 | "async-attributes", 248 | "async-channel 1.9.0", 249 | "async-global-executor", 250 | "async-io", 251 | "async-lock", 252 | "async-process", 253 | "crossbeam-utils", 254 | "futures-channel", 255 | "futures-core", 256 | "futures-io", 257 | "futures-lite", 258 | "gloo-timers", 259 | "kv-log-macro", 260 | "log", 261 | "memchr", 262 | "once_cell", 263 | "pin-project-lite", 264 | "pin-utils", 265 | "slab", 266 | "wasm-bindgen-futures", 267 | ] 268 | 269 | [[package]] 270 | name = "async-task" 271 | version = "4.7.1" 272 | source = "registry+https://github.com/rust-lang/crates.io-index" 273 | checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" 274 | 275 | [[package]] 276 | name = "async-trait" 277 | version = "0.1.87" 278 | source = "registry+https://github.com/rust-lang/crates.io-index" 279 | checksum = "d556ec1359574147ec0c4fc5eb525f3f23263a592b1a9c07e0a75b427de55c97" 280 | dependencies = [ 281 | "proc-macro2", 282 | "quote", 283 | "syn 2.0.100", 284 | ] 285 | 286 | [[package]] 287 | name = "atomic-waker" 288 | version = "1.1.2" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" 291 | 292 | [[package]] 293 | name = "autocfg" 294 | version = "1.4.0" 295 | source = "registry+https://github.com/rust-lang/crates.io-index" 296 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 297 | 298 | [[package]] 299 | name = "backtrace" 300 | version = "0.3.74" 301 | source = "registry+https://github.com/rust-lang/crates.io-index" 302 | checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" 303 | dependencies = [ 304 | "addr2line", 305 | "cfg-if", 306 | "libc", 307 | "miniz_oxide", 308 | "object", 309 | "rustc-demangle", 310 | "windows-targets 0.52.6", 311 | ] 312 | 313 | [[package]] 314 | name = "base64" 315 | version = "0.21.7" 316 | source = "registry+https://github.com/rust-lang/crates.io-index" 317 | checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" 318 | 319 | [[package]] 320 | name = "base64" 321 | version = "0.22.1" 322 | source = "registry+https://github.com/rust-lang/crates.io-index" 323 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 324 | 325 | [[package]] 326 | name = "basic-cookies" 327 | version = "0.1.5" 328 | source = "registry+https://github.com/rust-lang/crates.io-index" 329 | checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" 330 | dependencies = [ 331 | "lalrpop", 332 | "lalrpop-util", 333 | "regex", 334 | ] 335 | 336 | [[package]] 337 | name = "bit-set" 338 | version = "0.5.3" 339 | source = "registry+https://github.com/rust-lang/crates.io-index" 340 | checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" 341 | dependencies = [ 342 | "bit-vec", 343 | ] 344 | 345 | [[package]] 346 | name = "bit-vec" 347 | version = "0.6.3" 348 | source = "registry+https://github.com/rust-lang/crates.io-index" 349 | checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" 350 | 351 | [[package]] 352 | name = "bitflags" 353 | version = "2.9.0" 354 | source = "registry+https://github.com/rust-lang/crates.io-index" 355 | checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" 356 | 357 | [[package]] 358 | name = "blocking" 359 | version = "1.6.1" 360 | source = "registry+https://github.com/rust-lang/crates.io-index" 361 | checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" 362 | dependencies = [ 363 | "async-channel 2.3.1", 364 | "async-task", 365 | "futures-io", 366 | "futures-lite", 367 | "piper", 368 | ] 369 | 370 | [[package]] 371 | name = "bumpalo" 372 | version = "3.17.0" 373 | source = "registry+https://github.com/rust-lang/crates.io-index" 374 | checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" 375 | 376 | [[package]] 377 | name = "bytes" 378 | version = "1.10.1" 379 | source = "registry+https://github.com/rust-lang/crates.io-index" 380 | checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" 381 | 382 | [[package]] 383 | name = "cc" 384 | version = "1.2.16" 385 | source = "registry+https://github.com/rust-lang/crates.io-index" 386 | checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" 387 | dependencies = [ 388 | "shlex", 389 | ] 390 | 391 | [[package]] 392 | name = "cesu8" 393 | version = "1.1.0" 394 | source = "registry+https://github.com/rust-lang/crates.io-index" 395 | checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" 396 | 397 | [[package]] 398 | name = "cfg-if" 399 | version = "1.0.0" 400 | source = "registry+https://github.com/rust-lang/crates.io-index" 401 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 402 | 403 | [[package]] 404 | name = "cfg_aliases" 405 | version = "0.2.1" 406 | source = "registry+https://github.com/rust-lang/crates.io-index" 407 | checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 408 | 409 | [[package]] 410 | name = "clap" 411 | version = "4.5.32" 412 | source = "registry+https://github.com/rust-lang/crates.io-index" 413 | checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" 414 | dependencies = [ 415 | "clap_builder", 416 | "clap_derive", 417 | ] 418 | 419 | [[package]] 420 | name = "clap_builder" 421 | version = "4.5.32" 422 | source = "registry+https://github.com/rust-lang/crates.io-index" 423 | checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" 424 | dependencies = [ 425 | "anstream", 426 | "anstyle", 427 | "clap_lex", 428 | "strsim", 429 | ] 430 | 431 | [[package]] 432 | name = "clap_derive" 433 | version = "4.5.32" 434 | source = "registry+https://github.com/rust-lang/crates.io-index" 435 | checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" 436 | dependencies = [ 437 | "heck", 438 | "proc-macro2", 439 | "quote", 440 | "syn 2.0.100", 441 | ] 442 | 443 | [[package]] 444 | name = "clap_lex" 445 | version = "0.7.4" 446 | source = "registry+https://github.com/rust-lang/crates.io-index" 447 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 448 | 449 | [[package]] 450 | name = "colorchoice" 451 | version = "1.0.3" 452 | source = "registry+https://github.com/rust-lang/crates.io-index" 453 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 454 | 455 | [[package]] 456 | name = "combine" 457 | version = "4.6.7" 458 | source = "registry+https://github.com/rust-lang/crates.io-index" 459 | checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" 460 | dependencies = [ 461 | "bytes", 462 | "memchr", 463 | ] 464 | 465 | [[package]] 466 | name = "concurrent-queue" 467 | version = "2.5.0" 468 | source = "registry+https://github.com/rust-lang/crates.io-index" 469 | checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" 470 | dependencies = [ 471 | "crossbeam-utils", 472 | ] 473 | 474 | [[package]] 475 | name = "core-foundation" 476 | version = "0.10.0" 477 | source = "registry+https://github.com/rust-lang/crates.io-index" 478 | checksum = "b55271e5c8c478ad3f38ad24ef34923091e0548492a266d19b3c0b4d82574c63" 479 | dependencies = [ 480 | "core-foundation-sys", 481 | "libc", 482 | ] 483 | 484 | [[package]] 485 | name = "core-foundation-sys" 486 | version = "0.8.7" 487 | source = "registry+https://github.com/rust-lang/crates.io-index" 488 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 489 | 490 | [[package]] 491 | name = "crossbeam-channel" 492 | version = "0.5.14" 493 | source = "registry+https://github.com/rust-lang/crates.io-index" 494 | checksum = "06ba6d68e24814cb8de6bb986db8222d3a027d15872cabc0d18817bc3c0e4471" 495 | dependencies = [ 496 | "crossbeam-utils", 497 | ] 498 | 499 | [[package]] 500 | name = "crossbeam-utils" 501 | version = "0.8.21" 502 | source = "registry+https://github.com/rust-lang/crates.io-index" 503 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 504 | 505 | [[package]] 506 | name = "crunchy" 507 | version = "0.2.3" 508 | source = "registry+https://github.com/rust-lang/crates.io-index" 509 | checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929" 510 | 511 | [[package]] 512 | name = "deranged" 513 | version = "0.3.11" 514 | source = "registry+https://github.com/rust-lang/crates.io-index" 515 | checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" 516 | dependencies = [ 517 | "powerfmt", 518 | ] 519 | 520 | [[package]] 521 | name = "dirs-next" 522 | version = "2.0.0" 523 | source = "registry+https://github.com/rust-lang/crates.io-index" 524 | checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" 525 | dependencies = [ 526 | "cfg-if", 527 | "dirs-sys-next", 528 | ] 529 | 530 | [[package]] 531 | name = "dirs-sys-next" 532 | version = "0.1.2" 533 | source = "registry+https://github.com/rust-lang/crates.io-index" 534 | checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" 535 | dependencies = [ 536 | "libc", 537 | "redox_users", 538 | "winapi", 539 | ] 540 | 541 | [[package]] 542 | name = "displaydoc" 543 | version = "0.2.5" 544 | source = "registry+https://github.com/rust-lang/crates.io-index" 545 | checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" 546 | dependencies = [ 547 | "proc-macro2", 548 | "quote", 549 | "syn 2.0.100", 550 | ] 551 | 552 | [[package]] 553 | name = "either" 554 | version = "1.15.0" 555 | source = "registry+https://github.com/rust-lang/crates.io-index" 556 | checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" 557 | 558 | [[package]] 559 | name = "ena" 560 | version = "0.14.3" 561 | source = "registry+https://github.com/rust-lang/crates.io-index" 562 | checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" 563 | dependencies = [ 564 | "log", 565 | ] 566 | 567 | [[package]] 568 | name = "encoding_rs" 569 | version = "0.8.35" 570 | source = "registry+https://github.com/rust-lang/crates.io-index" 571 | checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 572 | dependencies = [ 573 | "cfg-if", 574 | ] 575 | 576 | [[package]] 577 | name = "equivalent" 578 | version = "1.0.2" 579 | source = "registry+https://github.com/rust-lang/crates.io-index" 580 | checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 581 | 582 | [[package]] 583 | name = "errno" 584 | version = "0.3.10" 585 | source = "registry+https://github.com/rust-lang/crates.io-index" 586 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 587 | dependencies = [ 588 | "libc", 589 | "windows-sys 0.59.0", 590 | ] 591 | 592 | [[package]] 593 | name = "event-listener" 594 | version = "2.5.3" 595 | source = "registry+https://github.com/rust-lang/crates.io-index" 596 | checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" 597 | 598 | [[package]] 599 | name = "event-listener" 600 | version = "5.4.0" 601 | source = "registry+https://github.com/rust-lang/crates.io-index" 602 | checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" 603 | dependencies = [ 604 | "concurrent-queue", 605 | "parking", 606 | "pin-project-lite", 607 | ] 608 | 609 | [[package]] 610 | name = "event-listener-strategy" 611 | version = "0.5.3" 612 | source = "registry+https://github.com/rust-lang/crates.io-index" 613 | checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" 614 | dependencies = [ 615 | "event-listener 5.4.0", 616 | "pin-project-lite", 617 | ] 618 | 619 | [[package]] 620 | name = "fastrand" 621 | version = "2.3.0" 622 | source = "registry+https://github.com/rust-lang/crates.io-index" 623 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 624 | 625 | [[package]] 626 | name = "fixedbitset" 627 | version = "0.4.2" 628 | source = "registry+https://github.com/rust-lang/crates.io-index" 629 | checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" 630 | 631 | [[package]] 632 | name = "fnv" 633 | version = "1.0.7" 634 | source = "registry+https://github.com/rust-lang/crates.io-index" 635 | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 636 | 637 | [[package]] 638 | name = "form_urlencoded" 639 | version = "1.2.1" 640 | source = "registry+https://github.com/rust-lang/crates.io-index" 641 | checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" 642 | dependencies = [ 643 | "percent-encoding", 644 | ] 645 | 646 | [[package]] 647 | name = "futures" 648 | version = "0.3.31" 649 | source = "registry+https://github.com/rust-lang/crates.io-index" 650 | checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" 651 | dependencies = [ 652 | "futures-channel", 653 | "futures-core", 654 | "futures-io", 655 | "futures-sink", 656 | "futures-task", 657 | "futures-util", 658 | ] 659 | 660 | [[package]] 661 | name = "futures-channel" 662 | version = "0.3.31" 663 | source = "registry+https://github.com/rust-lang/crates.io-index" 664 | checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" 665 | dependencies = [ 666 | "futures-core", 667 | "futures-sink", 668 | ] 669 | 670 | [[package]] 671 | name = "futures-core" 672 | version = "0.3.31" 673 | source = "registry+https://github.com/rust-lang/crates.io-index" 674 | checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 675 | 676 | [[package]] 677 | name = "futures-io" 678 | version = "0.3.31" 679 | source = "registry+https://github.com/rust-lang/crates.io-index" 680 | checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 681 | 682 | [[package]] 683 | name = "futures-lite" 684 | version = "2.6.0" 685 | source = "registry+https://github.com/rust-lang/crates.io-index" 686 | checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" 687 | dependencies = [ 688 | "fastrand", 689 | "futures-core", 690 | "futures-io", 691 | "parking", 692 | "pin-project-lite", 693 | ] 694 | 695 | [[package]] 696 | name = "futures-macro" 697 | version = "0.3.31" 698 | source = "registry+https://github.com/rust-lang/crates.io-index" 699 | checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" 700 | dependencies = [ 701 | "proc-macro2", 702 | "quote", 703 | "syn 2.0.100", 704 | ] 705 | 706 | [[package]] 707 | name = "futures-sink" 708 | version = "0.3.31" 709 | source = "registry+https://github.com/rust-lang/crates.io-index" 710 | checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" 711 | 712 | [[package]] 713 | name = "futures-task" 714 | version = "0.3.31" 715 | source = "registry+https://github.com/rust-lang/crates.io-index" 716 | checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" 717 | 718 | [[package]] 719 | name = "futures-util" 720 | version = "0.3.31" 721 | source = "registry+https://github.com/rust-lang/crates.io-index" 722 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" 723 | dependencies = [ 724 | "futures-channel", 725 | "futures-core", 726 | "futures-io", 727 | "futures-macro", 728 | "futures-sink", 729 | "futures-task", 730 | "memchr", 731 | "pin-project-lite", 732 | "pin-utils", 733 | "slab", 734 | ] 735 | 736 | [[package]] 737 | name = "getrandom" 738 | version = "0.2.15" 739 | source = "registry+https://github.com/rust-lang/crates.io-index" 740 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 741 | dependencies = [ 742 | "cfg-if", 743 | "js-sys", 744 | "libc", 745 | "wasi", 746 | "wasm-bindgen", 747 | ] 748 | 749 | [[package]] 750 | name = "gimli" 751 | version = "0.31.1" 752 | source = "registry+https://github.com/rust-lang/crates.io-index" 753 | checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" 754 | 755 | [[package]] 756 | name = "gloo-timers" 757 | version = "0.3.0" 758 | source = "registry+https://github.com/rust-lang/crates.io-index" 759 | checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" 760 | dependencies = [ 761 | "futures-channel", 762 | "futures-core", 763 | "js-sys", 764 | "wasm-bindgen", 765 | ] 766 | 767 | [[package]] 768 | name = "h2" 769 | version = "0.4.8" 770 | source = "registry+https://github.com/rust-lang/crates.io-index" 771 | checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" 772 | dependencies = [ 773 | "atomic-waker", 774 | "bytes", 775 | "fnv", 776 | "futures-core", 777 | "futures-sink", 778 | "http 1.2.0", 779 | "indexmap", 780 | "slab", 781 | "tokio", 782 | "tokio-util", 783 | "tracing", 784 | ] 785 | 786 | [[package]] 787 | name = "hashbrown" 788 | version = "0.15.2" 789 | source = "registry+https://github.com/rust-lang/crates.io-index" 790 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 791 | 792 | [[package]] 793 | name = "heck" 794 | version = "0.5.0" 795 | source = "registry+https://github.com/rust-lang/crates.io-index" 796 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 797 | 798 | [[package]] 799 | name = "hermit-abi" 800 | version = "0.4.0" 801 | source = "registry+https://github.com/rust-lang/crates.io-index" 802 | checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" 803 | 804 | [[package]] 805 | name = "http" 806 | version = "0.2.12" 807 | source = "registry+https://github.com/rust-lang/crates.io-index" 808 | checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" 809 | dependencies = [ 810 | "bytes", 811 | "fnv", 812 | "itoa", 813 | ] 814 | 815 | [[package]] 816 | name = "http" 817 | version = "1.2.0" 818 | source = "registry+https://github.com/rust-lang/crates.io-index" 819 | checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea" 820 | dependencies = [ 821 | "bytes", 822 | "fnv", 823 | "itoa", 824 | ] 825 | 826 | [[package]] 827 | name = "http-body" 828 | version = "0.4.6" 829 | source = "registry+https://github.com/rust-lang/crates.io-index" 830 | checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" 831 | dependencies = [ 832 | "bytes", 833 | "http 0.2.12", 834 | "pin-project-lite", 835 | ] 836 | 837 | [[package]] 838 | name = "http-body" 839 | version = "1.0.1" 840 | source = "registry+https://github.com/rust-lang/crates.io-index" 841 | checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" 842 | dependencies = [ 843 | "bytes", 844 | "http 1.2.0", 845 | ] 846 | 847 | [[package]] 848 | name = "http-body-util" 849 | version = "0.1.2" 850 | source = "registry+https://github.com/rust-lang/crates.io-index" 851 | checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" 852 | dependencies = [ 853 | "bytes", 854 | "futures-util", 855 | "http 1.2.0", 856 | "http-body 1.0.1", 857 | "pin-project-lite", 858 | ] 859 | 860 | [[package]] 861 | name = "httparse" 862 | version = "1.10.1" 863 | source = "registry+https://github.com/rust-lang/crates.io-index" 864 | checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" 865 | 866 | [[package]] 867 | name = "httpdate" 868 | version = "1.0.3" 869 | source = "registry+https://github.com/rust-lang/crates.io-index" 870 | checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 871 | 872 | [[package]] 873 | name = "httpmock" 874 | version = "0.7.0" 875 | source = "registry+https://github.com/rust-lang/crates.io-index" 876 | checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" 877 | dependencies = [ 878 | "assert-json-diff", 879 | "async-object-pool", 880 | "async-std", 881 | "async-trait", 882 | "base64 0.21.7", 883 | "basic-cookies", 884 | "crossbeam-utils", 885 | "form_urlencoded", 886 | "futures-util", 887 | "hyper 0.14.32", 888 | "lazy_static", 889 | "levenshtein", 890 | "log", 891 | "regex", 892 | "serde", 893 | "serde_json", 894 | "serde_regex", 895 | "similar", 896 | "tokio", 897 | "url", 898 | ] 899 | 900 | [[package]] 901 | name = "hyper" 902 | version = "0.14.32" 903 | source = "registry+https://github.com/rust-lang/crates.io-index" 904 | checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" 905 | dependencies = [ 906 | "bytes", 907 | "futures-channel", 908 | "futures-core", 909 | "futures-util", 910 | "http 0.2.12", 911 | "http-body 0.4.6", 912 | "httparse", 913 | "httpdate", 914 | "itoa", 915 | "pin-project-lite", 916 | "socket2", 917 | "tokio", 918 | "tower-service", 919 | "tracing", 920 | "want", 921 | ] 922 | 923 | [[package]] 924 | name = "hyper" 925 | version = "1.6.0" 926 | source = "registry+https://github.com/rust-lang/crates.io-index" 927 | checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" 928 | dependencies = [ 929 | "bytes", 930 | "futures-channel", 931 | "futures-util", 932 | "h2", 933 | "http 1.2.0", 934 | "http-body 1.0.1", 935 | "httparse", 936 | "itoa", 937 | "pin-project-lite", 938 | "smallvec", 939 | "tokio", 940 | "want", 941 | ] 942 | 943 | [[package]] 944 | name = "hyper-rustls" 945 | version = "0.27.5" 946 | source = "registry+https://github.com/rust-lang/crates.io-index" 947 | checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2" 948 | dependencies = [ 949 | "futures-util", 950 | "http 1.2.0", 951 | "hyper 1.6.0", 952 | "hyper-util", 953 | "rustls", 954 | "rustls-pki-types", 955 | "tokio", 956 | "tokio-rustls", 957 | "tower-service", 958 | "webpki-roots", 959 | ] 960 | 961 | [[package]] 962 | name = "hyper-util" 963 | version = "0.1.10" 964 | source = "registry+https://github.com/rust-lang/crates.io-index" 965 | checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" 966 | dependencies = [ 967 | "bytes", 968 | "futures-channel", 969 | "futures-util", 970 | "http 1.2.0", 971 | "http-body 1.0.1", 972 | "hyper 1.6.0", 973 | "pin-project-lite", 974 | "socket2", 975 | "tokio", 976 | "tower-service", 977 | "tracing", 978 | ] 979 | 980 | [[package]] 981 | name = "icu_collections" 982 | version = "1.5.0" 983 | source = "registry+https://github.com/rust-lang/crates.io-index" 984 | checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" 985 | dependencies = [ 986 | "displaydoc", 987 | "yoke", 988 | "zerofrom", 989 | "zerovec", 990 | ] 991 | 992 | [[package]] 993 | name = "icu_locid" 994 | version = "1.5.0" 995 | source = "registry+https://github.com/rust-lang/crates.io-index" 996 | checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" 997 | dependencies = [ 998 | "displaydoc", 999 | "litemap", 1000 | "tinystr", 1001 | "writeable", 1002 | "zerovec", 1003 | ] 1004 | 1005 | [[package]] 1006 | name = "icu_locid_transform" 1007 | version = "1.5.0" 1008 | source = "registry+https://github.com/rust-lang/crates.io-index" 1009 | checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" 1010 | dependencies = [ 1011 | "displaydoc", 1012 | "icu_locid", 1013 | "icu_locid_transform_data", 1014 | "icu_provider", 1015 | "tinystr", 1016 | "zerovec", 1017 | ] 1018 | 1019 | [[package]] 1020 | name = "icu_locid_transform_data" 1021 | version = "1.5.0" 1022 | source = "registry+https://github.com/rust-lang/crates.io-index" 1023 | checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" 1024 | 1025 | [[package]] 1026 | name = "icu_normalizer" 1027 | version = "1.5.0" 1028 | source = "registry+https://github.com/rust-lang/crates.io-index" 1029 | checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" 1030 | dependencies = [ 1031 | "displaydoc", 1032 | "icu_collections", 1033 | "icu_normalizer_data", 1034 | "icu_properties", 1035 | "icu_provider", 1036 | "smallvec", 1037 | "utf16_iter", 1038 | "utf8_iter", 1039 | "write16", 1040 | "zerovec", 1041 | ] 1042 | 1043 | [[package]] 1044 | name = "icu_normalizer_data" 1045 | version = "1.5.0" 1046 | source = "registry+https://github.com/rust-lang/crates.io-index" 1047 | checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" 1048 | 1049 | [[package]] 1050 | name = "icu_properties" 1051 | version = "1.5.1" 1052 | source = "registry+https://github.com/rust-lang/crates.io-index" 1053 | checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" 1054 | dependencies = [ 1055 | "displaydoc", 1056 | "icu_collections", 1057 | "icu_locid_transform", 1058 | "icu_properties_data", 1059 | "icu_provider", 1060 | "tinystr", 1061 | "zerovec", 1062 | ] 1063 | 1064 | [[package]] 1065 | name = "icu_properties_data" 1066 | version = "1.5.0" 1067 | source = "registry+https://github.com/rust-lang/crates.io-index" 1068 | checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" 1069 | 1070 | [[package]] 1071 | name = "icu_provider" 1072 | version = "1.5.0" 1073 | source = "registry+https://github.com/rust-lang/crates.io-index" 1074 | checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" 1075 | dependencies = [ 1076 | "displaydoc", 1077 | "icu_locid", 1078 | "icu_provider_macros", 1079 | "stable_deref_trait", 1080 | "tinystr", 1081 | "writeable", 1082 | "yoke", 1083 | "zerofrom", 1084 | "zerovec", 1085 | ] 1086 | 1087 | [[package]] 1088 | name = "icu_provider_macros" 1089 | version = "1.5.0" 1090 | source = "registry+https://github.com/rust-lang/crates.io-index" 1091 | checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" 1092 | dependencies = [ 1093 | "proc-macro2", 1094 | "quote", 1095 | "syn 2.0.100", 1096 | ] 1097 | 1098 | [[package]] 1099 | name = "idna" 1100 | version = "1.0.3" 1101 | source = "registry+https://github.com/rust-lang/crates.io-index" 1102 | checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" 1103 | dependencies = [ 1104 | "idna_adapter", 1105 | "smallvec", 1106 | "utf8_iter", 1107 | ] 1108 | 1109 | [[package]] 1110 | name = "idna_adapter" 1111 | version = "1.2.0" 1112 | source = "registry+https://github.com/rust-lang/crates.io-index" 1113 | checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" 1114 | dependencies = [ 1115 | "icu_normalizer", 1116 | "icu_properties", 1117 | ] 1118 | 1119 | [[package]] 1120 | name = "indexmap" 1121 | version = "2.8.0" 1122 | source = "registry+https://github.com/rust-lang/crates.io-index" 1123 | checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" 1124 | dependencies = [ 1125 | "equivalent", 1126 | "hashbrown", 1127 | ] 1128 | 1129 | [[package]] 1130 | name = "ipnet" 1131 | version = "2.11.0" 1132 | source = "registry+https://github.com/rust-lang/crates.io-index" 1133 | checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" 1134 | 1135 | [[package]] 1136 | name = "is_terminal_polyfill" 1137 | version = "1.70.1" 1138 | source = "registry+https://github.com/rust-lang/crates.io-index" 1139 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 1140 | 1141 | [[package]] 1142 | name = "itertools" 1143 | version = "0.11.0" 1144 | source = "registry+https://github.com/rust-lang/crates.io-index" 1145 | checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" 1146 | dependencies = [ 1147 | "either", 1148 | ] 1149 | 1150 | [[package]] 1151 | name = "itoa" 1152 | version = "1.0.15" 1153 | source = "registry+https://github.com/rust-lang/crates.io-index" 1154 | checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" 1155 | 1156 | [[package]] 1157 | name = "jni" 1158 | version = "0.21.1" 1159 | source = "registry+https://github.com/rust-lang/crates.io-index" 1160 | checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" 1161 | dependencies = [ 1162 | "cesu8", 1163 | "cfg-if", 1164 | "combine", 1165 | "jni-sys", 1166 | "log", 1167 | "thiserror 1.0.69", 1168 | "walkdir", 1169 | "windows-sys 0.45.0", 1170 | ] 1171 | 1172 | [[package]] 1173 | name = "jni-sys" 1174 | version = "0.3.0" 1175 | source = "registry+https://github.com/rust-lang/crates.io-index" 1176 | checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" 1177 | 1178 | [[package]] 1179 | name = "js-sys" 1180 | version = "0.3.77" 1181 | source = "registry+https://github.com/rust-lang/crates.io-index" 1182 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 1183 | dependencies = [ 1184 | "once_cell", 1185 | "wasm-bindgen", 1186 | ] 1187 | 1188 | [[package]] 1189 | name = "kv-log-macro" 1190 | version = "1.0.7" 1191 | source = "registry+https://github.com/rust-lang/crates.io-index" 1192 | checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" 1193 | dependencies = [ 1194 | "log", 1195 | ] 1196 | 1197 | [[package]] 1198 | name = "lalrpop" 1199 | version = "0.20.2" 1200 | source = "registry+https://github.com/rust-lang/crates.io-index" 1201 | checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" 1202 | dependencies = [ 1203 | "ascii-canvas", 1204 | "bit-set", 1205 | "ena", 1206 | "itertools", 1207 | "lalrpop-util", 1208 | "petgraph", 1209 | "pico-args", 1210 | "regex", 1211 | "regex-syntax 0.8.5", 1212 | "string_cache", 1213 | "term", 1214 | "tiny-keccak", 1215 | "unicode-xid", 1216 | "walkdir", 1217 | ] 1218 | 1219 | [[package]] 1220 | name = "lalrpop-util" 1221 | version = "0.20.2" 1222 | source = "registry+https://github.com/rust-lang/crates.io-index" 1223 | checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" 1224 | dependencies = [ 1225 | "regex-automata 0.4.9", 1226 | ] 1227 | 1228 | [[package]] 1229 | name = "lazy_static" 1230 | version = "1.5.0" 1231 | source = "registry+https://github.com/rust-lang/crates.io-index" 1232 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 1233 | 1234 | [[package]] 1235 | name = "levenshtein" 1236 | version = "1.0.5" 1237 | source = "registry+https://github.com/rust-lang/crates.io-index" 1238 | checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" 1239 | 1240 | [[package]] 1241 | name = "libc" 1242 | version = "0.2.170" 1243 | source = "registry+https://github.com/rust-lang/crates.io-index" 1244 | checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" 1245 | 1246 | [[package]] 1247 | name = "libredox" 1248 | version = "0.1.3" 1249 | source = "registry+https://github.com/rust-lang/crates.io-index" 1250 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 1251 | dependencies = [ 1252 | "bitflags", 1253 | "libc", 1254 | ] 1255 | 1256 | [[package]] 1257 | name = "linux-raw-sys" 1258 | version = "0.4.15" 1259 | source = "registry+https://github.com/rust-lang/crates.io-index" 1260 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 1261 | 1262 | [[package]] 1263 | name = "litemap" 1264 | version = "0.7.5" 1265 | source = "registry+https://github.com/rust-lang/crates.io-index" 1266 | checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" 1267 | 1268 | [[package]] 1269 | name = "lock_api" 1270 | version = "0.4.12" 1271 | source = "registry+https://github.com/rust-lang/crates.io-index" 1272 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 1273 | dependencies = [ 1274 | "autocfg", 1275 | "scopeguard", 1276 | ] 1277 | 1278 | [[package]] 1279 | name = "log" 1280 | version = "0.4.26" 1281 | source = "registry+https://github.com/rust-lang/crates.io-index" 1282 | checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" 1283 | dependencies = [ 1284 | "value-bag", 1285 | ] 1286 | 1287 | [[package]] 1288 | name = "matchers" 1289 | version = "0.1.0" 1290 | source = "registry+https://github.com/rust-lang/crates.io-index" 1291 | checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" 1292 | dependencies = [ 1293 | "regex-automata 0.1.10", 1294 | ] 1295 | 1296 | [[package]] 1297 | name = "memchr" 1298 | version = "2.7.4" 1299 | source = "registry+https://github.com/rust-lang/crates.io-index" 1300 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 1301 | 1302 | [[package]] 1303 | name = "mime" 1304 | version = "0.3.17" 1305 | source = "registry+https://github.com/rust-lang/crates.io-index" 1306 | checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 1307 | 1308 | [[package]] 1309 | name = "miniz_oxide" 1310 | version = "0.8.5" 1311 | source = "registry+https://github.com/rust-lang/crates.io-index" 1312 | checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" 1313 | dependencies = [ 1314 | "adler2", 1315 | ] 1316 | 1317 | [[package]] 1318 | name = "mio" 1319 | version = "1.0.3" 1320 | source = "registry+https://github.com/rust-lang/crates.io-index" 1321 | checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 1322 | dependencies = [ 1323 | "libc", 1324 | "wasi", 1325 | "windows-sys 0.52.0", 1326 | ] 1327 | 1328 | [[package]] 1329 | name = "new_debug_unreachable" 1330 | version = "1.0.6" 1331 | source = "registry+https://github.com/rust-lang/crates.io-index" 1332 | checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" 1333 | 1334 | [[package]] 1335 | name = "nu-ansi-term" 1336 | version = "0.46.0" 1337 | source = "registry+https://github.com/rust-lang/crates.io-index" 1338 | checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" 1339 | dependencies = [ 1340 | "overload", 1341 | "winapi", 1342 | ] 1343 | 1344 | [[package]] 1345 | name = "num-conv" 1346 | version = "0.1.0" 1347 | source = "registry+https://github.com/rust-lang/crates.io-index" 1348 | checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" 1349 | 1350 | [[package]] 1351 | name = "object" 1352 | version = "0.36.7" 1353 | source = "registry+https://github.com/rust-lang/crates.io-index" 1354 | checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" 1355 | dependencies = [ 1356 | "memchr", 1357 | ] 1358 | 1359 | [[package]] 1360 | name = "once_cell" 1361 | version = "1.21.0" 1362 | source = "registry+https://github.com/rust-lang/crates.io-index" 1363 | checksum = "cde51589ab56b20a6f686b2c68f7a0bd6add753d697abf720d63f8db3ab7b1ad" 1364 | 1365 | [[package]] 1366 | name = "openssl-probe" 1367 | version = "0.1.6" 1368 | source = "registry+https://github.com/rust-lang/crates.io-index" 1369 | checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" 1370 | 1371 | [[package]] 1372 | name = "overload" 1373 | version = "0.1.1" 1374 | source = "registry+https://github.com/rust-lang/crates.io-index" 1375 | checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" 1376 | 1377 | [[package]] 1378 | name = "parking" 1379 | version = "2.2.1" 1380 | source = "registry+https://github.com/rust-lang/crates.io-index" 1381 | checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" 1382 | 1383 | [[package]] 1384 | name = "parking_lot" 1385 | version = "0.12.3" 1386 | source = "registry+https://github.com/rust-lang/crates.io-index" 1387 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 1388 | dependencies = [ 1389 | "lock_api", 1390 | "parking_lot_core", 1391 | ] 1392 | 1393 | [[package]] 1394 | name = "parking_lot_core" 1395 | version = "0.9.10" 1396 | source = "registry+https://github.com/rust-lang/crates.io-index" 1397 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 1398 | dependencies = [ 1399 | "cfg-if", 1400 | "libc", 1401 | "redox_syscall", 1402 | "smallvec", 1403 | "windows-targets 0.52.6", 1404 | ] 1405 | 1406 | [[package]] 1407 | name = "percent-encoding" 1408 | version = "2.3.1" 1409 | source = "registry+https://github.com/rust-lang/crates.io-index" 1410 | checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" 1411 | 1412 | [[package]] 1413 | name = "petgraph" 1414 | version = "0.6.5" 1415 | source = "registry+https://github.com/rust-lang/crates.io-index" 1416 | checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" 1417 | dependencies = [ 1418 | "fixedbitset", 1419 | "indexmap", 1420 | ] 1421 | 1422 | [[package]] 1423 | name = "phf_shared" 1424 | version = "0.11.3" 1425 | source = "registry+https://github.com/rust-lang/crates.io-index" 1426 | checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" 1427 | dependencies = [ 1428 | "siphasher", 1429 | ] 1430 | 1431 | [[package]] 1432 | name = "pico-args" 1433 | version = "0.5.0" 1434 | source = "registry+https://github.com/rust-lang/crates.io-index" 1435 | checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" 1436 | 1437 | [[package]] 1438 | name = "pin-project-lite" 1439 | version = "0.2.16" 1440 | source = "registry+https://github.com/rust-lang/crates.io-index" 1441 | checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" 1442 | 1443 | [[package]] 1444 | name = "pin-utils" 1445 | version = "0.1.0" 1446 | source = "registry+https://github.com/rust-lang/crates.io-index" 1447 | checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 1448 | 1449 | [[package]] 1450 | name = "piper" 1451 | version = "0.2.4" 1452 | source = "registry+https://github.com/rust-lang/crates.io-index" 1453 | checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" 1454 | dependencies = [ 1455 | "atomic-waker", 1456 | "fastrand", 1457 | "futures-io", 1458 | ] 1459 | 1460 | [[package]] 1461 | name = "polling" 1462 | version = "3.7.4" 1463 | source = "registry+https://github.com/rust-lang/crates.io-index" 1464 | checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" 1465 | dependencies = [ 1466 | "cfg-if", 1467 | "concurrent-queue", 1468 | "hermit-abi", 1469 | "pin-project-lite", 1470 | "rustix", 1471 | "tracing", 1472 | "windows-sys 0.59.0", 1473 | ] 1474 | 1475 | [[package]] 1476 | name = "powerfmt" 1477 | version = "0.2.0" 1478 | source = "registry+https://github.com/rust-lang/crates.io-index" 1479 | checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 1480 | 1481 | [[package]] 1482 | name = "ppv-lite86" 1483 | version = "0.2.21" 1484 | source = "registry+https://github.com/rust-lang/crates.io-index" 1485 | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" 1486 | dependencies = [ 1487 | "zerocopy", 1488 | ] 1489 | 1490 | [[package]] 1491 | name = "precomputed-hash" 1492 | version = "0.1.1" 1493 | source = "registry+https://github.com/rust-lang/crates.io-index" 1494 | checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" 1495 | 1496 | [[package]] 1497 | name = "prefetcharr" 1498 | version = "1.0.0" 1499 | dependencies = [ 1500 | "anyhow", 1501 | "clap", 1502 | "futures", 1503 | "httpmock", 1504 | "reqwest", 1505 | "rustls", 1506 | "rustls-platform-verifier", 1507 | "serde", 1508 | "serde_json", 1509 | "tokio", 1510 | "tokio-util", 1511 | "tracing", 1512 | "tracing-appender", 1513 | "tracing-subscriber", 1514 | ] 1515 | 1516 | [[package]] 1517 | name = "proc-macro2" 1518 | version = "1.0.94" 1519 | source = "registry+https://github.com/rust-lang/crates.io-index" 1520 | checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" 1521 | dependencies = [ 1522 | "unicode-ident", 1523 | ] 1524 | 1525 | [[package]] 1526 | name = "quinn" 1527 | version = "0.11.6" 1528 | source = "registry+https://github.com/rust-lang/crates.io-index" 1529 | checksum = "62e96808277ec6f97351a2380e6c25114bc9e67037775464979f3037c92d05ef" 1530 | dependencies = [ 1531 | "bytes", 1532 | "pin-project-lite", 1533 | "quinn-proto", 1534 | "quinn-udp", 1535 | "rustc-hash", 1536 | "rustls", 1537 | "socket2", 1538 | "thiserror 2.0.12", 1539 | "tokio", 1540 | "tracing", 1541 | ] 1542 | 1543 | [[package]] 1544 | name = "quinn-proto" 1545 | version = "0.11.9" 1546 | source = "registry+https://github.com/rust-lang/crates.io-index" 1547 | checksum = "a2fe5ef3495d7d2e377ff17b1a8ce2ee2ec2a18cde8b6ad6619d65d0701c135d" 1548 | dependencies = [ 1549 | "bytes", 1550 | "getrandom", 1551 | "rand", 1552 | "ring", 1553 | "rustc-hash", 1554 | "rustls", 1555 | "rustls-pki-types", 1556 | "slab", 1557 | "thiserror 2.0.12", 1558 | "tinyvec", 1559 | "tracing", 1560 | "web-time", 1561 | ] 1562 | 1563 | [[package]] 1564 | name = "quinn-udp" 1565 | version = "0.5.10" 1566 | source = "registry+https://github.com/rust-lang/crates.io-index" 1567 | checksum = "e46f3055866785f6b92bc6164b76be02ca8f2eb4b002c0354b28cf4c119e5944" 1568 | dependencies = [ 1569 | "cfg_aliases", 1570 | "libc", 1571 | "once_cell", 1572 | "socket2", 1573 | "tracing", 1574 | "windows-sys 0.59.0", 1575 | ] 1576 | 1577 | [[package]] 1578 | name = "quote" 1579 | version = "1.0.39" 1580 | source = "registry+https://github.com/rust-lang/crates.io-index" 1581 | checksum = "c1f1914ce909e1658d9907913b4b91947430c7d9be598b15a1912935b8c04801" 1582 | dependencies = [ 1583 | "proc-macro2", 1584 | ] 1585 | 1586 | [[package]] 1587 | name = "rand" 1588 | version = "0.8.5" 1589 | source = "registry+https://github.com/rust-lang/crates.io-index" 1590 | checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" 1591 | dependencies = [ 1592 | "libc", 1593 | "rand_chacha", 1594 | "rand_core", 1595 | ] 1596 | 1597 | [[package]] 1598 | name = "rand_chacha" 1599 | version = "0.3.1" 1600 | source = "registry+https://github.com/rust-lang/crates.io-index" 1601 | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" 1602 | dependencies = [ 1603 | "ppv-lite86", 1604 | "rand_core", 1605 | ] 1606 | 1607 | [[package]] 1608 | name = "rand_core" 1609 | version = "0.6.4" 1610 | source = "registry+https://github.com/rust-lang/crates.io-index" 1611 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" 1612 | dependencies = [ 1613 | "getrandom", 1614 | ] 1615 | 1616 | [[package]] 1617 | name = "redox_syscall" 1618 | version = "0.5.10" 1619 | source = "registry+https://github.com/rust-lang/crates.io-index" 1620 | checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" 1621 | dependencies = [ 1622 | "bitflags", 1623 | ] 1624 | 1625 | [[package]] 1626 | name = "redox_users" 1627 | version = "0.4.6" 1628 | source = "registry+https://github.com/rust-lang/crates.io-index" 1629 | checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 1630 | dependencies = [ 1631 | "getrandom", 1632 | "libredox", 1633 | "thiserror 1.0.69", 1634 | ] 1635 | 1636 | [[package]] 1637 | name = "regex" 1638 | version = "1.11.1" 1639 | source = "registry+https://github.com/rust-lang/crates.io-index" 1640 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 1641 | dependencies = [ 1642 | "aho-corasick", 1643 | "memchr", 1644 | "regex-automata 0.4.9", 1645 | "regex-syntax 0.8.5", 1646 | ] 1647 | 1648 | [[package]] 1649 | name = "regex-automata" 1650 | version = "0.1.10" 1651 | source = "registry+https://github.com/rust-lang/crates.io-index" 1652 | checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" 1653 | dependencies = [ 1654 | "regex-syntax 0.6.29", 1655 | ] 1656 | 1657 | [[package]] 1658 | name = "regex-automata" 1659 | version = "0.4.9" 1660 | source = "registry+https://github.com/rust-lang/crates.io-index" 1661 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 1662 | dependencies = [ 1663 | "aho-corasick", 1664 | "memchr", 1665 | "regex-syntax 0.8.5", 1666 | ] 1667 | 1668 | [[package]] 1669 | name = "regex-syntax" 1670 | version = "0.6.29" 1671 | source = "registry+https://github.com/rust-lang/crates.io-index" 1672 | checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" 1673 | 1674 | [[package]] 1675 | name = "regex-syntax" 1676 | version = "0.8.5" 1677 | source = "registry+https://github.com/rust-lang/crates.io-index" 1678 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 1679 | 1680 | [[package]] 1681 | name = "reqwest" 1682 | version = "0.12.12" 1683 | source = "registry+https://github.com/rust-lang/crates.io-index" 1684 | checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" 1685 | dependencies = [ 1686 | "base64 0.22.1", 1687 | "bytes", 1688 | "encoding_rs", 1689 | "futures-core", 1690 | "futures-util", 1691 | "h2", 1692 | "http 1.2.0", 1693 | "http-body 1.0.1", 1694 | "http-body-util", 1695 | "hyper 1.6.0", 1696 | "hyper-rustls", 1697 | "hyper-util", 1698 | "ipnet", 1699 | "js-sys", 1700 | "log", 1701 | "mime", 1702 | "once_cell", 1703 | "percent-encoding", 1704 | "pin-project-lite", 1705 | "quinn", 1706 | "rustls", 1707 | "rustls-pemfile", 1708 | "rustls-pki-types", 1709 | "serde", 1710 | "serde_json", 1711 | "serde_urlencoded", 1712 | "sync_wrapper", 1713 | "tokio", 1714 | "tokio-rustls", 1715 | "tower", 1716 | "tower-service", 1717 | "url", 1718 | "wasm-bindgen", 1719 | "wasm-bindgen-futures", 1720 | "web-sys", 1721 | "webpki-roots", 1722 | "windows-registry", 1723 | ] 1724 | 1725 | [[package]] 1726 | name = "ring" 1727 | version = "0.17.13" 1728 | source = "registry+https://github.com/rust-lang/crates.io-index" 1729 | checksum = "70ac5d832aa16abd7d1def883a8545280c20a60f523a370aa3a9617c2b8550ee" 1730 | dependencies = [ 1731 | "cc", 1732 | "cfg-if", 1733 | "getrandom", 1734 | "libc", 1735 | "untrusted", 1736 | "windows-sys 0.52.0", 1737 | ] 1738 | 1739 | [[package]] 1740 | name = "rustc-demangle" 1741 | version = "0.1.24" 1742 | source = "registry+https://github.com/rust-lang/crates.io-index" 1743 | checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" 1744 | 1745 | [[package]] 1746 | name = "rustc-hash" 1747 | version = "2.1.1" 1748 | source = "registry+https://github.com/rust-lang/crates.io-index" 1749 | checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" 1750 | 1751 | [[package]] 1752 | name = "rustix" 1753 | version = "0.38.44" 1754 | source = "registry+https://github.com/rust-lang/crates.io-index" 1755 | checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" 1756 | dependencies = [ 1757 | "bitflags", 1758 | "errno", 1759 | "libc", 1760 | "linux-raw-sys", 1761 | "windows-sys 0.59.0", 1762 | ] 1763 | 1764 | [[package]] 1765 | name = "rustls" 1766 | version = "0.23.23" 1767 | source = "registry+https://github.com/rust-lang/crates.io-index" 1768 | checksum = "47796c98c480fce5406ef69d1c76378375492c3b0a0de587be0c1d9feb12f395" 1769 | dependencies = [ 1770 | "once_cell", 1771 | "ring", 1772 | "rustls-pki-types", 1773 | "rustls-webpki", 1774 | "subtle", 1775 | "zeroize", 1776 | ] 1777 | 1778 | [[package]] 1779 | name = "rustls-native-certs" 1780 | version = "0.8.1" 1781 | source = "registry+https://github.com/rust-lang/crates.io-index" 1782 | checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" 1783 | dependencies = [ 1784 | "openssl-probe", 1785 | "rustls-pki-types", 1786 | "schannel", 1787 | "security-framework", 1788 | ] 1789 | 1790 | [[package]] 1791 | name = "rustls-pemfile" 1792 | version = "2.2.0" 1793 | source = "registry+https://github.com/rust-lang/crates.io-index" 1794 | checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" 1795 | dependencies = [ 1796 | "rustls-pki-types", 1797 | ] 1798 | 1799 | [[package]] 1800 | name = "rustls-pki-types" 1801 | version = "1.11.0" 1802 | source = "registry+https://github.com/rust-lang/crates.io-index" 1803 | checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" 1804 | dependencies = [ 1805 | "web-time", 1806 | ] 1807 | 1808 | [[package]] 1809 | name = "rustls-platform-verifier" 1810 | version = "0.5.0" 1811 | source = "registry+https://github.com/rust-lang/crates.io-index" 1812 | checksum = "e012c45844a1790332c9386ed4ca3a06def221092eda277e6f079728f8ea99da" 1813 | dependencies = [ 1814 | "core-foundation", 1815 | "core-foundation-sys", 1816 | "jni", 1817 | "log", 1818 | "once_cell", 1819 | "rustls", 1820 | "rustls-native-certs", 1821 | "rustls-platform-verifier-android", 1822 | "rustls-webpki", 1823 | "security-framework", 1824 | "security-framework-sys", 1825 | "webpki-root-certs", 1826 | "windows-sys 0.52.0", 1827 | ] 1828 | 1829 | [[package]] 1830 | name = "rustls-platform-verifier-android" 1831 | version = "0.1.1" 1832 | source = "registry+https://github.com/rust-lang/crates.io-index" 1833 | checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" 1834 | 1835 | [[package]] 1836 | name = "rustls-webpki" 1837 | version = "0.102.8" 1838 | source = "registry+https://github.com/rust-lang/crates.io-index" 1839 | checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" 1840 | dependencies = [ 1841 | "ring", 1842 | "rustls-pki-types", 1843 | "untrusted", 1844 | ] 1845 | 1846 | [[package]] 1847 | name = "rustversion" 1848 | version = "1.0.20" 1849 | source = "registry+https://github.com/rust-lang/crates.io-index" 1850 | checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" 1851 | 1852 | [[package]] 1853 | name = "ryu" 1854 | version = "1.0.20" 1855 | source = "registry+https://github.com/rust-lang/crates.io-index" 1856 | checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" 1857 | 1858 | [[package]] 1859 | name = "same-file" 1860 | version = "1.0.6" 1861 | source = "registry+https://github.com/rust-lang/crates.io-index" 1862 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 1863 | dependencies = [ 1864 | "winapi-util", 1865 | ] 1866 | 1867 | [[package]] 1868 | name = "schannel" 1869 | version = "0.1.27" 1870 | source = "registry+https://github.com/rust-lang/crates.io-index" 1871 | checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" 1872 | dependencies = [ 1873 | "windows-sys 0.59.0", 1874 | ] 1875 | 1876 | [[package]] 1877 | name = "scopeguard" 1878 | version = "1.2.0" 1879 | source = "registry+https://github.com/rust-lang/crates.io-index" 1880 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 1881 | 1882 | [[package]] 1883 | name = "security-framework" 1884 | version = "3.2.0" 1885 | source = "registry+https://github.com/rust-lang/crates.io-index" 1886 | checksum = "271720403f46ca04f7ba6f55d438f8bd878d6b8ca0a1046e8228c4145bcbb316" 1887 | dependencies = [ 1888 | "bitflags", 1889 | "core-foundation", 1890 | "core-foundation-sys", 1891 | "libc", 1892 | "security-framework-sys", 1893 | ] 1894 | 1895 | [[package]] 1896 | name = "security-framework-sys" 1897 | version = "2.14.0" 1898 | source = "registry+https://github.com/rust-lang/crates.io-index" 1899 | checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" 1900 | dependencies = [ 1901 | "core-foundation-sys", 1902 | "libc", 1903 | ] 1904 | 1905 | [[package]] 1906 | name = "serde" 1907 | version = "1.0.219" 1908 | source = "registry+https://github.com/rust-lang/crates.io-index" 1909 | checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" 1910 | dependencies = [ 1911 | "serde_derive", 1912 | ] 1913 | 1914 | [[package]] 1915 | name = "serde_derive" 1916 | version = "1.0.219" 1917 | source = "registry+https://github.com/rust-lang/crates.io-index" 1918 | checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" 1919 | dependencies = [ 1920 | "proc-macro2", 1921 | "quote", 1922 | "syn 2.0.100", 1923 | ] 1924 | 1925 | [[package]] 1926 | name = "serde_json" 1927 | version = "1.0.140" 1928 | source = "registry+https://github.com/rust-lang/crates.io-index" 1929 | checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" 1930 | dependencies = [ 1931 | "itoa", 1932 | "memchr", 1933 | "ryu", 1934 | "serde", 1935 | ] 1936 | 1937 | [[package]] 1938 | name = "serde_regex" 1939 | version = "1.1.0" 1940 | source = "registry+https://github.com/rust-lang/crates.io-index" 1941 | checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" 1942 | dependencies = [ 1943 | "regex", 1944 | "serde", 1945 | ] 1946 | 1947 | [[package]] 1948 | name = "serde_urlencoded" 1949 | version = "0.7.1" 1950 | source = "registry+https://github.com/rust-lang/crates.io-index" 1951 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" 1952 | dependencies = [ 1953 | "form_urlencoded", 1954 | "itoa", 1955 | "ryu", 1956 | "serde", 1957 | ] 1958 | 1959 | [[package]] 1960 | name = "sharded-slab" 1961 | version = "0.1.7" 1962 | source = "registry+https://github.com/rust-lang/crates.io-index" 1963 | checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" 1964 | dependencies = [ 1965 | "lazy_static", 1966 | ] 1967 | 1968 | [[package]] 1969 | name = "shlex" 1970 | version = "1.3.0" 1971 | source = "registry+https://github.com/rust-lang/crates.io-index" 1972 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 1973 | 1974 | [[package]] 1975 | name = "signal-hook-registry" 1976 | version = "1.4.2" 1977 | source = "registry+https://github.com/rust-lang/crates.io-index" 1978 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 1979 | dependencies = [ 1980 | "libc", 1981 | ] 1982 | 1983 | [[package]] 1984 | name = "similar" 1985 | version = "2.7.0" 1986 | source = "registry+https://github.com/rust-lang/crates.io-index" 1987 | checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" 1988 | 1989 | [[package]] 1990 | name = "siphasher" 1991 | version = "1.0.1" 1992 | source = "registry+https://github.com/rust-lang/crates.io-index" 1993 | checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" 1994 | 1995 | [[package]] 1996 | name = "slab" 1997 | version = "0.4.9" 1998 | source = "registry+https://github.com/rust-lang/crates.io-index" 1999 | checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" 2000 | dependencies = [ 2001 | "autocfg", 2002 | ] 2003 | 2004 | [[package]] 2005 | name = "smallvec" 2006 | version = "1.14.0" 2007 | source = "registry+https://github.com/rust-lang/crates.io-index" 2008 | checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" 2009 | 2010 | [[package]] 2011 | name = "socket2" 2012 | version = "0.5.8" 2013 | source = "registry+https://github.com/rust-lang/crates.io-index" 2014 | checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" 2015 | dependencies = [ 2016 | "libc", 2017 | "windows-sys 0.52.0", 2018 | ] 2019 | 2020 | [[package]] 2021 | name = "stable_deref_trait" 2022 | version = "1.2.0" 2023 | source = "registry+https://github.com/rust-lang/crates.io-index" 2024 | checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" 2025 | 2026 | [[package]] 2027 | name = "string_cache" 2028 | version = "0.8.8" 2029 | source = "registry+https://github.com/rust-lang/crates.io-index" 2030 | checksum = "938d512196766101d333398efde81bc1f37b00cb42c2f8350e5df639f040bbbe" 2031 | dependencies = [ 2032 | "new_debug_unreachable", 2033 | "parking_lot", 2034 | "phf_shared", 2035 | "precomputed-hash", 2036 | ] 2037 | 2038 | [[package]] 2039 | name = "strsim" 2040 | version = "0.11.1" 2041 | source = "registry+https://github.com/rust-lang/crates.io-index" 2042 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 2043 | 2044 | [[package]] 2045 | name = "subtle" 2046 | version = "2.6.1" 2047 | source = "registry+https://github.com/rust-lang/crates.io-index" 2048 | checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" 2049 | 2050 | [[package]] 2051 | name = "syn" 2052 | version = "1.0.109" 2053 | source = "registry+https://github.com/rust-lang/crates.io-index" 2054 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 2055 | dependencies = [ 2056 | "proc-macro2", 2057 | "quote", 2058 | "unicode-ident", 2059 | ] 2060 | 2061 | [[package]] 2062 | name = "syn" 2063 | version = "2.0.100" 2064 | source = "registry+https://github.com/rust-lang/crates.io-index" 2065 | checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" 2066 | dependencies = [ 2067 | "proc-macro2", 2068 | "quote", 2069 | "unicode-ident", 2070 | ] 2071 | 2072 | [[package]] 2073 | name = "sync_wrapper" 2074 | version = "1.0.2" 2075 | source = "registry+https://github.com/rust-lang/crates.io-index" 2076 | checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" 2077 | dependencies = [ 2078 | "futures-core", 2079 | ] 2080 | 2081 | [[package]] 2082 | name = "synstructure" 2083 | version = "0.13.1" 2084 | source = "registry+https://github.com/rust-lang/crates.io-index" 2085 | checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" 2086 | dependencies = [ 2087 | "proc-macro2", 2088 | "quote", 2089 | "syn 2.0.100", 2090 | ] 2091 | 2092 | [[package]] 2093 | name = "term" 2094 | version = "0.7.0" 2095 | source = "registry+https://github.com/rust-lang/crates.io-index" 2096 | checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" 2097 | dependencies = [ 2098 | "dirs-next", 2099 | "rustversion", 2100 | "winapi", 2101 | ] 2102 | 2103 | [[package]] 2104 | name = "thiserror" 2105 | version = "1.0.69" 2106 | source = "registry+https://github.com/rust-lang/crates.io-index" 2107 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 2108 | dependencies = [ 2109 | "thiserror-impl 1.0.69", 2110 | ] 2111 | 2112 | [[package]] 2113 | name = "thiserror" 2114 | version = "2.0.12" 2115 | source = "registry+https://github.com/rust-lang/crates.io-index" 2116 | checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" 2117 | dependencies = [ 2118 | "thiserror-impl 2.0.12", 2119 | ] 2120 | 2121 | [[package]] 2122 | name = "thiserror-impl" 2123 | version = "1.0.69" 2124 | source = "registry+https://github.com/rust-lang/crates.io-index" 2125 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 2126 | dependencies = [ 2127 | "proc-macro2", 2128 | "quote", 2129 | "syn 2.0.100", 2130 | ] 2131 | 2132 | [[package]] 2133 | name = "thiserror-impl" 2134 | version = "2.0.12" 2135 | source = "registry+https://github.com/rust-lang/crates.io-index" 2136 | checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" 2137 | dependencies = [ 2138 | "proc-macro2", 2139 | "quote", 2140 | "syn 2.0.100", 2141 | ] 2142 | 2143 | [[package]] 2144 | name = "thread_local" 2145 | version = "1.1.8" 2146 | source = "registry+https://github.com/rust-lang/crates.io-index" 2147 | checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" 2148 | dependencies = [ 2149 | "cfg-if", 2150 | "once_cell", 2151 | ] 2152 | 2153 | [[package]] 2154 | name = "time" 2155 | version = "0.3.39" 2156 | source = "registry+https://github.com/rust-lang/crates.io-index" 2157 | checksum = "dad298b01a40a23aac4580b67e3dbedb7cc8402f3592d7f49469de2ea4aecdd8" 2158 | dependencies = [ 2159 | "deranged", 2160 | "itoa", 2161 | "num-conv", 2162 | "powerfmt", 2163 | "serde", 2164 | "time-core", 2165 | "time-macros", 2166 | ] 2167 | 2168 | [[package]] 2169 | name = "time-core" 2170 | version = "0.1.3" 2171 | source = "registry+https://github.com/rust-lang/crates.io-index" 2172 | checksum = "765c97a5b985b7c11d7bc27fa927dc4fe6af3a6dfb021d28deb60d3bf51e76ef" 2173 | 2174 | [[package]] 2175 | name = "time-macros" 2176 | version = "0.2.20" 2177 | source = "registry+https://github.com/rust-lang/crates.io-index" 2178 | checksum = "e8093bc3e81c3bc5f7879de09619d06c9a5a5e45ca44dfeeb7225bae38005c5c" 2179 | dependencies = [ 2180 | "num-conv", 2181 | "time-core", 2182 | ] 2183 | 2184 | [[package]] 2185 | name = "tiny-keccak" 2186 | version = "2.0.2" 2187 | source = "registry+https://github.com/rust-lang/crates.io-index" 2188 | checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" 2189 | dependencies = [ 2190 | "crunchy", 2191 | ] 2192 | 2193 | [[package]] 2194 | name = "tinystr" 2195 | version = "0.7.6" 2196 | source = "registry+https://github.com/rust-lang/crates.io-index" 2197 | checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" 2198 | dependencies = [ 2199 | "displaydoc", 2200 | "zerovec", 2201 | ] 2202 | 2203 | [[package]] 2204 | name = "tinyvec" 2205 | version = "1.9.0" 2206 | source = "registry+https://github.com/rust-lang/crates.io-index" 2207 | checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" 2208 | dependencies = [ 2209 | "tinyvec_macros", 2210 | ] 2211 | 2212 | [[package]] 2213 | name = "tinyvec_macros" 2214 | version = "0.1.1" 2215 | source = "registry+https://github.com/rust-lang/crates.io-index" 2216 | checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" 2217 | 2218 | [[package]] 2219 | name = "tokio" 2220 | version = "1.44.0" 2221 | source = "registry+https://github.com/rust-lang/crates.io-index" 2222 | checksum = "9975ea0f48b5aa3972bf2d888c238182458437cc2a19374b81b25cdf1023fb3a" 2223 | dependencies = [ 2224 | "backtrace", 2225 | "bytes", 2226 | "libc", 2227 | "mio", 2228 | "pin-project-lite", 2229 | "signal-hook-registry", 2230 | "socket2", 2231 | "tokio-macros", 2232 | "windows-sys 0.52.0", 2233 | ] 2234 | 2235 | [[package]] 2236 | name = "tokio-macros" 2237 | version = "2.5.0" 2238 | source = "registry+https://github.com/rust-lang/crates.io-index" 2239 | checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" 2240 | dependencies = [ 2241 | "proc-macro2", 2242 | "quote", 2243 | "syn 2.0.100", 2244 | ] 2245 | 2246 | [[package]] 2247 | name = "tokio-rustls" 2248 | version = "0.26.2" 2249 | source = "registry+https://github.com/rust-lang/crates.io-index" 2250 | checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" 2251 | dependencies = [ 2252 | "rustls", 2253 | "tokio", 2254 | ] 2255 | 2256 | [[package]] 2257 | name = "tokio-util" 2258 | version = "0.7.13" 2259 | source = "registry+https://github.com/rust-lang/crates.io-index" 2260 | checksum = "d7fcaa8d55a2bdd6b83ace262b016eca0d79ee02818c5c1bcdf0305114081078" 2261 | dependencies = [ 2262 | "bytes", 2263 | "futures-core", 2264 | "futures-sink", 2265 | "pin-project-lite", 2266 | "tokio", 2267 | ] 2268 | 2269 | [[package]] 2270 | name = "tower" 2271 | version = "0.5.2" 2272 | source = "registry+https://github.com/rust-lang/crates.io-index" 2273 | checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" 2274 | dependencies = [ 2275 | "futures-core", 2276 | "futures-util", 2277 | "pin-project-lite", 2278 | "sync_wrapper", 2279 | "tokio", 2280 | "tower-layer", 2281 | "tower-service", 2282 | ] 2283 | 2284 | [[package]] 2285 | name = "tower-layer" 2286 | version = "0.3.3" 2287 | source = "registry+https://github.com/rust-lang/crates.io-index" 2288 | checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" 2289 | 2290 | [[package]] 2291 | name = "tower-service" 2292 | version = "0.3.3" 2293 | source = "registry+https://github.com/rust-lang/crates.io-index" 2294 | checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" 2295 | 2296 | [[package]] 2297 | name = "tracing" 2298 | version = "0.1.41" 2299 | source = "registry+https://github.com/rust-lang/crates.io-index" 2300 | checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" 2301 | dependencies = [ 2302 | "pin-project-lite", 2303 | "tracing-attributes", 2304 | "tracing-core", 2305 | ] 2306 | 2307 | [[package]] 2308 | name = "tracing-appender" 2309 | version = "0.2.3" 2310 | source = "registry+https://github.com/rust-lang/crates.io-index" 2311 | checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" 2312 | dependencies = [ 2313 | "crossbeam-channel", 2314 | "thiserror 1.0.69", 2315 | "time", 2316 | "tracing-subscriber", 2317 | ] 2318 | 2319 | [[package]] 2320 | name = "tracing-attributes" 2321 | version = "0.1.28" 2322 | source = "registry+https://github.com/rust-lang/crates.io-index" 2323 | checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" 2324 | dependencies = [ 2325 | "proc-macro2", 2326 | "quote", 2327 | "syn 2.0.100", 2328 | ] 2329 | 2330 | [[package]] 2331 | name = "tracing-core" 2332 | version = "0.1.33" 2333 | source = "registry+https://github.com/rust-lang/crates.io-index" 2334 | checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" 2335 | dependencies = [ 2336 | "once_cell", 2337 | "valuable", 2338 | ] 2339 | 2340 | [[package]] 2341 | name = "tracing-log" 2342 | version = "0.2.0" 2343 | source = "registry+https://github.com/rust-lang/crates.io-index" 2344 | checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" 2345 | dependencies = [ 2346 | "log", 2347 | "once_cell", 2348 | "tracing-core", 2349 | ] 2350 | 2351 | [[package]] 2352 | name = "tracing-subscriber" 2353 | version = "0.3.19" 2354 | source = "registry+https://github.com/rust-lang/crates.io-index" 2355 | checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" 2356 | dependencies = [ 2357 | "matchers", 2358 | "nu-ansi-term", 2359 | "once_cell", 2360 | "regex", 2361 | "sharded-slab", 2362 | "smallvec", 2363 | "thread_local", 2364 | "tracing", 2365 | "tracing-core", 2366 | "tracing-log", 2367 | ] 2368 | 2369 | [[package]] 2370 | name = "try-lock" 2371 | version = "0.2.5" 2372 | source = "registry+https://github.com/rust-lang/crates.io-index" 2373 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2374 | 2375 | [[package]] 2376 | name = "unicode-ident" 2377 | version = "1.0.18" 2378 | source = "registry+https://github.com/rust-lang/crates.io-index" 2379 | checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" 2380 | 2381 | [[package]] 2382 | name = "unicode-xid" 2383 | version = "0.2.6" 2384 | source = "registry+https://github.com/rust-lang/crates.io-index" 2385 | checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 2386 | 2387 | [[package]] 2388 | name = "untrusted" 2389 | version = "0.9.0" 2390 | source = "registry+https://github.com/rust-lang/crates.io-index" 2391 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 2392 | 2393 | [[package]] 2394 | name = "url" 2395 | version = "2.5.4" 2396 | source = "registry+https://github.com/rust-lang/crates.io-index" 2397 | checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" 2398 | dependencies = [ 2399 | "form_urlencoded", 2400 | "idna", 2401 | "percent-encoding", 2402 | ] 2403 | 2404 | [[package]] 2405 | name = "utf16_iter" 2406 | version = "1.0.5" 2407 | source = "registry+https://github.com/rust-lang/crates.io-index" 2408 | checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" 2409 | 2410 | [[package]] 2411 | name = "utf8_iter" 2412 | version = "1.0.4" 2413 | source = "registry+https://github.com/rust-lang/crates.io-index" 2414 | checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 2415 | 2416 | [[package]] 2417 | name = "utf8parse" 2418 | version = "0.2.2" 2419 | source = "registry+https://github.com/rust-lang/crates.io-index" 2420 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 2421 | 2422 | [[package]] 2423 | name = "valuable" 2424 | version = "0.1.1" 2425 | source = "registry+https://github.com/rust-lang/crates.io-index" 2426 | checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" 2427 | 2428 | [[package]] 2429 | name = "value-bag" 2430 | version = "1.10.0" 2431 | source = "registry+https://github.com/rust-lang/crates.io-index" 2432 | checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" 2433 | 2434 | [[package]] 2435 | name = "walkdir" 2436 | version = "2.5.0" 2437 | source = "registry+https://github.com/rust-lang/crates.io-index" 2438 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 2439 | dependencies = [ 2440 | "same-file", 2441 | "winapi-util", 2442 | ] 2443 | 2444 | [[package]] 2445 | name = "want" 2446 | version = "0.3.1" 2447 | source = "registry+https://github.com/rust-lang/crates.io-index" 2448 | checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" 2449 | dependencies = [ 2450 | "try-lock", 2451 | ] 2452 | 2453 | [[package]] 2454 | name = "wasi" 2455 | version = "0.11.0+wasi-snapshot-preview1" 2456 | source = "registry+https://github.com/rust-lang/crates.io-index" 2457 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 2458 | 2459 | [[package]] 2460 | name = "wasm-bindgen" 2461 | version = "0.2.100" 2462 | source = "registry+https://github.com/rust-lang/crates.io-index" 2463 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 2464 | dependencies = [ 2465 | "cfg-if", 2466 | "once_cell", 2467 | "rustversion", 2468 | "wasm-bindgen-macro", 2469 | ] 2470 | 2471 | [[package]] 2472 | name = "wasm-bindgen-backend" 2473 | version = "0.2.100" 2474 | source = "registry+https://github.com/rust-lang/crates.io-index" 2475 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 2476 | dependencies = [ 2477 | "bumpalo", 2478 | "log", 2479 | "proc-macro2", 2480 | "quote", 2481 | "syn 2.0.100", 2482 | "wasm-bindgen-shared", 2483 | ] 2484 | 2485 | [[package]] 2486 | name = "wasm-bindgen-futures" 2487 | version = "0.4.50" 2488 | source = "registry+https://github.com/rust-lang/crates.io-index" 2489 | checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" 2490 | dependencies = [ 2491 | "cfg-if", 2492 | "js-sys", 2493 | "once_cell", 2494 | "wasm-bindgen", 2495 | "web-sys", 2496 | ] 2497 | 2498 | [[package]] 2499 | name = "wasm-bindgen-macro" 2500 | version = "0.2.100" 2501 | source = "registry+https://github.com/rust-lang/crates.io-index" 2502 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 2503 | dependencies = [ 2504 | "quote", 2505 | "wasm-bindgen-macro-support", 2506 | ] 2507 | 2508 | [[package]] 2509 | name = "wasm-bindgen-macro-support" 2510 | version = "0.2.100" 2511 | source = "registry+https://github.com/rust-lang/crates.io-index" 2512 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 2513 | dependencies = [ 2514 | "proc-macro2", 2515 | "quote", 2516 | "syn 2.0.100", 2517 | "wasm-bindgen-backend", 2518 | "wasm-bindgen-shared", 2519 | ] 2520 | 2521 | [[package]] 2522 | name = "wasm-bindgen-shared" 2523 | version = "0.2.100" 2524 | source = "registry+https://github.com/rust-lang/crates.io-index" 2525 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 2526 | dependencies = [ 2527 | "unicode-ident", 2528 | ] 2529 | 2530 | [[package]] 2531 | name = "web-sys" 2532 | version = "0.3.77" 2533 | source = "registry+https://github.com/rust-lang/crates.io-index" 2534 | checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" 2535 | dependencies = [ 2536 | "js-sys", 2537 | "wasm-bindgen", 2538 | ] 2539 | 2540 | [[package]] 2541 | name = "web-time" 2542 | version = "1.1.0" 2543 | source = "registry+https://github.com/rust-lang/crates.io-index" 2544 | checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" 2545 | dependencies = [ 2546 | "js-sys", 2547 | "wasm-bindgen", 2548 | ] 2549 | 2550 | [[package]] 2551 | name = "webpki-root-certs" 2552 | version = "0.26.8" 2553 | source = "registry+https://github.com/rust-lang/crates.io-index" 2554 | checksum = "09aed61f5e8d2c18344b3faa33a4c837855fe56642757754775548fee21386c4" 2555 | dependencies = [ 2556 | "rustls-pki-types", 2557 | ] 2558 | 2559 | [[package]] 2560 | name = "webpki-roots" 2561 | version = "0.26.8" 2562 | source = "registry+https://github.com/rust-lang/crates.io-index" 2563 | checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" 2564 | dependencies = [ 2565 | "rustls-pki-types", 2566 | ] 2567 | 2568 | [[package]] 2569 | name = "winapi" 2570 | version = "0.3.9" 2571 | source = "registry+https://github.com/rust-lang/crates.io-index" 2572 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 2573 | dependencies = [ 2574 | "winapi-i686-pc-windows-gnu", 2575 | "winapi-x86_64-pc-windows-gnu", 2576 | ] 2577 | 2578 | [[package]] 2579 | name = "winapi-i686-pc-windows-gnu" 2580 | version = "0.4.0" 2581 | source = "registry+https://github.com/rust-lang/crates.io-index" 2582 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 2583 | 2584 | [[package]] 2585 | name = "winapi-util" 2586 | version = "0.1.9" 2587 | source = "registry+https://github.com/rust-lang/crates.io-index" 2588 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 2589 | dependencies = [ 2590 | "windows-sys 0.59.0", 2591 | ] 2592 | 2593 | [[package]] 2594 | name = "winapi-x86_64-pc-windows-gnu" 2595 | version = "0.4.0" 2596 | source = "registry+https://github.com/rust-lang/crates.io-index" 2597 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 2598 | 2599 | [[package]] 2600 | name = "windows-registry" 2601 | version = "0.2.0" 2602 | source = "registry+https://github.com/rust-lang/crates.io-index" 2603 | checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" 2604 | dependencies = [ 2605 | "windows-result", 2606 | "windows-strings", 2607 | "windows-targets 0.52.6", 2608 | ] 2609 | 2610 | [[package]] 2611 | name = "windows-result" 2612 | version = "0.2.0" 2613 | source = "registry+https://github.com/rust-lang/crates.io-index" 2614 | checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" 2615 | dependencies = [ 2616 | "windows-targets 0.52.6", 2617 | ] 2618 | 2619 | [[package]] 2620 | name = "windows-strings" 2621 | version = "0.1.0" 2622 | source = "registry+https://github.com/rust-lang/crates.io-index" 2623 | checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" 2624 | dependencies = [ 2625 | "windows-result", 2626 | "windows-targets 0.52.6", 2627 | ] 2628 | 2629 | [[package]] 2630 | name = "windows-sys" 2631 | version = "0.45.0" 2632 | source = "registry+https://github.com/rust-lang/crates.io-index" 2633 | checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" 2634 | dependencies = [ 2635 | "windows-targets 0.42.2", 2636 | ] 2637 | 2638 | [[package]] 2639 | name = "windows-sys" 2640 | version = "0.52.0" 2641 | source = "registry+https://github.com/rust-lang/crates.io-index" 2642 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 2643 | dependencies = [ 2644 | "windows-targets 0.52.6", 2645 | ] 2646 | 2647 | [[package]] 2648 | name = "windows-sys" 2649 | version = "0.59.0" 2650 | source = "registry+https://github.com/rust-lang/crates.io-index" 2651 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 2652 | dependencies = [ 2653 | "windows-targets 0.52.6", 2654 | ] 2655 | 2656 | [[package]] 2657 | name = "windows-targets" 2658 | version = "0.42.2" 2659 | source = "registry+https://github.com/rust-lang/crates.io-index" 2660 | checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 2661 | dependencies = [ 2662 | "windows_aarch64_gnullvm 0.42.2", 2663 | "windows_aarch64_msvc 0.42.2", 2664 | "windows_i686_gnu 0.42.2", 2665 | "windows_i686_msvc 0.42.2", 2666 | "windows_x86_64_gnu 0.42.2", 2667 | "windows_x86_64_gnullvm 0.42.2", 2668 | "windows_x86_64_msvc 0.42.2", 2669 | ] 2670 | 2671 | [[package]] 2672 | name = "windows-targets" 2673 | version = "0.52.6" 2674 | source = "registry+https://github.com/rust-lang/crates.io-index" 2675 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 2676 | dependencies = [ 2677 | "windows_aarch64_gnullvm 0.52.6", 2678 | "windows_aarch64_msvc 0.52.6", 2679 | "windows_i686_gnu 0.52.6", 2680 | "windows_i686_gnullvm", 2681 | "windows_i686_msvc 0.52.6", 2682 | "windows_x86_64_gnu 0.52.6", 2683 | "windows_x86_64_gnullvm 0.52.6", 2684 | "windows_x86_64_msvc 0.52.6", 2685 | ] 2686 | 2687 | [[package]] 2688 | name = "windows_aarch64_gnullvm" 2689 | version = "0.42.2" 2690 | source = "registry+https://github.com/rust-lang/crates.io-index" 2691 | checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" 2692 | 2693 | [[package]] 2694 | name = "windows_aarch64_gnullvm" 2695 | version = "0.52.6" 2696 | source = "registry+https://github.com/rust-lang/crates.io-index" 2697 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 2698 | 2699 | [[package]] 2700 | name = "windows_aarch64_msvc" 2701 | version = "0.42.2" 2702 | source = "registry+https://github.com/rust-lang/crates.io-index" 2703 | checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" 2704 | 2705 | [[package]] 2706 | name = "windows_aarch64_msvc" 2707 | version = "0.52.6" 2708 | source = "registry+https://github.com/rust-lang/crates.io-index" 2709 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 2710 | 2711 | [[package]] 2712 | name = "windows_i686_gnu" 2713 | version = "0.42.2" 2714 | source = "registry+https://github.com/rust-lang/crates.io-index" 2715 | checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" 2716 | 2717 | [[package]] 2718 | name = "windows_i686_gnu" 2719 | version = "0.52.6" 2720 | source = "registry+https://github.com/rust-lang/crates.io-index" 2721 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 2722 | 2723 | [[package]] 2724 | name = "windows_i686_gnullvm" 2725 | version = "0.52.6" 2726 | source = "registry+https://github.com/rust-lang/crates.io-index" 2727 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 2728 | 2729 | [[package]] 2730 | name = "windows_i686_msvc" 2731 | version = "0.42.2" 2732 | source = "registry+https://github.com/rust-lang/crates.io-index" 2733 | checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" 2734 | 2735 | [[package]] 2736 | name = "windows_i686_msvc" 2737 | version = "0.52.6" 2738 | source = "registry+https://github.com/rust-lang/crates.io-index" 2739 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 2740 | 2741 | [[package]] 2742 | name = "windows_x86_64_gnu" 2743 | version = "0.42.2" 2744 | source = "registry+https://github.com/rust-lang/crates.io-index" 2745 | checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 2746 | 2747 | [[package]] 2748 | name = "windows_x86_64_gnu" 2749 | version = "0.52.6" 2750 | source = "registry+https://github.com/rust-lang/crates.io-index" 2751 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 2752 | 2753 | [[package]] 2754 | name = "windows_x86_64_gnullvm" 2755 | version = "0.42.2" 2756 | source = "registry+https://github.com/rust-lang/crates.io-index" 2757 | checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 2758 | 2759 | [[package]] 2760 | name = "windows_x86_64_gnullvm" 2761 | version = "0.52.6" 2762 | source = "registry+https://github.com/rust-lang/crates.io-index" 2763 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 2764 | 2765 | [[package]] 2766 | name = "windows_x86_64_msvc" 2767 | version = "0.42.2" 2768 | source = "registry+https://github.com/rust-lang/crates.io-index" 2769 | checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 2770 | 2771 | [[package]] 2772 | name = "windows_x86_64_msvc" 2773 | version = "0.52.6" 2774 | source = "registry+https://github.com/rust-lang/crates.io-index" 2775 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 2776 | 2777 | [[package]] 2778 | name = "write16" 2779 | version = "1.0.0" 2780 | source = "registry+https://github.com/rust-lang/crates.io-index" 2781 | checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" 2782 | 2783 | [[package]] 2784 | name = "writeable" 2785 | version = "0.5.5" 2786 | source = "registry+https://github.com/rust-lang/crates.io-index" 2787 | checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" 2788 | 2789 | [[package]] 2790 | name = "yoke" 2791 | version = "0.7.5" 2792 | source = "registry+https://github.com/rust-lang/crates.io-index" 2793 | checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" 2794 | dependencies = [ 2795 | "serde", 2796 | "stable_deref_trait", 2797 | "yoke-derive", 2798 | "zerofrom", 2799 | ] 2800 | 2801 | [[package]] 2802 | name = "yoke-derive" 2803 | version = "0.7.5" 2804 | source = "registry+https://github.com/rust-lang/crates.io-index" 2805 | checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" 2806 | dependencies = [ 2807 | "proc-macro2", 2808 | "quote", 2809 | "syn 2.0.100", 2810 | "synstructure", 2811 | ] 2812 | 2813 | [[package]] 2814 | name = "zerocopy" 2815 | version = "0.8.23" 2816 | source = "registry+https://github.com/rust-lang/crates.io-index" 2817 | checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" 2818 | dependencies = [ 2819 | "zerocopy-derive", 2820 | ] 2821 | 2822 | [[package]] 2823 | name = "zerocopy-derive" 2824 | version = "0.8.23" 2825 | source = "registry+https://github.com/rust-lang/crates.io-index" 2826 | checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" 2827 | dependencies = [ 2828 | "proc-macro2", 2829 | "quote", 2830 | "syn 2.0.100", 2831 | ] 2832 | 2833 | [[package]] 2834 | name = "zerofrom" 2835 | version = "0.1.6" 2836 | source = "registry+https://github.com/rust-lang/crates.io-index" 2837 | checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" 2838 | dependencies = [ 2839 | "zerofrom-derive", 2840 | ] 2841 | 2842 | [[package]] 2843 | name = "zerofrom-derive" 2844 | version = "0.1.6" 2845 | source = "registry+https://github.com/rust-lang/crates.io-index" 2846 | checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" 2847 | dependencies = [ 2848 | "proc-macro2", 2849 | "quote", 2850 | "syn 2.0.100", 2851 | "synstructure", 2852 | ] 2853 | 2854 | [[package]] 2855 | name = "zeroize" 2856 | version = "1.8.1" 2857 | source = "registry+https://github.com/rust-lang/crates.io-index" 2858 | checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" 2859 | 2860 | [[package]] 2861 | name = "zerovec" 2862 | version = "0.10.4" 2863 | source = "registry+https://github.com/rust-lang/crates.io-index" 2864 | checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" 2865 | dependencies = [ 2866 | "yoke", 2867 | "zerofrom", 2868 | "zerovec-derive", 2869 | ] 2870 | 2871 | [[package]] 2872 | name = "zerovec-derive" 2873 | version = "0.10.3" 2874 | source = "registry+https://github.com/rust-lang/crates.io-index" 2875 | checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" 2876 | dependencies = [ 2877 | "proc-macro2", 2878 | "quote", 2879 | "syn 2.0.100", 2880 | ] 2881 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "prefetcharr" 3 | version = "1.0.0" 4 | edition = "2024" 5 | authors = ["Paul Hüber"] 6 | license = "MIT OR Apache-2.0" 7 | rust-version = "1.85.0" 8 | 9 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | clap = { version = "4", features = ["derive", "env", "string"] } 14 | futures = { version = "0.3.31", default-features = false, features = ["std"] } 15 | reqwest = { version = "0", default-features = false, features = ["charset", "http2", "json", "rustls-tls"] } 16 | rustls = { version = "0.23.23", default-features = false } 17 | rustls-platform-verifier = "0.5.0" 18 | serde = { version = "1", features = ["derive"] } 19 | serde_json = "1" 20 | tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "time", "sync"] } 21 | tokio-util = "0.7.13" 22 | tracing = "0" 23 | tracing-appender = "0" 24 | tracing-subscriber = { version = "0", features = ["env-filter"] } 25 | 26 | [dev-dependencies] 27 | httpmock = "0.7.0" 28 | 29 | [profile.release] 30 | strip = "debuginfo" 31 | lto = "fat" 32 | codegen-units = 1 33 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:alpine as builder 2 | 3 | RUN apk add libc-dev 4 | 5 | WORKDIR /app 6 | ADD . /app 7 | RUN cargo build --release 8 | 9 | 10 | FROM alpine:latest 11 | 12 | ENV INTERVAL=900 13 | ENV REMAINING_EPISODES=2 14 | 15 | COPY --from=builder /app/target/release/prefetcharr / 16 | COPY --from=builder /app/ATTRIBUTION.md / 17 | 18 | CMD ["sh", "-c", "./prefetcharr \ 19 | --media-server-type \"${MEDIA_SERVER_TYPE}\" \ 20 | --media-server-url \"${MEDIA_SERVER_URL}\" \ 21 | --sonarr-url \"${SONARR_URL}\" \ 22 | --log-dir \"${LOG_DIR}\" \ 23 | --interval \"${INTERVAL}\" \ 24 | --remaining-episodes \"${REMAINING_EPISODES}\" \ 25 | --users \"${USERS:---users}\" \ 26 | --libraries \"${LIBRARIES:---libraries}\" \ 27 | --connection-retries 6 \ 28 | "] 29 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Paul Hüber 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Paul Hüber 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # prefetcharr 2 | 3 | Let [Sonarr](https://sonarr.tv) fetch the next season of a show you are watching 4 | on [Jellyfin](https://jellyfin.org)/[Emby](https://emby.media)/[Plex](https://www.plex.tv). 5 | 6 | ## Details 7 | 8 | _prefetcharr_ periodically polls your media server for active playback sessions. 9 | For TV shows, it checks whether the pilot is playing or if the end of a season 10 | is almost reached. 11 | If this is the case and the next/first season has not been downloaded yet, 12 | _prefetcharr_ asks _Sonarr_ to monitor it and initiate a season search. If there 13 | are no more seasons left, the series gets monitored for new seasons instead. 14 | 15 | ## Build and install 16 | 17 | To install, first ensure Rust is installed on your system by following the 18 | instructions at [Install Rust](https://www.rust-lang.org/tools/install), then 19 | run: 20 | ``` 21 | cargo install --git https://github.com/p-hueber/prefetcharr 22 | ``` 23 | 24 | Or with docker compose: 25 | ```yml 26 | services: 27 | prefetcharr: 28 | image: phueber/prefetcharr:latest 29 | container_name: prefetcharr 30 | environment: 31 | # `jellyfin`, `emby` or `plex` 32 | - MEDIA_SERVER_TYPE=jellyfin 33 | # Jellyfin/Emby/Plex baseurl 34 | - MEDIA_SERVER_URL=http://example.com/jellyfin 35 | # Jellyfin/Emby API key or plex server token 36 | - MEDIA_SERVER_API_KEY= 37 | # Sonarr baseurl 38 | - SONARR_URL=http://example.com/sonarr 39 | # Sonarr API key 40 | - SONARR_API_KEY= 41 | # Logging directory 42 | - LOG_DIR=/log 43 | # Log level 44 | - RUST_LOG=prefetcharr=debug 45 | # Polling interval in seconds 46 | - INTERVAL=900 47 | # The last episodes trigger a search 48 | - REMAINING_EPISODES=2 49 | # Optional: Only monitor sessions for specific user IDs or names 50 | # - USERS=john,12345,Axel F 51 | # Optional: Only monitor sessions for specific libraries 52 | # - LIBRARIES=TV Shows,Anime 53 | volumes: 54 | - /path/to/log/dir:/log 55 | 56 | ``` 57 |
58 | Unraid intructions 59 | 60 | 1. **Disable Docker** in Unraid. 61 | 2. Upload [my-prefetcharr.xml](https://raw.githubusercontent.com/p-hueber/prefetcharr/latest/unraid/my-prefetcharr.xml) 62 | to the following directory: `/boot/config/plugins/dockerMan/templates-user`. 63 | 3. Once the file is uploaded, **re-enable Docker**. 64 | 4. To add the Docker image, go to **Docker** > **Add Container**. 65 | 5. Select **Template** > **User Templates** > then choose **prefetcharr**. 66 |
67 | 68 | ## Configuration 69 | 70 | ### API keys 71 | 72 | _prefetcharr_ needs two different API keys to do its job. 73 | 74 | #### `SONARR_API_KEY` 75 | 76 | Go to `Settings` -> `General` -> `Security` and copy the API key. 77 | 78 | #### `MEDIA_SERVER_API_KEY` 79 | 80 | The key to use and how to obtain it differs on the type of media server you use: 81 | 82 | #### Jellyfin 83 | 84 | Log in as an administrator and go to `Administration` -> `Dashboard` -> 85 | `Advanced` -> `Api Keys`. Add a new key or use an existing one. 86 | 87 | #### Emby 88 | 89 | Log in as an administrator, click on the gear on the top right and go to 90 | `Advanced` -> `Api Keys`. Add a new key or use an existing one. 91 | 92 | #### Plex 93 | 94 | You need to [extract the server token](https://www.plexopedia.com/plex-media-server/general/plex-token/#plexservertoken) 95 | from a configuration file and use it as the API key. 96 | 97 | ### Upgrading pilots 98 | 99 | If you want to store pilot episodes only, _prefetcharr_ can fetch the first 100 | season for you on demand. 101 | This method works well for individual episodes but may encounter issues with 102 | season packs. 103 | For this to function in _Sonarr_, grabbing the season pack must be considered 104 | an upgrade of the pilot episode. 105 | This can be achieved through a [custom format](https://trash-guides.info/Sonarr/sonarr-collection-of-custom-formats/#season-pack). 106 | [Import](https://trash-guides.info/Sonarr/sonarr-import-custom-formats/) 107 | the custom format and [configure a quality profile](https://trash-guides.info/Sonarr/sonarr-setup-quality-profiles/) 108 | to prefer it. 109 | 110 | ## How to use 111 | 112 | ### Host installation 113 | 114 | If you installed _prefetcharr_ through `cargo`, you can get a description of the 115 | command-line interface by running `prefetcharr --help`. 116 | 117 | ### Docker installation 118 | 119 | Users utilizing Docker only need to start the container, e.g. using `docker 120 | compose up -d prefetcharr`. 121 | Once the container is running, you may want to check the logs for errors. You 122 | can do so by either calling `docker logs prefetcharr` or by checking the logging 123 | directory you configured. 124 | -------------------------------------------------------------------------------- /about.hbs: -------------------------------------------------------------------------------- 1 | # Third Party Licenses 2 | This page lists the licenses of the projects used in `prefetcharr`. 3 | 4 | ## Overview of licenses 5 | {{#each overview}} 6 | - [{{{name}}}](#{{{id}}}) ({{count}}) 7 | {{/each}} 8 | 9 | ## All license text 10 | {{#each licenses}} 11 | ### {{{id}}} 12 | {{{name}}} 13 | 14 | #### Used by 15 | {{#each used_by}} 16 | - [{{{crate.name}}}]({{#if crate.repository}} {{crate.repository}} {{else}} https://crates.io/crates/{{crate.name}} {{/if}}) {{{crate.version}}} 17 | {{/each}} 18 | 19 | #### License 20 | ``` 21 | {{{text}}} 22 | ``` 23 | {{/each}} 24 | -------------------------------------------------------------------------------- /about.toml: -------------------------------------------------------------------------------- 1 | accepted = [ 2 | "MIT", 3 | "Apache-2.0", 4 | "Apache-2.0 WITH LLVM-exception", 5 | "Unicode-3.0", 6 | "ISC", 7 | "MPL-2.0", 8 | "BSD-3-Clause", 9 | "CC0-1.0", 10 | ] 11 | -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | # This template contains all of the possible sections and their default values 2 | 3 | # Note that all fields that take a lint level have these possible values: 4 | # * deny - An error will be produced and the check will fail 5 | # * warn - A warning will be produced, but the check will not fail 6 | # * allow - No warning or error will be produced, though in some cases a note 7 | # will be 8 | 9 | # The values provided in this template are the default values that will be used 10 | # when any section or field is not specified in your own configuration 11 | 12 | [graph] 13 | # If 1 or more target triples (and optionally, target_features) are specified, 14 | # only the specified targets will be checked when running `cargo deny check`. 15 | # This means, if a particular package is only ever used as a target specific 16 | # dependency, such as, for example, the `nix` crate only being used via the 17 | # `target_family = "unix"` configuration, that only having windows targets in 18 | # this list would mean the nix crate, as well as any of its exclusive 19 | # dependencies not shared by any other crates, would be ignored, as the target 20 | # list here is effectively saying which targets you are building for. 21 | targets = [ 22 | # The triple can be any string, but only the target triples built in to 23 | # rustc (as of 1.40) can be checked against actual config expressions 24 | #{ triple = "x86_64-unknown-linux-musl" }, 25 | # You can also specify which target_features you promise are enabled for a 26 | # particular target. target_features are currently not validated against 27 | # the actual valid features supported by the target architecture. 28 | #{ triple = "wasm32-unknown-unknown", features = ["atomics"] }, 29 | ] 30 | # When creating the dependency graph used as the source of truth when checks are 31 | # executed, this field can be used to prune crates from the graph, removing them 32 | # from the view of cargo-deny. This is an extremely heavy hammer, as if a crate 33 | # is pruned from the graph, all of its dependencies will also be pruned unless 34 | # they are connected to another crate in the graph that hasn't been pruned, 35 | # so it should be used with care. The identifiers are [Package ID Specifications] 36 | # (https://doc.rust-lang.org/cargo/reference/pkgid-spec.html) 37 | #exclude = [] 38 | # If true, metadata will be collected with `--all-features`. Note that this can't 39 | # be toggled off if true, if you want to conditionally enable `--all-features` it 40 | # is recommended to pass `--all-features` on the cmd line instead 41 | all-features = false 42 | # If true, metadata will be collected with `--no-default-features`. The same 43 | # caveat with `all-features` applies 44 | no-default-features = false 45 | # If set, these feature will be enabled when collecting metadata. If `--features` 46 | # is specified on the cmd line they will take precedence over this option. 47 | #features = [] 48 | 49 | [output] 50 | # When outputting inclusion graphs in diagnostics that include features, this 51 | # option can be used to specify the depth at which feature edges will be added. 52 | # This option is included since the graphs can be quite large and the addition 53 | # of features from the crate(s) to all of the graph roots can be far too verbose. 54 | # This option can be overridden via `--feature-depth` on the cmd line 55 | feature-depth = 1 56 | 57 | # This section is considered when running `cargo deny check advisories` 58 | # More documentation for the advisories section can be found here: 59 | # https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html 60 | [advisories] 61 | # The path where the advisory database is cloned/fetched into 62 | db-path = "~/.cargo/advisory-db" 63 | # The url(s) of the advisory databases to use 64 | db-urls = ["https://github.com/rustsec/advisory-db"] 65 | # The lint level for unmaintained crates 66 | #unmaintained = "all" 67 | # The lint level for crates that have been yanked from their source registry 68 | yanked = "deny" 69 | # A list of advisory IDs to ignore. Note that ignored advisories will still 70 | # output a note when they are encountered. 71 | ignore = [ 72 | #"RUSTSEC-0000-0000", 73 | ] 74 | 75 | # If this is true, then cargo deny will use the git executable to fetch advisory database. 76 | # If this is false, then it uses a built-in git library. 77 | # Setting this to true can be helpful if you have special authentication requirements that cargo-deny does not support. 78 | # See Git Authentication for more information about setting up git authentication. 79 | #git-fetch-with-cli = true 80 | 81 | # This section is considered when running `cargo deny check licenses` 82 | # More documentation for the licenses section can be found here: 83 | # https://embarkstudios.github.io/cargo-deny/checks/licenses/cfg.html 84 | [licenses] 85 | # List of explicitly allowed licenses 86 | # See https://spdx.org/licenses/ for list of possible licenses 87 | # [possible values: any SPDX 3.11 short identifier (+ optional exception)]. 88 | allow = [ 89 | "MIT", 90 | "Apache-2.0", 91 | "Apache-2.0 WITH LLVM-exception", 92 | "Unicode-3.0", 93 | "ISC", 94 | "MPL-2.0", 95 | "BSD-3-Clause", 96 | ] 97 | # The confidence threshold for detecting a license from license text. 98 | # The higher the value, the more closely the license text must be to the 99 | # canonical license text of a valid SPDX license file. 100 | # [possible values: any between 0.0 and 1.0]. 101 | confidence-threshold = 0.8 102 | # Allow 1 or more licenses on a per-crate basis, so that particular licenses 103 | # aren't accepted for every possible crate as with the normal allow list 104 | exceptions = [ 105 | # Each entry is the crate and version constraint, and its specific allow 106 | # list 107 | #{ allow = ["Zlib"], name = "adler32", version = "*" }, 108 | ] 109 | 110 | # Some crates don't have (easily) machine readable licensing information, 111 | # adding a clarification entry for it allows you to manually specify the 112 | # licensing information 113 | [[licenses.clarify]] 114 | # The name of the crate the clarification applies to 115 | name = "ring" 116 | # The optional version constraint for the crate 117 | version = "*" 118 | # The SPDX expression for the license requirements of the crate 119 | expression = "MIT AND ISC AND OpenSSL" 120 | # One or more files in the crate's source used as the "source of truth" for 121 | # the license expression. If the contents match, the clarification will be used 122 | # when running the license check, otherwise the clarification will be ignored 123 | # and the crate will be checked normally, which may produce warnings or errors 124 | # depending on the rest of your configuration 125 | license-files = [ 126 | # Each entry is a crate relative path, and the (opaque) hash of its contents 127 | { path = "LICENSE", hash = 0xbd0eed23 } 128 | ] 129 | 130 | [licenses.private] 131 | # If true, ignores workspace crates that aren't published, or are only 132 | # published to private registries. 133 | # To see how to mark a crate as unpublished (to the official registry), 134 | # visit https://doc.rust-lang.org/cargo/reference/manifest.html#the-publish-field. 135 | ignore = false 136 | # One or more private registries that you might publish crates to, if a crate 137 | # is only published to private registries, and ignore is true, the crate will 138 | # not have its license(s) checked 139 | registries = [ 140 | #"https://sekretz.com/registry 141 | ] 142 | 143 | # This section is considered when running `cargo deny check bans`. 144 | # More documentation about the 'bans' section can be found here: 145 | # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html 146 | [bans] 147 | # Lint level for when multiple versions of the same crate are detected 148 | multiple-versions = "warn" 149 | # Lint level for when a crate version requirement is `*` 150 | wildcards = "allow" 151 | # The graph highlighting used when creating dotgraphs for crates 152 | # with multiple versions 153 | # * lowest-version - The path to the lowest versioned duplicate is highlighted 154 | # * simplest-path - The path to the version with the fewest edges is highlighted 155 | # * all - Both lowest-version and simplest-path are used 156 | highlight = "all" 157 | # The default lint level for `default` features for crates that are members of 158 | # the workspace that is being checked. This can be overridden by allowing/denying 159 | # `default` on a crate-by-crate basis if desired. 160 | workspace-default-features = "allow" 161 | # The default lint level for `default` features for external crates that are not 162 | # members of the workspace. This can be overridden by allowing/denying `default` 163 | # on a crate-by-crate basis if desired. 164 | external-default-features = "allow" 165 | # List of crates that are allowed. Use with care! 166 | allow = [ 167 | #{ name = "ansi_term", version = "=0.11.0" }, 168 | ] 169 | # List of crates to deny 170 | deny = [ 171 | # Each entry the name of a crate and a version range. If version is 172 | # not specified, all versions will be matched. 173 | #{ name = "ansi_term", version = "=0.11.0" }, 174 | # 175 | # Wrapper crates can optionally be specified to allow the crate when it 176 | # is a direct dependency of the otherwise banned crate 177 | #{ name = "ansi_term", version = "=0.11.0", wrappers = [] }, 178 | ] 179 | 180 | # List of features to allow/deny 181 | # Each entry the name of a crate and a version range. If version is 182 | # not specified, all versions will be matched. 183 | #[[bans.features]] 184 | #name = "reqwest" 185 | # Features to not allow 186 | #deny = ["json"] 187 | # Features to allow 188 | #allow = [ 189 | # "rustls", 190 | # "__rustls", 191 | # "__tls", 192 | # "hyper-rustls", 193 | # "rustls", 194 | # "rustls-pemfile", 195 | # "rustls-tls-webpki-roots", 196 | # "tokio-rustls", 197 | # "webpki-roots", 198 | #] 199 | # If true, the allowed features must exactly match the enabled feature set. If 200 | # this is set there is no point setting `deny` 201 | #exact = true 202 | 203 | # Certain crates/versions that will be skipped when doing duplicate detection. 204 | skip = [ 205 | #{ name = "ansi_term", version = "=0.11.0" }, 206 | ] 207 | # Similarly to `skip` allows you to skip certain crates during duplicate 208 | # detection. Unlike skip, it also includes the entire tree of transitive 209 | # dependencies starting at the specified crate, up to a certain depth, which is 210 | # by default infinite. 211 | skip-tree = [ 212 | #{ name = "ansi_term", version = "=0.11.0", depth = 20 }, 213 | ] 214 | 215 | # This section is considered when running `cargo deny check sources`. 216 | # More documentation about the 'sources' section can be found here: 217 | # https://embarkstudios.github.io/cargo-deny/checks/sources/cfg.html 218 | [sources] 219 | # Lint level for what to happen when a crate from a crate registry that is not 220 | # in the allow list is encountered 221 | unknown-registry = "warn" 222 | # Lint level for what to happen when a crate from a git repository that is not 223 | # in the allow list is encountered 224 | unknown-git = "warn" 225 | # List of URLs for allowed crate registries. Defaults to the crates.io index 226 | # if not specified. If it is specified but empty, no registries are allowed. 227 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 228 | # List of URLs for allowed Git repositories 229 | allow-git = [] 230 | 231 | [sources.allow-org] 232 | # 1 or more github.com organizations to allow git sources for 233 | #github = [""] 234 | # 1 or more gitlab.com organizations to allow git sources for 235 | #gitlab = [""] 236 | # 1 or more bitbucket.org organizations to allow git sources for 237 | #bitbucket = [""] 238 | -------------------------------------------------------------------------------- /src/filter.rs: -------------------------------------------------------------------------------- 1 | use std::future::{Ready, ready}; 2 | 3 | use tracing::debug; 4 | 5 | use crate::media_server::NowPlaying; 6 | 7 | pub fn users(users: &[String]) -> impl FnMut(&NowPlaying) -> Ready { 8 | move |np: &NowPlaying| { 9 | let accept = 10 | users.is_empty() || users.contains(&np.user.id) || users.contains(&np.user.name); 11 | if !accept { 12 | debug!( 13 | now_playing = ?np, 14 | ?users, 15 | "Ignoring session from unwanted user" 16 | ); 17 | } 18 | ready(accept) 19 | } 20 | } 21 | 22 | pub fn libraries(libraries: &[String]) -> impl FnMut(&NowPlaying) -> Ready { 23 | move |np: &NowPlaying| { 24 | let library = np.library.as_ref(); 25 | let accept = libraries.is_empty() || library.is_some_and(|l| libraries.contains(l)); 26 | if !accept { 27 | debug!( 28 | now_playing = ?np, 29 | ?libraries, 30 | "Ignoring session from unwanted library" 31 | ); 32 | } 33 | ready(accept) 34 | } 35 | } 36 | 37 | #[cfg(test)] 38 | mod test { 39 | use crate::media_server::{NowPlaying, User}; 40 | 41 | fn np_default() -> NowPlaying { 42 | NowPlaying { 43 | series: crate::media_server::Series::Tvdb(0), 44 | episode: 0, 45 | season: 0, 46 | user: User { 47 | name: String::new(), 48 | id: String::new(), 49 | }, 50 | library: None, 51 | } 52 | } 53 | 54 | #[tokio::test] 55 | async fn users_unrestricted() { 56 | let mut filter = super::users(&[]); 57 | assert!(filter(&np_default()).await); 58 | } 59 | 60 | #[tokio::test] 61 | async fn users_accepted_by_name() { 62 | let users = vec!["Other".to_string(), "User".to_string()]; 63 | let mut filter = super::users(users.as_slice()); 64 | let np = NowPlaying { 65 | user: User { 66 | id: "1".to_string(), 67 | name: "User".to_string(), 68 | }, 69 | ..np_default() 70 | }; 71 | assert!(filter(&np).await); 72 | } 73 | 74 | #[tokio::test] 75 | async fn users_accepted_by_id() { 76 | let users = vec!["1".to_string(), "2".to_string()]; 77 | let mut filter = super::users(users.as_slice()); 78 | let np = NowPlaying { 79 | user: User { 80 | id: "1".to_string(), 81 | name: "User".to_string(), 82 | }, 83 | ..np_default() 84 | }; 85 | assert!(filter(&np).await); 86 | } 87 | 88 | #[tokio::test] 89 | async fn users_rejected() { 90 | let users = vec!["Nope".to_string()]; 91 | let mut filter = super::users(users.as_slice()); 92 | let np = NowPlaying { ..np_default() }; 93 | assert!(!filter(&np).await); 94 | } 95 | 96 | #[tokio::test] 97 | async fn libraries_unrestricted() { 98 | let mut filter = super::libraries(&[]); 99 | assert!(filter(&np_default()).await); 100 | } 101 | 102 | #[tokio::test] 103 | async fn libraries_accepted() { 104 | let libraries = vec!["Movies".to_string(), "TV".to_string()]; 105 | let mut filter = super::libraries(libraries.as_slice()); 106 | let np = NowPlaying { 107 | library: Some("TV".to_string()), 108 | ..np_default() 109 | }; 110 | assert!(filter(&np).await); 111 | } 112 | 113 | #[tokio::test] 114 | async fn libraries_unknown_rejected() { 115 | let libraries = vec!["Nope".to_string()]; 116 | let mut filter = super::libraries(libraries.as_slice()); 117 | let np = NowPlaying { 118 | library: None, 119 | ..np_default() 120 | }; 121 | assert!(!filter(&np).await); 122 | } 123 | 124 | #[tokio::test] 125 | async fn libraries_rejected() { 126 | let libraries = vec!["TV".to_string()]; 127 | let mut filter = super::libraries(libraries.as_slice()); 128 | let np = NowPlaying { 129 | library: Some("Movies".to_string()), 130 | ..np_default() 131 | }; 132 | assert!(!filter(&np).await); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::pedantic)] 2 | 3 | use std::{ 4 | fmt::Display, 5 | io::{IsTerminal, stderr}, 6 | path::PathBuf, 7 | time::Duration, 8 | }; 9 | 10 | use anyhow::Context as _; 11 | use clap::{Parser, ValueEnum, arg, command}; 12 | use futures::{StreamExt as _, TryStreamExt}; 13 | use tokio::sync::mpsc; 14 | use tokio_util::sync::PollSender; 15 | use tracing::{error, info, level_filters::LevelFilter, warn}; 16 | use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt}; 17 | 18 | use crate::{media_server::plex, util::once::Seen}; 19 | 20 | mod filter; 21 | mod media_server; 22 | mod process; 23 | mod sonarr; 24 | mod util; 25 | 26 | use media_server::embyfin; 27 | 28 | const NAME: &str = env!("CARGO_PKG_NAME"); 29 | const VERSION: &str = env!("CARGO_PKG_VERSION"); 30 | 31 | #[derive(Parser)] 32 | #[command(author, version, about, long_about = None)] 33 | struct Args { 34 | /// Media server type 35 | #[arg(long, default_value = "jellyfin")] 36 | media_server_type: MediaServer, 37 | /// Jellyfin/Emby/Plex baseurl 38 | #[arg(long, value_name = "URL")] 39 | media_server_url: String, 40 | /// Jellyfin/Emby API key or Plex server token 41 | #[arg(long, value_name = "API_KEY", env = "MEDIA_SERVER_API_KEY")] 42 | media_server_api_key: String, 43 | /// Sonarr baseurl 44 | #[arg(long, value_name = "URL")] 45 | sonarr_url: String, 46 | /// Sonarr API key 47 | #[arg(long, value_name = "API_KEY", env = "SONARR_API_KEY")] 48 | sonarr_api_key: String, 49 | /// Polling interval 50 | #[arg(long, value_name = "SECONDS", default_value_t = 900)] 51 | interval: u64, 52 | /// Logging directory 53 | #[arg(long)] 54 | log_dir: Option, 55 | /// The last episodes trigger a search 56 | #[arg(long, value_name = "NUM", default_value_t = 2)] 57 | remaining_episodes: u8, 58 | /// User IDs or names to monitor episodes for (default: empty/all users) 59 | /// 60 | /// Each entry here is checked against the user's ID and name 61 | #[arg(long, value_name = "USER", value_delimiter = ',', num_args = 0..)] 62 | users: Vec, 63 | /// Number of retries for the initial connection probing 64 | #[arg(long, value_name = "NUM", default_value_t = 0)] 65 | connection_retries: usize, 66 | /// Library names to monitor episodes for. (default: empty/all libraries) 67 | #[arg(long, value_name = "LIBRARY", value_delimiter = ',', num_args = 0..)] 68 | libraries: Vec, 69 | } 70 | 71 | #[derive(Clone, Debug, ValueEnum)] 72 | enum MediaServer { 73 | Jellyfin, 74 | Emby, 75 | Plex, 76 | } 77 | 78 | impl Display for MediaServer { 79 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 80 | let name = match self { 81 | MediaServer::Jellyfin => "Jellyfin", 82 | MediaServer::Emby => "Emby", 83 | MediaServer::Plex => "Plex", 84 | }; 85 | f.write_str(name) 86 | } 87 | } 88 | 89 | #[derive(Debug, Eq, PartialEq)] 90 | pub enum Message { 91 | NowPlaying(media_server::NowPlaying), 92 | } 93 | 94 | #[tokio::main] 95 | async fn main() -> Result<(), Box> { 96 | let args = Args::parse(); 97 | 98 | enable_logging(args.log_dir.as_ref()); 99 | 100 | info!("{NAME} {VERSION}"); 101 | 102 | if let Err(e) = run(args).await { 103 | error!("{e:#}"); 104 | info!("{NAME} exits due to an error"); 105 | return Err(e.into()); 106 | } 107 | 108 | Ok(()) 109 | } 110 | 111 | async fn run(args: Args) -> anyhow::Result<()> { 112 | let (tx, rx) = mpsc::channel(1); 113 | 114 | let sonarr_client = sonarr::Client::new(&args.sonarr_url, &args.sonarr_api_key) 115 | .context("Invalid connection parameters for Sonarr")?; 116 | util::retry(args.connection_retries, async || { 117 | sonarr_client.probe().await.context("Probing Sonarr failed") 118 | }) 119 | .await?; 120 | 121 | info!("Start watching {} sessions", args.media_server_type); 122 | let interval = Duration::from_secs(args.interval); 123 | let client: Box = match args.media_server_type { 124 | MediaServer::Emby | MediaServer::Jellyfin => { 125 | let client = embyfin::Client::new( 126 | &args.media_server_url, 127 | &args.media_server_api_key, 128 | args.media_server_type.try_into()?, 129 | ) 130 | .context("Invalid connection parameters")?; 131 | Box::new(client) 132 | } 133 | MediaServer::Plex => { 134 | let client = plex::Client::new(&args.media_server_url, &args.media_server_api_key) 135 | .context("Invalid connection parameters")?; 136 | Box::new(client) 137 | } 138 | }; 139 | 140 | client.probe_with_retry(args.connection_retries).await?; 141 | 142 | let sink = PollSender::new(tx); 143 | let np_updates = client 144 | .now_playing_updates(interval) 145 | .inspect_err(|err| error!("Cannot fetch sessions from media server: {err}")) 146 | .filter_map(async |res| res.ok()) // remove errors 147 | .filter(filter::users(args.users.as_slice())) 148 | .filter(filter::libraries(args.libraries.as_slice())) 149 | .map(Message::NowPlaying) 150 | .map(Ok) // align with the error type of `PollSender` 151 | .forward(sink); 152 | 153 | let seen = Seen::default(); 154 | let mut actor = process::Actor::new(rx, sonarr_client, seen, args.remaining_episodes); 155 | 156 | let _ = tokio::join!(np_updates, actor.process()); 157 | 158 | Ok(()) 159 | } 160 | 161 | fn enable_logging(log_dir: Option<&PathBuf>) { 162 | let env_filter = EnvFilter::builder() 163 | .with_default_directive(LevelFilter::INFO.into()) 164 | .from_env_lossy(); 165 | 166 | let subscriber = tracing_subscriber::fmt() 167 | .with_env_filter(env_filter) 168 | .with_ansi(stderr().is_terminal()) 169 | .with_writer(stderr) 170 | .finish(); 171 | 172 | let rolling_layer = log_dir.as_ref().map(|log_dir| { 173 | let file_appender = tracing_appender::rolling::daily(log_dir, "prefetcharr.log"); 174 | tracing_subscriber::fmt::layer() 175 | .with_ansi(false) 176 | .with_writer(file_appender) 177 | }); 178 | 179 | subscriber 180 | .with(rolling_layer) 181 | .try_init() 182 | .expect("setting the default subscriber"); 183 | } 184 | -------------------------------------------------------------------------------- /src/media_server.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use futures::{ 4 | FutureExt as _, StreamExt, TryStreamExt as _, 5 | future::{BoxFuture, LocalBoxFuture}, 6 | stream::{self, BoxStream, LocalBoxStream}, 7 | }; 8 | use tokio::time::MissedTickBehavior; 9 | use tracing::debug; 10 | 11 | use crate::util; 12 | 13 | pub mod embyfin; 14 | pub mod plex; 15 | 16 | #[derive(Clone, Debug, PartialEq, Eq, Hash)] 17 | pub enum Series { 18 | Title(String), 19 | Tvdb(i32), 20 | } 21 | 22 | #[derive(Clone, Debug, Eq, PartialEq)] 23 | pub struct NowPlaying { 24 | pub series: Series, 25 | pub episode: i32, 26 | pub season: i32, 27 | pub user: User, 28 | pub library: Option, 29 | } 30 | 31 | #[derive(Clone, Debug, Eq, PartialEq)] 32 | pub struct User { 33 | pub name: String, 34 | pub id: String, 35 | } 36 | 37 | pub trait ProvideNowPlaying { 38 | type Session; 39 | 40 | fn sessions( 41 | &self, 42 | ) -> impl std::future::Future>> + Send; 43 | 44 | fn extract( 45 | &self, 46 | session: Self::Session, 47 | ) -> impl std::future::Future> + Send; 48 | 49 | fn now_playing(&self) -> BoxFuture>> 50 | where 51 | ::Session: std::marker::Send, 52 | Self: Sync, 53 | { 54 | async move { 55 | let np = stream::iter(self.sessions().await?) 56 | .then(|s| self.extract(s)) 57 | .inspect_err(|e| debug!("Ignoring session: {e}")) 58 | .filter_map(async |r| r.ok()) 59 | .boxed(); 60 | Ok(np) 61 | } 62 | .boxed() 63 | } 64 | } 65 | 66 | pub trait Client { 67 | fn now_playing(&self) -> BoxFuture>>; 68 | 69 | fn now_playing_updates( 70 | &self, 71 | interval: Duration, 72 | ) -> LocalBoxStream> { 73 | let mut interval = tokio::time::interval(interval); 74 | interval.set_missed_tick_behavior(MissedTickBehavior::Skip); 75 | 76 | stream::unfold((self, interval), async |(slf, mut interval)| { 77 | interval.tick().await; 78 | 79 | let item = match slf.now_playing().await { 80 | Ok(np_stream) => Ok(np_stream.map(Ok)), 81 | Err(err) => Err(err), 82 | }; 83 | Some((item, (slf, interval))) 84 | }) 85 | .try_flatten() 86 | .boxed_local() 87 | } 88 | 89 | fn probe(&self) -> LocalBoxFuture>; 90 | 91 | fn probe_with_retry(&self, retries: usize) -> LocalBoxFuture> { 92 | util::retry(retries, || self.probe()).boxed_local() 93 | } 94 | } 95 | 96 | #[cfg(test)] 97 | pub mod test { 98 | use std::{future::ready, time::Instant}; 99 | 100 | use anyhow::anyhow; 101 | use stream::TryStreamExt; 102 | 103 | use super::*; 104 | 105 | struct Mock { 106 | bad_session: bool, 107 | bad_call: bool, 108 | } 109 | 110 | impl Mock { 111 | fn new() -> Self { 112 | Self { 113 | bad_session: false, 114 | bad_call: false, 115 | } 116 | } 117 | } 118 | 119 | pub fn np_default() -> NowPlaying { 120 | NowPlaying { 121 | series: Series::Tvdb(1234), 122 | episode: 5, 123 | season: 3, 124 | user: User { 125 | name: "user".to_string(), 126 | id: "08ba1929-681e-4b24-929b-9245852f65c0".to_string(), 127 | }, 128 | library: None, 129 | } 130 | } 131 | 132 | fn now_playing() -> Vec { 133 | vec![ 134 | NowPlaying { 135 | series: Series::Tvdb(0), 136 | episode: 1, 137 | season: 2, 138 | ..np_default() 139 | }, 140 | NowPlaying { 141 | series: Series::Tvdb(3), 142 | episode: 4, 143 | season: 5, 144 | ..np_default() 145 | }, 146 | ] 147 | } 148 | 149 | impl ProvideNowPlaying for Mock { 150 | type Session = Option; 151 | 152 | async fn sessions(&self) -> anyhow::Result> { 153 | let head = if self.bad_session { 154 | vec![None] 155 | } else { 156 | Vec::new() 157 | }; 158 | let np = now_playing().into_iter().map(Some).collect(); 159 | if self.bad_call { 160 | Err(anyhow!("API error")) 161 | } else { 162 | Ok([head, np].concat()) 163 | } 164 | } 165 | 166 | async fn extract(&self, session: Self::Session) -> anyhow::Result { 167 | session.ok_or_else(|| anyhow!("no session")) 168 | } 169 | } 170 | 171 | impl Client for Mock { 172 | fn now_playing(&self) -> BoxFuture>> { 173 | ProvideNowPlaying::now_playing(self) 174 | } 175 | 176 | fn probe(&self) -> LocalBoxFuture> { 177 | unimplemented!() 178 | } 179 | } 180 | 181 | #[tokio::test] 182 | async fn now_playing_first_instant() { 183 | let client = Mock::new(); 184 | let mut np_updates = client.now_playing_updates(Duration::from_secs(3600)); 185 | let expect = now_playing(); 186 | let np: Vec = np_updates 187 | .as_mut() 188 | .take(expect.len()) 189 | .try_collect() 190 | .await 191 | .unwrap(); 192 | assert_eq!(np, expect); 193 | tokio::time::timeout(Duration::from_millis(100), np_updates.next()) 194 | .await 195 | .unwrap_err(); 196 | } 197 | 198 | #[tokio::test] 199 | async fn now_playing_second_instant() { 200 | let client = Mock::new(); 201 | let interval = Duration::from_millis(100); 202 | let np_updates = client.now_playing_updates(interval); 203 | let expect = now_playing(); 204 | let earlier = Instant::now(); 205 | let np: Vec = np_updates 206 | .skip(expect.len()) 207 | .take(expect.len()) 208 | .try_collect() 209 | .await 210 | .unwrap(); 211 | assert!(Instant::now().duration_since(earlier) >= interval); 212 | assert_eq!(np, expect); 213 | } 214 | 215 | #[tokio::test] 216 | async fn now_playing_ignore_bad_session() { 217 | let client = Mock { 218 | bad_session: true, 219 | bad_call: false, 220 | }; 221 | let interval = Duration::from_millis(100); 222 | let np_updates = client.now_playing_updates(interval); 223 | let expect = now_playing(); 224 | let earlier = Instant::now(); 225 | let np: Vec = np_updates 226 | .skip(expect.len()) 227 | .take(expect.len()) 228 | .try_collect() 229 | .await 230 | .unwrap(); 231 | assert!(Instant::now().duration_since(earlier) >= interval); 232 | assert_eq!(np, expect); 233 | } 234 | 235 | #[tokio::test] 236 | async fn now_playing_ignore_bad_call() { 237 | let client = Mock { 238 | bad_session: true, 239 | bad_call: true, 240 | }; 241 | let interval = Duration::from_millis(100); 242 | let np_updates = client.now_playing_updates(interval); 243 | let earlier = Instant::now(); 244 | assert_eq!( 245 | np_updates 246 | .take(2) 247 | .filter(|i| ready(i.is_err())) 248 | .count() 249 | .await, 250 | 2 251 | ); 252 | assert!(Instant::now().duration_since(earlier) >= interval); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/media_server/embyfin.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::{Context, Result, anyhow}; 4 | use futures::{ 5 | FutureExt, 6 | future::{BoxFuture, LocalBoxFuture}, 7 | stream::BoxStream, 8 | }; 9 | use reqwest::{ 10 | Url, 11 | header::{HeaderMap, HeaderValue}, 12 | }; 13 | use rustls_platform_verifier::ConfigVerifierExt; 14 | use serde::{Deserialize, Serialize, de::DeserializeOwned}; 15 | use serde_json::Value; 16 | 17 | use crate::MediaServer; 18 | 19 | use super::{NowPlaying, ProvideNowPlaying}; 20 | 21 | #[derive(Clone, Debug, Serialize, Deserialize)] 22 | #[serde(rename_all = "PascalCase")] 23 | struct Episode { 24 | series_id: String, 25 | season_id: String, 26 | index_number: i32, 27 | path: String, 28 | #[serde(flatten)] 29 | _other: serde_json::Value, 30 | } 31 | 32 | #[derive(Clone, Debug, Serialize, Deserialize)] 33 | #[serde(rename_all = "PascalCase")] 34 | struct Season { 35 | index_number: i32, 36 | #[serde(flatten)] 37 | _other: serde_json::Value, 38 | } 39 | 40 | #[derive(Clone, Debug, Serialize, Deserialize)] 41 | #[serde(rename_all = "PascalCase")] 42 | struct Series { 43 | name: String, 44 | provider_ids: HashMap, 45 | #[serde(flatten)] 46 | _other: serde_json::Value, 47 | } 48 | 49 | #[derive(Clone, Debug, Serialize, Deserialize)] 50 | #[serde(rename_all = "PascalCase")] 51 | pub struct SessionInfo { 52 | user_id: String, 53 | user_name: String, 54 | now_playing_item: Episode, 55 | #[serde(flatten)] 56 | other: serde_json::Value, 57 | } 58 | 59 | #[derive(Clone, Debug, Serialize, Deserialize)] 60 | #[serde(rename_all = "PascalCase")] 61 | pub struct VirtualFolderInfo { 62 | name: String, 63 | locations: Vec, 64 | #[serde(flatten)] 65 | other: serde_json::Value, 66 | } 67 | 68 | #[derive(Clone, Copy)] 69 | pub enum Fork { 70 | Jellyfin, 71 | Emby, 72 | } 73 | 74 | impl TryFrom for Fork { 75 | type Error = anyhow::Error; 76 | 77 | fn try_from(value: MediaServer) -> std::result::Result { 78 | match value { 79 | MediaServer::Jellyfin => Ok(Self::Jellyfin), 80 | MediaServer::Emby => Ok(Self::Emby), 81 | MediaServer::Plex => Err(anyhow!("media server is neither Emby nor Jellyfin")), 82 | } 83 | } 84 | } 85 | 86 | pub struct Client { 87 | base_url: Url, 88 | http: reqwest::Client, 89 | fork: Fork, 90 | } 91 | 92 | impl Client { 93 | pub fn new(base_url: &str, api_key: &str, fork: Fork) -> Result { 94 | let base_url = base_url.parse()?; 95 | 96 | let mut headers = HeaderMap::new(); 97 | match fork { 98 | Fork::Jellyfin => { 99 | let value = format!("MediaBrowser Token=\"{api_key}\""); 100 | 101 | let mut value = HeaderValue::from_str(&value)?; 102 | value.set_sensitive(true); 103 | 104 | headers.insert("Authorization", value); 105 | } 106 | Fork::Emby => { 107 | let mut token = HeaderValue::from_str(api_key)?; 108 | token.set_sensitive(true); 109 | 110 | headers.insert("X-Emby-Token", token); 111 | } 112 | } 113 | 114 | headers.insert( 115 | reqwest::header::ACCEPT, 116 | HeaderValue::from_static("application/json"), 117 | ); 118 | 119 | let http = reqwest::Client::builder() 120 | .default_headers(headers) 121 | .use_preconfigured_tls(rustls::ClientConfig::with_platform_verifier()) 122 | .build()?; 123 | 124 | Ok(Self { 125 | base_url, 126 | http, 127 | fork, 128 | }) 129 | } 130 | 131 | async fn get(&self, path: &str) -> Result { 132 | let mut url = self.base_url.clone(); 133 | url.path_segments_mut() 134 | .map_err(|()| anyhow!("url is relative"))? 135 | .extend(path.split('/')); 136 | let response = self.http.get(url).send().await?.error_for_status()?; 137 | Ok(response.json::().await?) 138 | } 139 | 140 | async fn item(&self, user_id: &str, item_id: &str) -> Result { 141 | let path = format!("Users/{user_id}/Items/{item_id}"); 142 | self.get(path.as_str()).await 143 | } 144 | 145 | async fn virtual_folders(&self) -> Result> { 146 | self.get("Library/VirtualFolders").await 147 | } 148 | } 149 | 150 | #[derive(Debug)] 151 | struct Ids { 152 | user: String, 153 | series: String, 154 | season: String, 155 | } 156 | 157 | impl From for Ids { 158 | fn from(session: SessionInfo) -> Self { 159 | Ids::new(&session) 160 | } 161 | } 162 | 163 | impl Ids { 164 | fn new(session: &SessionInfo) -> Self { 165 | let user_id = session.user_id.clone(); 166 | let np = &session.now_playing_item; 167 | let series_id = np.series_id.clone(); 168 | let season_id = np.season_id.clone(); 169 | 170 | Self { 171 | user: user_id, 172 | series: series_id, 173 | season: season_id, 174 | } 175 | } 176 | } 177 | 178 | impl super::ProvideNowPlaying for Client { 179 | type Session = SessionInfo; 180 | 181 | async fn sessions(&self) -> anyhow::Result> { 182 | Ok(self 183 | .get::>("Sessions") 184 | .await? 185 | .iter() 186 | .cloned() 187 | .map(serde_json::from_value) 188 | .filter_map(Result::ok) 189 | .collect::>()) 190 | } 191 | 192 | async fn extract(&self, session: Self::Session) -> anyhow::Result { 193 | let ids = Ids::new(&session); 194 | let episode_num = session.now_playing_item.index_number; 195 | let user = super::User { 196 | id: session.user_id, 197 | name: session.user_name, 198 | }; 199 | let series: Series = self.item(&ids.user, &ids.series).await?; 200 | 201 | let season: Season = self.item(&ids.user, &ids.season).await?; 202 | let season_num = season.index_number; 203 | 204 | let tvdb_id = series.provider_ids.get("Tvdb"); 205 | 206 | let series = match tvdb_id { 207 | Some(tvdb) => super::Series::Tvdb(tvdb.parse()?), 208 | None => super::Series::Title(series.name), 209 | }; 210 | 211 | let item_path = session.now_playing_item.path.clone(); 212 | let library = { 213 | let folders = self.virtual_folders().await?; 214 | let folder = folders.into_iter().find(|f| { 215 | f.locations 216 | .iter() 217 | .any(|l| item_path.starts_with(l.as_str())) 218 | }); 219 | folder.map(|f| f.name) 220 | }; 221 | 222 | let now_playing = NowPlaying { 223 | series, 224 | episode: episode_num, 225 | season: season_num, 226 | user, 227 | library, 228 | }; 229 | 230 | Ok(now_playing) 231 | } 232 | } 233 | 234 | impl super::Client for Client { 235 | fn now_playing(&self) -> BoxFuture>> { 236 | ProvideNowPlaying::now_playing(self) 237 | } 238 | 239 | fn probe(&self) -> LocalBoxFuture> { 240 | async move { 241 | let name = match self.fork { 242 | Fork::Jellyfin => "Jellyfin", 243 | Fork::Emby => "Emby", 244 | }; 245 | self.get::("System/Endpoint") 246 | .await 247 | .with_context(|| format!("Probing {name} failed"))?; 248 | Ok(()) 249 | } 250 | .boxed_local() 251 | } 252 | } 253 | 254 | #[cfg(test)] 255 | mod test { 256 | use std::time::{Duration, Instant}; 257 | 258 | use futures::StreamExt; 259 | 260 | use crate::media_server::{Client, NowPlaying, Series, embyfin, test::np_default}; 261 | 262 | fn episode() -> serde_json::Value { 263 | serde_json::json!( 264 | [{ 265 | "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", 266 | "UserName": "user", 267 | "NowPlayingItem": { 268 | "SeriesId": "a", 269 | "SeasonId": "b", 270 | "IndexNumber": 5, 271 | "Path": "/media/tv/a/b/c.mkv" 272 | } 273 | }] 274 | ) 275 | } 276 | fn series() -> serde_json::Value { 277 | serde_json::json!({ 278 | "Name": "Test Show", 279 | "ProviderIds": { "Tvdb": "1234" } 280 | }) 281 | } 282 | 283 | fn virtual_folders() -> serde_json::Value { 284 | serde_json::json!( 285 | [ 286 | { 287 | "Name": "Kids Movies", 288 | "Locations": [ 289 | "/media/KidsMovies" 290 | ], 291 | "CollectionType": "movies", 292 | "LibraryOptions": {}, 293 | "ItemId": "7", 294 | "Id": "7", 295 | "Guid": "5ca6e6c767144c3284fb91a2f8e0523c", 296 | "PrimaryImageItemId": "7" 297 | }, 298 | { 299 | "Name": "Kids TV Shows", 300 | "Locations": [ 301 | "/media/KidsTV" 302 | ], 303 | "CollectionType": "tvshows", 304 | "LibraryOptions": {}, 305 | "ItemId": "9", 306 | "Id": "9", 307 | "Guid": "4a46715108f54dfc8f6418bad7e46718", 308 | "PrimaryImageItemId": "9" 309 | }, 310 | { 311 | "Name": "Movies", 312 | "Locations": [ 313 | "/media/Movies" 314 | ], 315 | "CollectionType": "movies", 316 | "LibraryOptions": {}, 317 | "ItemId": "3", 318 | "Id": "3", 319 | "Guid": "963293cee9894fb09580d46eddd61fe5", 320 | "PrimaryImageItemId": "3" 321 | }, 322 | { 323 | "Name": "Playlists", 324 | "Locations": [ 325 | "/config/data/playlists", 326 | "/config/data/userplaylists" 327 | ], 328 | "CollectionType": "playlists", 329 | "LibraryOptions": {}, 330 | "ItemId": "36639", 331 | "Id": "36639", 332 | "Guid": "04c519cbf764481e9b66448210d2c055", 333 | "PrimaryImageItemId": "36639" 334 | }, 335 | { 336 | "Name": "TV Shows", 337 | "Locations": [ 338 | "/media/tv" 339 | ], 340 | "CollectionType": "tvshows", 341 | "LibraryOptions": {}, 342 | "ItemId": "5", 343 | "Id": "5", 344 | "Guid": "26ee32dcd90f4607807874f09ced0fed", 345 | "PrimaryImageItemId": "5" 346 | }, 347 | { 348 | "Name": "Collections", 349 | "Locations": [], 350 | "CollectionType": "boxsets", 351 | "LibraryOptions": {}, 352 | "ItemId": "80071", 353 | "Id": "80071", 354 | "Guid": "a09546b017694aaa9f87c298823f91c0", 355 | "PrimaryImageItemId": "80071" 356 | } 357 | ] 358 | ) 359 | } 360 | 361 | #[tokio::test] 362 | async fn single_session() -> Result<(), Box> { 363 | let server = httpmock::MockServer::start_async().await; 364 | 365 | let sessions_mock = server 366 | .mock_async(|when, then| { 367 | when.path("/pathprefix/Sessions"); 368 | then.json_body(episode()); 369 | }) 370 | .await; 371 | 372 | let season_mock = server 373 | .mock_async(|when, then| { 374 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/b"); 375 | then.json_body(serde_json::json!({"IndexNumber": 3})); 376 | }) 377 | .await; 378 | 379 | let series_mock = server 380 | .mock_async(|when, then| { 381 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/a"); 382 | then.json_body(series()); 383 | }) 384 | .await; 385 | 386 | let folders_mock = server 387 | .mock_async(|when, then| { 388 | when.path("/pathprefix/Library/VirtualFolders"); 389 | then.json_body(virtual_folders()); 390 | }) 391 | .await; 392 | 393 | let client = embyfin::Client::new( 394 | &server.url("/pathprefix"), 395 | "secret", 396 | embyfin::Fork::Jellyfin, 397 | )?; 398 | 399 | let mut np_updates = client.now_playing_updates(Duration::from_secs(100)); 400 | let message = np_updates.next().await.transpose().unwrap(); 401 | let message_expect = NowPlaying { 402 | library: Some("TV Shows".to_string()), 403 | ..np_default() 404 | }; 405 | 406 | assert_eq!(message, Some(message_expect)); 407 | 408 | sessions_mock.assert_async().await; 409 | series_mock.assert_async().await; 410 | season_mock.assert_async().await; 411 | folders_mock.assert_async().await; 412 | 413 | Ok(()) 414 | } 415 | 416 | #[tokio::test] 417 | async fn skip_invalid_sessions() -> Result<(), Box> { 418 | let server = httpmock::MockServer::start_async().await; 419 | 420 | let sessions_mock = server 421 | .mock_async(|when, then| { 422 | when.path("/pathprefix/Sessions"); 423 | then.json_body(serde_json::json!( 424 | [{ "invalid": "session" }, 425 | { 426 | "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", 427 | "UserName": "user", 428 | "NowPlayingItem": { 429 | "SeriesId": "invalid", 430 | "SeasonId": "invalid", 431 | "IndexNumber": 5, 432 | "Path": "/media/a/b/c.mkv" 433 | } 434 | }, 435 | { 436 | "UserId": "08ba1929-681e-4b24-929b-9245852f65c0", 437 | "UserName": "user", 438 | "NowPlayingItem": { 439 | "SeriesId": "a", 440 | "SeasonId": "b", 441 | "IndexNumber": 5, 442 | "Path": "/media/a/b/c.mkv" 443 | }, 444 | }] 445 | )); 446 | }) 447 | .await; 448 | 449 | let season_mock = server 450 | .mock_async(|when, then| { 451 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/b"); 452 | then.json_body(serde_json::json!({"IndexNumber": 3})); 453 | }) 454 | .await; 455 | 456 | let series_mock = server 457 | .mock_async(|when, then| { 458 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/a"); 459 | then.json_body(series()); 460 | }) 461 | .await; 462 | 463 | let _folders_mock = server 464 | .mock_async(|when, then| { 465 | when.path("/pathprefix/Library/VirtualFolders"); 466 | then.json_body(serde_json::json!([])); 467 | }) 468 | .await; 469 | 470 | let client = embyfin::Client::new( 471 | &server.url("/pathprefix"), 472 | "secret", 473 | embyfin::Fork::Jellyfin, 474 | )?; 475 | 476 | let mut np_updates = client.now_playing_updates(Duration::from_secs(100)); 477 | let message = np_updates.next().await.transpose().unwrap(); 478 | let message_expect = np_default(); 479 | 480 | assert_eq!(message, Some(message_expect)); 481 | 482 | sessions_mock.assert_async().await; 483 | series_mock.assert_async().await; 484 | season_mock.assert_async().await; 485 | 486 | Ok(()) 487 | } 488 | 489 | #[tokio::test] 490 | async fn name_fallback_emby() -> Result<(), Box> { 491 | let server = httpmock::MockServer::start_async().await; 492 | 493 | let sessions_mock = server 494 | .mock_async(|when, then| { 495 | when.path("/pathprefix/Sessions"); 496 | then.json_body(episode()); 497 | }) 498 | .await; 499 | 500 | let season_mock = server 501 | .mock_async(|when, then| { 502 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/b"); 503 | then.json_body(serde_json::json!({"IndexNumber": 3})); 504 | }) 505 | .await; 506 | 507 | let series_mock = server 508 | .mock_async(|when, then| { 509 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/a"); 510 | then.json_body(serde_json::json!({ 511 | "Name": "Test Show", 512 | "ProviderIds": { } 513 | })); 514 | }) 515 | .await; 516 | 517 | let _folders_mock = server 518 | .mock_async(|when, then| { 519 | when.path("/pathprefix/Library/VirtualFolders"); 520 | then.json_body(serde_json::json!([])); 521 | }) 522 | .await; 523 | 524 | let client = 525 | embyfin::Client::new(&server.url("/pathprefix"), "secret", embyfin::Fork::Emby)?; 526 | 527 | let mut np_updates = client.now_playing_updates(Duration::from_secs(100)); 528 | let message = np_updates.next().await.transpose().unwrap(); 529 | let message_expect = NowPlaying { 530 | series: Series::Title("Test Show".to_string()), 531 | ..np_default() 532 | }; 533 | 534 | assert_eq!(message, Some(message_expect)); 535 | 536 | sessions_mock.assert_async().await; 537 | series_mock.assert_async().await; 538 | season_mock.assert_async().await; 539 | 540 | Ok(()) 541 | } 542 | 543 | #[test] 544 | fn bad_url() { 545 | assert!(embyfin::Client::new("/notanurl", "secret", embyfin::Fork::Jellyfin,).is_err()); 546 | } 547 | 548 | #[tokio::test] 549 | async fn interval() -> Result<(), Box> { 550 | let server = httpmock::MockServer::start_async().await; 551 | 552 | let _sessions_mock = server 553 | .mock_async(|when, then| { 554 | when.path("/pathprefix/Sessions"); 555 | then.json_body(episode()); 556 | }) 557 | .await; 558 | 559 | let _season_mock = server 560 | .mock_async(|when, then| { 561 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/b"); 562 | then.json_body(serde_json::json!({"IndexNumber": 3})); 563 | }) 564 | .await; 565 | 566 | let _series_mock = server 567 | .mock_async(|when, then| { 568 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/a"); 569 | then.json_body(series()); 570 | }) 571 | .await; 572 | 573 | let _folders_mock = server 574 | .mock_async(|when, then| { 575 | when.path("/pathprefix/Library/VirtualFolders"); 576 | then.json_body(serde_json::json!([])); 577 | }) 578 | .await; 579 | 580 | let client = embyfin::Client::new( 581 | &server.url("/pathprefix"), 582 | "secret", 583 | embyfin::Fork::Jellyfin, 584 | )?; 585 | 586 | let mut np_updates = client.now_playing_updates(Duration::from_millis(100)); 587 | 588 | let start = Instant::now(); 589 | let _ = np_updates.next().await; 590 | let tolerance = Instant::now().duration_since(start) * 2; 591 | 592 | let start = Instant::now(); 593 | let _ = np_updates.next().await; 594 | assert!(Instant::now().duration_since(start) >= Duration::from_millis(100) - tolerance); 595 | 596 | Ok(()) 597 | } 598 | 599 | #[tokio::test] 600 | async fn jellyfin_auth() -> Result<(), Box> { 601 | let server = httpmock::MockServer::start_async().await; 602 | 603 | let sessions_mock = server 604 | .mock_async(|when, then| { 605 | when.path("/pathprefix/Sessions") 606 | .header("Authorization", "MediaBrowser Token=\"secret\""); 607 | then.json_body(episode()); 608 | }) 609 | .await; 610 | 611 | let _season_mock = server 612 | .mock_async(|when, then| { 613 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/b"); 614 | then.json_body(serde_json::json!({"IndexNumber": 3})); 615 | }) 616 | .await; 617 | 618 | let _series_mock = server 619 | .mock_async(|when, then| { 620 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/a"); 621 | then.json_body(series()); 622 | }) 623 | .await; 624 | 625 | let _folders_mock = server 626 | .mock_async(|when, then| { 627 | when.path("/pathprefix/Library/VirtualFolders"); 628 | then.json_body(serde_json::json!([])); 629 | }) 630 | .await; 631 | 632 | let client = embyfin::Client::new( 633 | &server.url("/pathprefix"), 634 | "secret", 635 | embyfin::Fork::Jellyfin, 636 | )?; 637 | 638 | let mut np_updates = client.now_playing_updates(Duration::from_secs(100)); 639 | 640 | let _ = np_updates.next().await; 641 | sessions_mock.assert_async().await; 642 | 643 | Ok(()) 644 | } 645 | 646 | #[tokio::test] 647 | async fn emby_auth() -> Result<(), Box> { 648 | let server = httpmock::MockServer::start_async().await; 649 | 650 | let sessions_mock = server 651 | .mock_async(|when, then| { 652 | when.path("/pathprefix/Sessions") 653 | .header("X-Emby-Token", "secret"); 654 | then.json_body(episode()); 655 | }) 656 | .await; 657 | 658 | let _season_mock = server 659 | .mock_async(|when, then| { 660 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/b"); 661 | then.json_body(serde_json::json!({"IndexNumber": 3})); 662 | }) 663 | .await; 664 | 665 | let _series_mock = server 666 | .mock_async(|when, then| { 667 | when.path("/pathprefix/Users/08ba1929-681e-4b24-929b-9245852f65c0/Items/a"); 668 | then.json_body(series()); 669 | }) 670 | .await; 671 | 672 | let _folders_mock = server 673 | .mock_async(|when, then| { 674 | when.path("/pathprefix/Library/VirtualFolders"); 675 | then.json_body(serde_json::json!([])); 676 | }) 677 | .await; 678 | 679 | let client = 680 | embyfin::Client::new(&server.url("/pathprefix"), "secret", embyfin::Fork::Emby)?; 681 | 682 | let mut np_updates = client.now_playing_updates(Duration::from_secs(100)); 683 | 684 | let _ = np_updates.next().await; 685 | sessions_mock.assert_async().await; 686 | 687 | Ok(()) 688 | } 689 | } 690 | -------------------------------------------------------------------------------- /src/media_server/plex.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, anyhow, bail}; 2 | use futures::{ 3 | FutureExt, 4 | future::{BoxFuture, LocalBoxFuture}, 5 | stream::BoxStream, 6 | }; 7 | use reqwest::header::{HeaderMap, HeaderValue}; 8 | use rustls_platform_verifier::ConfigVerifierExt; 9 | use serde::{Deserialize, de::DeserializeOwned}; 10 | use serde_json::Value; 11 | 12 | use super::{NowPlaying, ProvideNowPlaying}; 13 | 14 | #[derive(Debug, Deserialize)] 15 | #[serde(rename_all = "camelCase")] 16 | pub struct User { 17 | id: String, 18 | title: String, 19 | #[serde(flatten)] 20 | _other: serde_json::Value, 21 | } 22 | 23 | impl From for super::User { 24 | fn from(value: User) -> Self { 25 | Self { 26 | name: value.title, 27 | id: value.id, 28 | } 29 | } 30 | } 31 | 32 | #[derive(Debug, Deserialize)] 33 | #[serde(rename_all = "camelCase")] 34 | pub struct Episode { 35 | grandparent_title: String, 36 | grandparent_key: String, 37 | index: i32, 38 | parent_index: i32, 39 | r#type: String, 40 | #[serde(rename = "User")] 41 | user: User, 42 | library_section_title: String, 43 | #[serde(flatten)] 44 | _other: serde_json::Value, 45 | } 46 | 47 | pub struct Client { 48 | http: reqwest::Client, 49 | url: reqwest::Url, 50 | } 51 | 52 | impl Client { 53 | pub fn new(url: &str, token: &str) -> Result { 54 | let mut token = HeaderValue::from_str(token)?; 55 | token.set_sensitive(true); 56 | let mut headers = HeaderMap::new(); 57 | headers.insert("X-Plex-Token", token); 58 | headers.insert( 59 | reqwest::header::ACCEPT, 60 | HeaderValue::from_static("application/json"), 61 | ); 62 | let http = reqwest::Client::builder() 63 | .default_headers(headers) 64 | .use_preconfigured_tls(rustls::ClientConfig::with_platform_verifier()) 65 | .build()?; 66 | 67 | let url = url.parse()?; 68 | 69 | Ok(Self { http, url }) 70 | } 71 | 72 | async fn get(&self, path: &str) -> Result { 73 | let mut url = self.url.clone(); 74 | url.path_segments_mut() 75 | .map_err(|()| anyhow!("url is relative"))? 76 | .extend(path.split('/')); 77 | let response = self.http.get(url).send().await?.error_for_status()?; 78 | Ok(response.json::().await?) 79 | } 80 | 81 | async fn tvdb(&self, key: &str) -> Option { 82 | self.get::(key) 83 | .await 84 | .ok()? 85 | .get("MediaContainer")? 86 | .get("Metadata")? 87 | .as_array()? 88 | .first()? 89 | .get("Guid")? 90 | .as_array()? 91 | .iter() 92 | .find_map(|g| { 93 | let uri = g.as_object()?.get("id")?.as_str()?; 94 | let (provider, id) = uri.split_once("://")?; 95 | (provider == "tvdb").then_some(id.parse().ok()?) 96 | }) 97 | } 98 | } 99 | 100 | impl ProvideNowPlaying for Client { 101 | type Session = Episode; 102 | 103 | async fn sessions(&self) -> anyhow::Result> { 104 | let obj: serde_json::Map = self.get("status/sessions").await?; 105 | Ok(obj 106 | .get("MediaContainer") 107 | .and_then(|v| v.get("Metadata")) 108 | .and_then(Value::as_array) 109 | .map(|metas| { 110 | metas 111 | .iter() 112 | .cloned() 113 | .map(serde_json::value::from_value) 114 | .filter_map(Result::ok) 115 | .collect::>() 116 | }) 117 | .unwrap_or_default()) 118 | } 119 | 120 | async fn extract(&self, session: Self::Session) -> anyhow::Result { 121 | if session.r#type != "episode" { 122 | bail!("not an episode"); 123 | } 124 | let episode = session.index; 125 | let season = session.parent_index; 126 | let series = match self.tvdb(&session.grandparent_key).await { 127 | Some(id) => super::Series::Tvdb(id), 128 | None => super::Series::Title(session.grandparent_title), 129 | }; 130 | let user = session.user.into(); 131 | let library = Some(session.library_section_title); 132 | 133 | Ok(NowPlaying { 134 | series, 135 | episode, 136 | season, 137 | user, 138 | library, 139 | }) 140 | } 141 | } 142 | 143 | impl super::Client for Client { 144 | fn now_playing(&self) -> BoxFuture>> { 145 | ProvideNowPlaying::now_playing(self) 146 | } 147 | 148 | fn probe(&self) -> LocalBoxFuture> { 149 | async { 150 | self.get::("status/sessions").await?; 151 | Ok(()) 152 | } 153 | .boxed_local() 154 | } 155 | } 156 | 157 | #[cfg(test)] 158 | mod test { 159 | use std::time::Duration; 160 | 161 | use futures::StreamExt as _; 162 | 163 | use crate::media_server::{Client, NowPlaying, Series, plex, test::np_default}; 164 | 165 | fn episode() -> serde_json::Value { 166 | serde_json::json!( 167 | { 168 | "MediaContainer": { 169 | "Metadata": [{ 170 | "grandparentTitle": "Test Show", 171 | "grandparentKey": "path/to/series", 172 | "index": 5, 173 | "parentIndex": 3, 174 | "type": "episode", 175 | "User": { 176 | "id": "08ba1929-681e-4b24-929b-9245852f65c0", 177 | "title": "user", 178 | "thumb": "ignore" 179 | }, 180 | "librarySectionTitle": "TV Shows" 181 | }] 182 | } 183 | } 184 | ) 185 | } 186 | 187 | fn series() -> serde_json::Value { 188 | serde_json::json!({ 189 | "MediaContainer": { 190 | "Metadata": [{ 191 | "Guid": [ 192 | {"id": "ignore"}, 193 | {"id": "ignore://"}, 194 | {"id": "://ignore"}, 195 | {"id": "ignore://0"}, 196 | {"id": "tvdb://1234"} 197 | ] 198 | }] 199 | } 200 | }) 201 | } 202 | 203 | #[tokio::test] 204 | async fn single_session() -> Result<(), Box> { 205 | let server = httpmock::MockServer::start_async().await; 206 | 207 | let sessions_mock = server 208 | .mock_async(|when, then| { 209 | when.path("/pathprefix/status/sessions"); 210 | then.json_body(episode()); 211 | }) 212 | .await; 213 | 214 | let series_mock = server 215 | .mock_async(|when, then| { 216 | when.path("/pathprefix/path/to/series"); 217 | then.json_body(series()); 218 | }) 219 | .await; 220 | 221 | let client = plex::Client::new(&server.url("/pathprefix"), "secret")?; 222 | 223 | let mut np_updates = client.now_playing_updates(Duration::from_secs(100)); 224 | let message = np_updates.next().await.transpose().unwrap(); 225 | let message_expect = NowPlaying { 226 | library: Some("TV Shows".into()), 227 | ..np_default() 228 | }; 229 | 230 | assert_eq!(message, Some(message_expect)); 231 | 232 | sessions_mock.assert_async().await; 233 | series_mock.assert_async().await; 234 | 235 | Ok(()) 236 | } 237 | 238 | #[tokio::test] 239 | async fn skip_invalid_sessions() -> Result<(), Box> { 240 | let server = httpmock::MockServer::start_async().await; 241 | 242 | let sessions_mock = server 243 | .mock_async(|when, then| { 244 | when.path("/pathprefix/status/sessions"); 245 | then.json_body(serde_json::json!( 246 | { 247 | "MediaContainer": { 248 | "Metadata": [ 249 | { "invalid": "session" }, 250 | { 251 | "grandparentTitle": "invalid", 252 | "index": 5, 253 | "parentIndex": 3, 254 | "type": "episode" 255 | }, 256 | { 257 | "grandparentTitle": "Test Show", 258 | "grandparentKey": "path/to/series", 259 | "index": 5, 260 | "parentIndex": 3, 261 | "type": "invalid" 262 | }, 263 | { 264 | "grandparentTitle": "Test Show", 265 | "grandparentKey": "path/to/series", 266 | "index": 5, 267 | "parentIndex": 3, 268 | "type": "episode", 269 | "librarySectionTitle": "TV Shows", 270 | "User": { 271 | "id": "08ba1929-681e-4b24-929b-9245852f65c0", 272 | "title": "user", 273 | "thumb": "ignore" 274 | } 275 | } 276 | ] 277 | } 278 | } 279 | )); 280 | }) 281 | .await; 282 | 283 | let series_mock = server 284 | .mock_async(|when, then| { 285 | when.path("/pathprefix/path/to/series"); 286 | then.json_body(series()); 287 | }) 288 | .await; 289 | 290 | let client = plex::Client::new(&server.url("/pathprefix"), "secret")?; 291 | 292 | let mut np_updates = client.now_playing_updates(Duration::from_secs(100)); 293 | let message = np_updates.next().await.transpose().unwrap(); 294 | let message_expect = NowPlaying { 295 | library: Some("TV Shows".into()), 296 | ..np_default() 297 | }; 298 | 299 | assert_eq!(message, Some(message_expect)); 300 | 301 | sessions_mock.assert_async().await; 302 | series_mock.assert_async().await; 303 | 304 | Ok(()) 305 | } 306 | 307 | #[tokio::test] 308 | async fn name_fallback() -> Result<(), Box> { 309 | let server = httpmock::MockServer::start_async().await; 310 | 311 | let sessions_mock = server 312 | .mock_async(|when, then| { 313 | when.path("/pathprefix/status/sessions"); 314 | then.json_body(serde_json::json!( 315 | { 316 | "MediaContainer": { 317 | "Metadata": [ 318 | { 319 | "grandparentTitle": "Test Show", 320 | "grandparentKey": "invalid", 321 | "index": 5, 322 | "parentIndex": 3, 323 | "type": "episode", 324 | "librarySectionTitle": "TV Shows", 325 | "User": { 326 | "id": "08ba1929-681e-4b24-929b-9245852f65c0", 327 | "title": "user", 328 | "thumb": "ignore" 329 | } 330 | } 331 | ] 332 | } 333 | } 334 | )); 335 | }) 336 | .await; 337 | 338 | let series_mock = server 339 | .mock_async(|when, then| { 340 | when.path("/pathprefix/path/to/series"); 341 | then.json_body(series()); 342 | }) 343 | .await; 344 | 345 | let client = plex::Client::new(&server.url("/pathprefix"), "secret")?; 346 | 347 | let mut np_updates = client.now_playing_updates(Duration::from_secs(100)); 348 | let message = np_updates.next().await.transpose().unwrap(); 349 | let message_expect = NowPlaying { 350 | series: Series::Title("Test Show".to_string()), 351 | library: Some("TV Shows".into()), 352 | ..np_default() 353 | }; 354 | 355 | assert_eq!(message, Some(message_expect)); 356 | 357 | sessions_mock.assert_async().await; 358 | series_mock.assert_hits_async(0).await; 359 | 360 | Ok(()) 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /src/process.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use tokio::sync::mpsc; 3 | use tracing::{debug, error, info}; 4 | 5 | use crate::{ 6 | Message, 7 | media_server::{NowPlaying, Series}, 8 | sonarr, 9 | util::once::Seen, 10 | }; 11 | 12 | pub struct Actor { 13 | rx: mpsc::Receiver, 14 | sonarr_client: sonarr::Client, 15 | seen: Seen, 16 | remaining_episodes: u8, 17 | } 18 | 19 | impl Actor { 20 | pub fn new( 21 | rx: mpsc::Receiver, 22 | sonarr_client: sonarr::Client, 23 | seen: Seen, 24 | remaining_episodes: u8, 25 | ) -> Self { 26 | Self { 27 | rx, 28 | sonarr_client, 29 | seen, 30 | remaining_episodes, 31 | } 32 | } 33 | } 34 | 35 | impl Actor { 36 | pub async fn process(&mut self) { 37 | while let Some(msg) = self.rx.recv().await { 38 | match msg { 39 | Message::NowPlaying(np) => { 40 | if let Err(e) = self.search_next(np).await { 41 | error!(err = ?e, "Failed to process"); 42 | } 43 | } 44 | }; 45 | } 46 | } 47 | 48 | async fn search_next(&mut self, np: NowPlaying) -> anyhow::Result<()> { 49 | let series = self.sonarr_client.series().await?; 50 | let mut series = series 51 | .into_iter() 52 | .find(|s| match &np.series { 53 | Series::Title(t) => s.title.as_ref() == Some(t), 54 | Series::Tvdb(i) => &s.tvdb_id == i, 55 | }) 56 | .ok_or_else(|| anyhow!("series not found in Sonarr"))?; 57 | 58 | info!(title = series.title.clone().unwrap_or_else(|| "?".to_string()), now_playing = ?np); 59 | 60 | let season = series 61 | .season(np.season) 62 | .ok_or_else(|| anyhow!("season not known to Sonarr"))?; 63 | 64 | let is_pilot = np.episode == 1 && np.season == 1; 65 | let is_only_episode = season 66 | .statistics 67 | .as_ref() 68 | .is_some_and(|s| s.episode_file_count == 1); 69 | let is_end_of_season = np.episode 70 | > season 71 | .last_episode() 72 | .unwrap_or(0) 73 | .saturating_sub(i32::from(self.remaining_episodes)); 74 | 75 | if !(is_end_of_season || is_pilot && is_only_episode) { 76 | debug!(now_playing = ?np, season = ?season, "ignoring early episode"); 77 | return Ok(()); 78 | } 79 | 80 | let next_season = if is_pilot && is_only_episode { 81 | info!("Stand-alone pilot episode detected, target first season"); 82 | season 83 | } else if let Some(s) = series.season_mut(np.season + 1) { 84 | s 85 | } else { 86 | info!("Next season not known, monitor new seasons instead"); 87 | series.monitor_new_items = Some(sonarr::NewItemMonitorTypes::All); 88 | series.monitored = true; 89 | self.sonarr_client.put_series(&series).await?; 90 | return Ok(()); 91 | }; 92 | 93 | let next_season_num = next_season.season_number; 94 | 95 | if !self.seen.once(np.series.clone(), next_season_num) { 96 | debug!(now_playing = ?np, "skip previously processed item"); 97 | return Ok(()); 98 | } 99 | 100 | if let Some(statistics) = &next_season.statistics { 101 | if statistics.episode_file_count == statistics.total_episode_count 102 | && statistics.total_episode_count > 0 103 | { 104 | debug!(num = next_season_num, "skip already downloaded season"); 105 | return Ok(()); 106 | } 107 | } 108 | 109 | info!(num = next_season_num, "Searching next season"); 110 | 111 | self.sonarr_client 112 | .search_season(&series, next_season_num) 113 | .await?; 114 | 115 | Ok(()) 116 | } 117 | } 118 | 119 | #[cfg(test)] 120 | #[allow(clippy::too_many_lines)] 121 | mod test { 122 | use std::time::Duration; 123 | 124 | use httpmock::Method::{POST, PUT}; 125 | use serde_json::json; 126 | use tokio::sync::mpsc; 127 | 128 | use crate::{ 129 | Message, 130 | media_server::{NowPlaying, Series, test::np_default}, 131 | util::once, 132 | }; 133 | 134 | #[tokio::test] 135 | async fn search_next() -> Result<(), Box> { 136 | let server = httpmock::MockServer::start_async().await; 137 | 138 | let series_mock = server 139 | .mock_async(|when, then| { 140 | when.path("/pathprefix/api/v3/series"); 141 | then.json_body(serde_json::json!( 142 | [{ 143 | "id": 1234, 144 | "title": "TestShow", 145 | "tvdbId": 5678, 146 | "monitored": false, 147 | "monitorNewItems": "all", 148 | "seasons": [{ 149 | "seasonNumber": 1, 150 | "monitored": true, 151 | "statistics": { 152 | "sizeOnDisk": 9000, 153 | "episodeCount": 8, 154 | "episodeFileCount": 8, 155 | "totalEpisodeCount": 8, 156 | } 157 | },{ 158 | "seasonNumber": 2, 159 | "monitored": false, 160 | "statistics": { 161 | "sizeOnDisk": 9000, 162 | "episodeCount": 0, 163 | "episodeFileCount": 0, 164 | "totalEpisodeCount": 8, 165 | } 166 | }] 167 | } 168 | ] 169 | )); 170 | }) 171 | .await; 172 | 173 | let put_series_mock = server 174 | .mock_async(|when, then| { 175 | when.path("/pathprefix/api/v3/series/1234") 176 | .method(PUT) 177 | .json_body(serde_json::json!( 178 | { 179 | "id": 1234, 180 | "title": "TestShow", 181 | "tvdbId": 5678, 182 | "monitored": true, 183 | "monitorNewItems": "all", 184 | "seasons": [{ 185 | "seasonNumber": 1, 186 | "monitored": true, 187 | "statistics": { 188 | "sizeOnDisk": 9000, 189 | "episodeCount": 8, 190 | "episodeFileCount": 8, 191 | "totalEpisodeCount": 8, 192 | } 193 | },{ 194 | "seasonNumber": 2, 195 | "monitored": true, 196 | "statistics": { 197 | "sizeOnDisk": 9000, 198 | "episodeCount": 0, 199 | "episodeFileCount": 0, 200 | "totalEpisodeCount": 8, 201 | } 202 | }] 203 | } 204 | )); 205 | then.json_body(json!({})); 206 | }) 207 | .await; 208 | 209 | let command_mock = server 210 | .mock_async(|when, then| { 211 | when.path("/pathprefix/api/v3/command") 212 | .method(POST) 213 | .json_body(json!({ 214 | "name": "SeasonSearch", 215 | "seriesId": 1234, 216 | "seasonNumber": 2, 217 | })); 218 | then.json_body(json!({})); 219 | }) 220 | .await; 221 | 222 | let (tx, rx) = mpsc::channel(1); 223 | let sonarr = crate::sonarr::Client::new(&server.url("/pathprefix"), "secret")?; 224 | tokio::spawn(async move { 225 | super::Actor::new(rx, sonarr, once::Seen::default(), 2) 226 | .process() 227 | .await; 228 | }); 229 | 230 | tx.send(Message::NowPlaying(NowPlaying { 231 | series: Series::Title("TestShow".to_string()), 232 | episode: 7, 233 | season: 1, 234 | ..np_default() 235 | })) 236 | .await?; 237 | 238 | tokio::time::sleep(Duration::from_millis(500)).await; 239 | 240 | series_mock.assert_async().await; 241 | put_series_mock.assert_async().await; 242 | command_mock.assert_async().await; 243 | 244 | Ok(()) 245 | } 246 | 247 | #[tokio::test] 248 | async fn monitor() -> Result<(), Box> { 249 | let server = httpmock::MockServer::start_async().await; 250 | 251 | let series_mock = server 252 | .mock_async(|when, then| { 253 | when.path("/pathprefix/api/v3/series"); 254 | then.json_body(serde_json::json!( 255 | [{ 256 | "id": 1234, 257 | "title": "TestShow", 258 | "tvdbId": 5678, 259 | "monitored": false, 260 | "monitorNewItems": "all", 261 | "seasons": [{ 262 | "seasonNumber": 1, 263 | "monitored": true, 264 | "statistics": { 265 | "sizeOnDisk": 9000, 266 | "episodeCount": 8, 267 | "episodeFileCount": 8, 268 | "totalEpisodeCount": 8, 269 | } 270 | }] 271 | } 272 | ] 273 | )); 274 | }) 275 | .await; 276 | 277 | let put_series_mock = server 278 | .mock_async(|when, then| { 279 | when.path("/pathprefix/api/v3/series/1234") 280 | .method(PUT) 281 | .json_body(serde_json::json!( 282 | { 283 | "id": 1234, 284 | "title": "TestShow", 285 | "tvdbId": 5678, 286 | "monitored": true, 287 | "monitorNewItems": "all", 288 | "seasons": [{ 289 | "seasonNumber": 1, 290 | "monitored": true, 291 | "statistics": { 292 | "sizeOnDisk": 9000, 293 | "episodeCount": 8, 294 | "episodeFileCount": 8, 295 | "totalEpisodeCount": 8, 296 | } 297 | }] 298 | } 299 | )); 300 | then.json_body(json!({})); 301 | }) 302 | .await; 303 | 304 | let (tx, rx) = mpsc::channel(1); 305 | let sonarr = crate::sonarr::Client::new(&server.url("/pathprefix"), "secret")?; 306 | tokio::spawn(async move { 307 | super::Actor::new(rx, sonarr, once::Seen::default(), 2) 308 | .process() 309 | .await; 310 | }); 311 | 312 | tx.send(Message::NowPlaying(NowPlaying { 313 | series: Series::Tvdb(5678), 314 | episode: 7, 315 | season: 1, 316 | ..np_default() 317 | })) 318 | .await?; 319 | 320 | tokio::time::sleep(Duration::from_millis(500)).await; 321 | 322 | series_mock.assert_async().await; 323 | put_series_mock.assert_async().await; 324 | 325 | Ok(()) 326 | } 327 | 328 | #[tokio::test] 329 | async fn pilot() -> Result<(), Box> { 330 | let server = httpmock::MockServer::start_async().await; 331 | 332 | let series_mock = server 333 | .mock_async(|when, then| { 334 | when.path("/pathprefix/api/v3/series"); 335 | then.json_body(serde_json::json!( 336 | [{ 337 | "id": 1234, 338 | "title": "TestShow", 339 | "tvdbId": 5678, 340 | "monitored": false, 341 | "monitorNewItems": "all", 342 | "seasons": [{ 343 | "seasonNumber": 1, 344 | "monitored": true, 345 | "statistics": { 346 | "sizeOnDisk": 9000, 347 | "episodeCount": 8, 348 | "episodeFileCount": 1, 349 | "totalEpisodeCount": 8, 350 | } 351 | }] 352 | } 353 | ] 354 | )); 355 | }) 356 | .await; 357 | 358 | let put_series_mock = server 359 | .mock_async(|when, then| { 360 | when.path("/pathprefix/api/v3/series/1234") 361 | .method(PUT) 362 | .json_body(serde_json::json!( 363 | { 364 | "id": 1234, 365 | "title": "TestShow", 366 | "tvdbId": 5678, 367 | "monitored": true, 368 | "monitorNewItems": "all", 369 | "seasons": [{ 370 | "seasonNumber": 1, 371 | "monitored": true, 372 | "statistics": { 373 | "sizeOnDisk": 9000, 374 | "episodeCount": 8, 375 | "episodeFileCount": 1, 376 | "totalEpisodeCount": 8, 377 | } 378 | }] 379 | } 380 | )); 381 | then.json_body(json!({})); 382 | }) 383 | .await; 384 | 385 | let command_mock = server 386 | .mock_async(|when, then| { 387 | when.path("/pathprefix/api/v3/command") 388 | .method(POST) 389 | .json_body(json!({ 390 | "name": "SeasonSearch", 391 | "seriesId": 1234, 392 | "seasonNumber": 1, 393 | })); 394 | then.json_body(json!({})); 395 | }) 396 | .await; 397 | 398 | let (tx, rx) = mpsc::channel(1); 399 | let sonarr = crate::sonarr::Client::new(&server.url("/pathprefix"), "secret")?; 400 | tokio::spawn(async move { 401 | super::Actor::new(rx, sonarr, once::Seen::default(), 2) 402 | .process() 403 | .await; 404 | }); 405 | 406 | tx.send(Message::NowPlaying(NowPlaying { 407 | series: Series::Title("TestShow".to_string()), 408 | episode: 1, 409 | season: 1, 410 | ..np_default() 411 | })) 412 | .await?; 413 | 414 | tokio::time::sleep(Duration::from_millis(500)).await; 415 | 416 | series_mock.assert_async().await; 417 | put_series_mock.assert_async().await; 418 | command_mock.assert_async().await; 419 | 420 | Ok(()) 421 | } 422 | } 423 | -------------------------------------------------------------------------------- /src/sonarr.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Result, anyhow}; 2 | use reqwest::{ 3 | Url, 4 | header::{HeaderMap, HeaderValue}, 5 | }; 6 | use rustls_platform_verifier::ConfigVerifierExt; 7 | use serde::{Deserialize, Serialize, de::DeserializeOwned}; 8 | use serde_json::{Value, json}; 9 | use tracing::debug; 10 | 11 | #[derive(Clone)] 12 | pub struct Client { 13 | base_url: Url, 14 | client: reqwest::Client, 15 | } 16 | 17 | impl Client { 18 | pub fn new(base_url: &str, api_key: &str) -> Result { 19 | let mut api_key = HeaderValue::from_str(api_key)?; 20 | api_key.set_sensitive(true); 21 | let mut headers = HeaderMap::new(); 22 | headers.insert("X-Api-Key", api_key); 23 | headers.insert( 24 | reqwest::header::ACCEPT, 25 | HeaderValue::from_static("application/json"), 26 | ); 27 | 28 | let client = reqwest::Client::builder() 29 | .default_headers(headers) 30 | .use_preconfigured_tls(rustls::ClientConfig::with_platform_verifier()) 31 | .build()?; 32 | 33 | let base_url = base_url.parse()?; 34 | 35 | Ok(Self { base_url, client }) 36 | } 37 | 38 | async fn get(&self, path: &str) -> Result { 39 | let mut url = self.base_url.clone(); 40 | url.path_segments_mut() 41 | .map_err(|()| anyhow!("url is relative"))? 42 | .push("api") 43 | .push("v3") 44 | .extend(path.split('/')); 45 | let response = self.client.get(url).send().await?.error_for_status()?; 46 | Ok(response.json::().await?) 47 | } 48 | 49 | pub async fn probe(&self) -> Result<()> { 50 | let mut url = self.base_url.clone(); 51 | url.path_segments_mut() 52 | .map_err(|()| anyhow!("url is relative"))? 53 | .push("api"); 54 | self.client.get(url).send().await?.error_for_status()?; 55 | Ok(()) 56 | } 57 | 58 | pub async fn put_series(&self, series: &SeriesResource) -> Result { 59 | let mut url = self.base_url.clone(); 60 | url.path_segments_mut() 61 | .map_err(|()| anyhow!("url is relative"))? 62 | .push("api") 63 | .push("v3") 64 | .push("series") 65 | .push(&series.id.to_string()); 66 | let response = self 67 | .client 68 | .put(url) 69 | .json(series) 70 | .send() 71 | .await? 72 | .error_for_status()?; 73 | Ok(response.json().await?) 74 | } 75 | 76 | pub async fn series(&self) -> Result> { 77 | let series = self 78 | .get::("series") 79 | .await? 80 | .as_array() 81 | .ok_or_else(|| anyhow!("not an array"))? 82 | .iter() 83 | .filter_map(|s| match serde_json::from_value(s.clone()) { 84 | Ok(v) => Some(v), 85 | Err(e) => { 86 | debug!(series=?s, "ignoring malformed series entry: {e}"); 87 | None 88 | } 89 | }) 90 | .collect::>(); 91 | Ok(series) 92 | } 93 | 94 | pub async fn search_season( 95 | &self, 96 | series: &SeriesResource, 97 | season_num: i32, 98 | ) -> Result { 99 | let series_monitored = series.monitored; 100 | 101 | let mut series = series.clone(); 102 | let season = series 103 | .season_mut(season_num) 104 | .ok_or_else(|| anyhow!("there is no season {season_num}"))?; 105 | 106 | if !season.monitored || !series_monitored { 107 | season.monitored = true; 108 | series.monitored = true; 109 | self.put_series(&series).await?; 110 | } 111 | 112 | let cmd = json!({ 113 | "name": "SeasonSearch", 114 | "seriesId": series.id, 115 | "seasonNumber": season_num, 116 | }); 117 | 118 | let mut url = self.base_url.clone(); 119 | url.path_segments_mut() 120 | .map_err(|()| anyhow!("url is relative"))? 121 | .push("api") 122 | .push("v3") 123 | .push("command"); 124 | 125 | let response = self 126 | .client 127 | .post(url) 128 | .json(&cmd) 129 | .send() 130 | .await? 131 | .error_for_status()?; 132 | 133 | Ok(response.json().await?) 134 | } 135 | } 136 | 137 | #[derive(Clone, Debug, Serialize, Deserialize)] 138 | #[serde(rename_all = "camelCase")] 139 | pub struct SeasonStatisticsResource { 140 | pub size_on_disk: i64, 141 | pub episode_count: i32, 142 | pub episode_file_count: i32, 143 | pub total_episode_count: i32, 144 | #[serde(flatten)] 145 | other: serde_json::Value, 146 | } 147 | 148 | #[derive(Clone, Debug, Serialize, Deserialize)] 149 | #[serde(rename_all = "camelCase")] 150 | pub struct SeasonResource { 151 | pub season_number: i32, 152 | pub monitored: bool, 153 | pub statistics: Option, 154 | #[serde(flatten)] 155 | other: serde_json::Value, 156 | } 157 | 158 | impl SeasonResource { 159 | pub fn last_episode(&self) -> Option { 160 | self.statistics.as_ref().map(|s| s.total_episode_count) 161 | } 162 | } 163 | 164 | #[derive(Clone, Debug, Serialize, Deserialize)] 165 | #[serde(rename_all = "camelCase")] 166 | pub enum NewItemMonitorTypes { 167 | All, 168 | None, 169 | } 170 | 171 | #[derive(Clone, Debug, Serialize, Deserialize)] 172 | #[serde(rename_all = "camelCase")] 173 | pub struct SeriesResource { 174 | pub id: i32, 175 | pub title: Option, 176 | pub tvdb_id: i32, 177 | pub monitored: bool, 178 | // optional for v3 compatibility 179 | pub monitor_new_items: Option, 180 | pub seasons: Vec, 181 | #[serde(flatten)] 182 | other: serde_json::Value, 183 | } 184 | 185 | impl SeriesResource { 186 | pub fn season(&self, num: i32) -> Option<&SeasonResource> { 187 | self.seasons.iter().find(|s| s.season_number == num) 188 | } 189 | 190 | pub fn season_mut(&mut self, num: i32) -> Option<&mut SeasonResource> { 191 | self.seasons.iter_mut().find(|s| s.season_number == num) 192 | } 193 | } 194 | 195 | #[cfg(test)] 196 | mod test { 197 | use httpmock::Method::{POST, PUT}; 198 | use serde_json::{Value, json}; 199 | 200 | use crate::sonarr::{ 201 | NewItemMonitorTypes, SeasonResource, SeasonStatisticsResource, SeriesResource, 202 | }; 203 | 204 | #[tokio::test] 205 | async fn auth() -> Result<(), Box> { 206 | let server = httpmock::MockServer::start_async().await; 207 | 208 | let series_mock = server 209 | .mock_async(|when, then| { 210 | when.path("/pathprefix/api/v3/series") 211 | .header("X-Api-Key", "secret"); 212 | then.json_body(serde_json::json!([])); 213 | }) 214 | .await; 215 | let client = super::Client::new(&server.url("/pathprefix"), "secret")?; 216 | 217 | let _ = client.series().await?; 218 | 219 | series_mock.assert_async().await; 220 | 221 | Ok(()) 222 | } 223 | 224 | #[tokio::test] 225 | async fn series_v3() -> Result<(), Box> { 226 | let server = httpmock::MockServer::start_async().await; 227 | 228 | let series_mock = server 229 | .mock_async(|when, then| { 230 | when.path("/pathprefix/api/v3/series"); 231 | then.json_body(serde_json::json!( 232 | [{ 233 | "id": 1234, 234 | "title": "TestShow", 235 | "tvdbId": 5678, 236 | "monitored": false, 237 | "seasons": [] 238 | }] 239 | )); 240 | }) 241 | .await; 242 | let client = super::Client::new(&server.url("/pathprefix"), "secret")?; 243 | 244 | let series = client.series().await?; 245 | assert_eq!(series[0].id, 1234); 246 | 247 | series_mock.assert_async().await; 248 | 249 | Ok(()) 250 | } 251 | 252 | #[tokio::test] 253 | async fn series_multiple() -> Result<(), Box> { 254 | let server = httpmock::MockServer::start_async().await; 255 | 256 | let series_mock = server 257 | .mock_async(|when, then| { 258 | when.path("/pathprefix/api/v3/series"); 259 | then.json_body(serde_json::json!( 260 | [{ 261 | "id": 1234, 262 | "title": "TestShow", 263 | "tvdbId": 5678, 264 | "monitored": false, 265 | "monitorNewItems": "all", 266 | "seasons": [] 267 | },{ 268 | "id": 1234, 269 | "title": "TestShow", 270 | "tvdbId": 5678, 271 | "monitored": false, 272 | "monitorNewItems": "all", 273 | "seasons": [] 274 | }] 275 | )); 276 | }) 277 | .await; 278 | let client = super::Client::new(&server.url("/pathprefix"), "secret")?; 279 | 280 | let series = client.series().await?; 281 | assert_eq!(series.len(), 2); 282 | 283 | series_mock.assert_async().await; 284 | 285 | Ok(()) 286 | } 287 | 288 | #[tokio::test] 289 | async fn series_parse_missing_statistics() -> Result<(), Box> { 290 | let server = httpmock::MockServer::start_async().await; 291 | 292 | let series_mock = server 293 | .mock_async(|when, then| { 294 | when.path("/pathprefix/api/v3/series"); 295 | then.json_body(serde_json::json!( 296 | [{ 297 | "id": 1234, 298 | "title": "TestShow", 299 | "tvdbId": 5678, 300 | "monitored": false, 301 | "monitorNewItems": "all", 302 | "seasons": [{ 303 | "seasonNumber": 0, 304 | "monitored": false 305 | }] 306 | }] 307 | )); 308 | }) 309 | .await; 310 | let client = super::Client::new(&server.url("/pathprefix"), "secret")?; 311 | 312 | let series = client.series().await?; 313 | assert_eq!(series.len(), 1); 314 | 315 | series_mock.assert_async().await; 316 | 317 | Ok(()) 318 | } 319 | 320 | #[tokio::test] 321 | async fn series_skip_malformed_series() -> Result<(), Box> { 322 | let server = httpmock::MockServer::start_async().await; 323 | 324 | let series_mock = server 325 | .mock_async(|when, then| { 326 | when.path("/pathprefix/api/v3/series"); 327 | then.json_body(serde_json::json!( 328 | [{ 329 | "invalid": "TestShow", 330 | },{ 331 | "id": 1234, 332 | "title": "TestShow", 333 | "tvdbId": 5678, 334 | "monitored": false, 335 | "monitorNewItems": "all", 336 | "seasons": [] 337 | }] 338 | )); 339 | }) 340 | .await; 341 | let client = super::Client::new(&server.url("/pathprefix"), "secret")?; 342 | 343 | let series = client.series().await?; 344 | assert_eq!(series.len(), 1); 345 | 346 | series_mock.assert_async().await; 347 | 348 | Ok(()) 349 | } 350 | 351 | #[tokio::test] 352 | async fn series_emtpy() -> Result<(), Box> { 353 | let server = httpmock::MockServer::start_async().await; 354 | 355 | let series_mock = server 356 | .mock_async(|when, then| { 357 | when.path("/pathprefix/api/v3/series"); 358 | then.json_body(serde_json::json!([])); 359 | }) 360 | .await; 361 | let client = super::Client::new(&server.url("/pathprefix"), "secret")?; 362 | 363 | let series = client.series().await?; 364 | assert_eq!(series.len(), 0); 365 | 366 | series_mock.assert_async().await; 367 | 368 | Ok(()) 369 | } 370 | 371 | #[tokio::test] 372 | async fn put_series() -> Result<(), Box> { 373 | let server = httpmock::MockServer::start_async().await; 374 | 375 | let series = SeriesResource { 376 | id: 1234, 377 | title: Some("TestShow".to_string()), 378 | tvdb_id: 5678, 379 | monitored: false, 380 | monitor_new_items: Some(NewItemMonitorTypes::All), 381 | seasons: vec![], 382 | other: Value::Null, 383 | }; 384 | 385 | let series_mock = server 386 | .mock_async(|when, then| { 387 | when.path("/pathprefix/api/v3/series/1234") 388 | .method(PUT) 389 | .json_body(serde_json::json!( 390 | { 391 | "id": 1234, 392 | "title": "TestShow", 393 | "tvdbId": 5678, 394 | "monitored": false, 395 | "monitorNewItems": "all", 396 | "seasons": [] 397 | } 398 | )); 399 | then.json_body(json!({})); 400 | }) 401 | .await; 402 | let client = super::Client::new(&server.url("/pathprefix"), "secret")?; 403 | 404 | client.put_series(&series).await?; 405 | 406 | series_mock.assert_async().await; 407 | 408 | Ok(()) 409 | } 410 | 411 | #[tokio::test] 412 | async fn search_season() -> Result<(), Box> { 413 | let server = httpmock::MockServer::start_async().await; 414 | 415 | let season = SeasonResource { 416 | season_number: 1, 417 | monitored: false, 418 | statistics: SeasonStatisticsResource { 419 | size_on_disk: 9000, 420 | episode_count: 8, 421 | episode_file_count: 8, 422 | total_episode_count: 0, 423 | other: Value::Null, 424 | } 425 | .into(), 426 | other: Value::Null, 427 | }; 428 | 429 | let series = SeriesResource { 430 | id: 1234, 431 | title: Some("TestShow".to_string()), 432 | tvdb_id: 5678, 433 | monitored: false, 434 | monitor_new_items: Some(NewItemMonitorTypes::All), 435 | seasons: vec![season], 436 | other: serde_json::json!({}), 437 | }; 438 | 439 | let command_mock = server 440 | .mock_async(|when, then| { 441 | when.path("/pathprefix/api/v3/command") 442 | .method(POST) 443 | .json_body(json!({ 444 | "name": "SeasonSearch", 445 | "seriesId": 1234, 446 | "seasonNumber": 1, 447 | })); 448 | then.json_body(json!({})); 449 | }) 450 | .await; 451 | 452 | let series_mock = server 453 | .mock_async(|when, then| { 454 | when.path("/pathprefix/api/v3/series/1234") 455 | .method(PUT) 456 | .json_body(serde_json::json!( 457 | { 458 | "id": 1234, 459 | "title": "TestShow", 460 | "tvdbId": 5678, 461 | "monitored": true, 462 | "monitorNewItems": "all", 463 | "seasons": [{ 464 | "seasonNumber": 1, 465 | "monitored": true, 466 | "statistics": { 467 | "sizeOnDisk": 9000, 468 | "episodeCount": 8, 469 | "episodeFileCount": 8, 470 | "totalEpisodeCount": 0, 471 | } 472 | }] 473 | } 474 | )); 475 | then.json_body(json!({})); 476 | }) 477 | .await; 478 | let client = super::Client::new(&server.url("/pathprefix"), "secret")?; 479 | 480 | client.search_season(&series, 1).await?; 481 | 482 | series_mock.assert_async().await; 483 | command_mock.assert_async().await; 484 | 485 | Ok(()) 486 | } 487 | } 488 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | use std::{future::Future, time::Duration}; 2 | 3 | use tokio::time::sleep; 4 | use tracing::{error, info}; 5 | 6 | pub(crate) mod once; 7 | 8 | #[cfg(not(test))] 9 | fn duration(t: u64) -> Duration { 10 | Duration::from_secs(t) 11 | } 12 | #[cfg(test)] 13 | fn duration(t: u64) -> Duration { 14 | Duration::from_millis(t * 100) 15 | } 16 | 17 | pub async fn retry(retries: usize, mut f: U) -> Result 18 | where 19 | E: std::fmt::Display, 20 | F: Future>, 21 | U: FnMut() -> F, 22 | { 23 | let mut last_err = None; 24 | for i in 0..=retries { 25 | if last_err.is_some() { 26 | let secs = 2u64.saturating_pow(u32::try_from(i).unwrap_or(u32::MAX)); 27 | info!("Retry after {secs} seconds"); 28 | sleep(duration(secs)).await; 29 | } 30 | match f().await { 31 | res @ Ok(_) => return res, 32 | Err(err) => { 33 | error!("{err:#}"); 34 | last_err = Some(err); 35 | } 36 | } 37 | } 38 | Err(last_err.expect("last retry reached after error")) 39 | } 40 | 41 | #[cfg(test)] 42 | mod test { 43 | use std::future::ready; 44 | 45 | use super::*; 46 | 47 | const OK: Result<(), &'static str> = Ok(()); 48 | const ERR: Result<(), &'static str> = Err("err"); 49 | const EPSILON: u64 = 1; 50 | 51 | #[tokio::test] 52 | async fn zero_retries_success() { 53 | let earlier = std::time::Instant::now(); 54 | retry(0, || ready(OK)).await.unwrap(); 55 | let t = std::time::Instant::now().duration_since(earlier); 56 | assert!(t < duration(EPSILON)); 57 | } 58 | 59 | #[tokio::test] 60 | async fn zero_retries_fail() { 61 | let earlier = std::time::Instant::now(); 62 | retry(0, || ready(ERR)).await.unwrap_err(); 63 | let t = std::time::Instant::now().duration_since(earlier); 64 | assert!(t < duration(EPSILON)); 65 | } 66 | 67 | #[tokio::test] 68 | async fn three_retries_early_success() { 69 | let mut failures = 2usize; 70 | let make_fut = || { 71 | if failures == 0 { 72 | ready(OK) 73 | } else { 74 | failures -= 1; 75 | ready(ERR) 76 | } 77 | }; 78 | let earlier = std::time::Instant::now(); 79 | retry(3, make_fut).await.unwrap(); 80 | let t = std::time::Instant::now().duration_since(earlier); 81 | assert!(t >= duration(2 + 4)); 82 | assert!(t < duration(2 + 4 + EPSILON)); 83 | } 84 | 85 | #[tokio::test] 86 | async fn three_retries_fail() { 87 | let earlier = std::time::Instant::now(); 88 | retry(3, || ready(ERR)).await.unwrap_err(); 89 | let t = std::time::Instant::now().duration_since(earlier); 90 | assert!(t >= duration(2 + 4 + 8)); 91 | assert!(t < duration(2 + 4 + 8 + EPSILON)); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/util/once.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | collections::HashSet, 3 | hash::Hash, 4 | time::{Duration, Instant}, 5 | }; 6 | 7 | use crate::media_server::Series; 8 | 9 | const RETAIN_DURATION: Duration = Duration::from_secs(60 * 60 * 24 * 7); 10 | 11 | #[derive(PartialEq, Eq, Hash)] 12 | struct Season { 13 | series: Series, 14 | season: i32, 15 | } 16 | 17 | struct Entry { 18 | season: Season, 19 | touched: Instant, 20 | } 21 | 22 | impl Entry { 23 | fn new(series: Series, season: i32) -> Self { 24 | Self { 25 | season: Season { series, season }, 26 | touched: Instant::now(), 27 | } 28 | } 29 | } 30 | 31 | impl Eq for Entry {} 32 | 33 | impl PartialEq for Entry { 34 | fn eq(&self, other: &Entry) -> bool { 35 | ::eq(&self.season, &other.season) 36 | } 37 | } 38 | 39 | impl Hash for Entry { 40 | fn hash(&self, ra_expand_state: &mut H) { 41 | ::hash(&self.season, ra_expand_state); 42 | } 43 | } 44 | 45 | #[derive(Default)] 46 | pub struct Seen(HashSet); 47 | 48 | impl Seen { 49 | pub fn once(&mut self, series: Series, season: i32) -> bool { 50 | self.prune(); 51 | self.0.replace(Entry::new(series, season)).is_none() 52 | } 53 | 54 | fn prune(&mut self) { 55 | self.0 56 | .retain(|e| Instant::now().saturating_duration_since(e.touched) <= RETAIN_DURATION); 57 | } 58 | } 59 | 60 | #[cfg(test)] 61 | mod test { 62 | use std::time::{Duration, Instant}; 63 | 64 | use crate::{ 65 | media_server::Series, 66 | util::once::{Entry, Seen}, 67 | }; 68 | 69 | #[test] 70 | fn twice() { 71 | let mut seen = Seen::default(); 72 | let series = Series::Tvdb(1); 73 | let season = 3; 74 | assert!(seen.once(series.clone(), season)); 75 | assert!(!seen.once(series, season)); 76 | } 77 | 78 | #[test] 79 | fn prune_old() { 80 | let mut seen = Seen::default(); 81 | let series = Series::Tvdb(1); 82 | let season = 3; 83 | 84 | let mut old = Entry::new(series.clone(), season); 85 | old.touched = Instant::now().checked_sub(super::RETAIN_DURATION).unwrap(); 86 | 87 | seen.0.replace(old); 88 | assert!(seen.once(series, season)); 89 | } 90 | 91 | #[test] 92 | fn touch() { 93 | let mut seen = Seen::default(); 94 | let series = Series::Tvdb(1); 95 | let season = 3; 96 | 97 | let mut old = Entry::new(series.clone(), season); 98 | old.touched = (Instant::now() + Duration::from_millis(100)) 99 | .checked_sub(super::RETAIN_DURATION) 100 | .unwrap(); 101 | 102 | seen.0.replace(old); 103 | assert!(!seen.once(series.clone(), season)); 104 | 105 | std::thread::sleep(Duration::from_millis(100)); 106 | assert!(!seen.once(series, season)); 107 | } 108 | 109 | #[test] 110 | fn different_season() { 111 | let mut seen = Seen::default(); 112 | let series = Series::Tvdb(1); 113 | assert!(seen.once(series.clone(), 1)); 114 | assert!(seen.once(series, 2)); 115 | } 116 | 117 | #[test] 118 | fn different_series() { 119 | let mut seen = Seen::default(); 120 | let season = 1; 121 | assert!(seen.once(Series::Tvdb(1), season)); 122 | assert!(seen.once(Series::Tvdb(2), season)); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /unraid/my-prefetcharr.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | prefetcharr 4 | phueber/prefetcharr:latest 5 | https://github.com/p-hueber/prefetcharr 6 | bridge 7 | 8 | sh 9 | false 10 | 11 | 12 | 13 | 14 | 15 | 16 | https://avatars.githubusercontent.com/u/45110470?v=4 17 | 18 | 19 | 20 | 1732836312 21 | 22 | 23 | 24 | plex 25 | 26 | 27 | 28 | 29 | /log 30 | prefetcharr=debug 31 | 900 32 | 2 33 | /mnt/user/appdata/prefetcharr/logs 34 | 35 | --------------------------------------------------------------------------------