From 68c972daddf0780729b01b11a0c47f8cec837564 Mon Sep 17 00:00:00 2001 From: junior Date: Sat, 17 Oct 2026 22:49:11 +0800 Subject: [PATCH] feat: implement RSS subscriptions, downloader routing and local media checks --- .env.example | 26 + .gitignore | 9 +- Cargo.lock | 3841 ++++++++++++++++++++++ Cargo.toml | 22 +- LICENSE | 339 ++ README.md | 259 ++ migrations/0001_initial.sql | 34 + migrations/0002_downloader_instances.sql | 18 + migrations/0003_media_destinations.sql | 37 + src/api.rs | 248 ++ src/config.rs | 106 + src/domain.rs | 160 + src/downloader.rs | 265 ++ src/error.rs | 47 + src/feeds.rs | 183 ++ src/lib.rs | 13 + src/main.rs | 189 +- src/media.rs | 269 ++ src/media_store.rs | 153 + src/mikan.rs | 460 +++ src/parser.rs | 89 + src/service.rs | 380 +++ src/storage.rs | 367 +++ tests/fixtures/releases.xml | 28 + tests/local_media.rs | 154 + tests/mikan.rs | 319 ++ tests/network.rs | 154 + tests/pipeline.rs | 1100 +++++++ 28 files changed, 9226 insertions(+), 43 deletions(-) create mode 100644 .env.example create mode 100644 LICENSE create mode 100644 README.md create mode 100644 migrations/0001_initial.sql create mode 100644 migrations/0002_downloader_instances.sql create mode 100644 migrations/0003_media_destinations.sql create mode 100644 src/api.rs create mode 100644 src/config.rs create mode 100644 src/domain.rs create mode 100644 src/downloader.rs create mode 100644 src/error.rs create mode 100644 src/feeds.rs create mode 100644 src/lib.rs create mode 100644 src/media.rs create mode 100644 src/media_store.rs create mode 100644 src/mikan.rs create mode 100644 src/parser.rs create mode 100644 src/service.rs create mode 100644 src/storage.rs create mode 100644 tests/fixtures/releases.xml create mode 100644 tests/local_media.rs create mode 100644 tests/mikan.rs create mode 100644 tests/network.rs create mode 100644 tests/pipeline.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d3f3163 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Copy values into your shell, or source a private .env file before cargo run. +# This application does not automatically load .env files. +APP_PORT=8080 +DATABASE_URL=sqlite://data/ani.db +POLL_INTERVAL_SECONDS=300 +RUST_LOG=info + +# Website requests (Mikan pages/covers, RSS and torrent metadata). +# Unset means direct; HTTP_PROXY/HTTPS_PROXY/ALL_PROXY/NO_PROXY are NOT inherited. +MIKAN_BASE_URL=https://mikanani.me +# WEBSITE_PROXY_URL=http://127.0.0.1:7897 +# WEBSITE_PROXY_URL=socks5h://127.0.0.1:7897 +# Optional URL-encoded credentials: http://username:password@host:port +# Comma-separated explicit direct exceptions; empty by default. +# WEBSITE_DIRECT_HOSTS=localhost,127.0.0.1,::1 +# qBittorrent control connections always remain direct. + +# Initial import only, when the database has no downloader instances. +# Existing instances and the global selection are managed through /api/downloaders. +# When upgrading, these MUST identify the original downloader of existing jobs. +# Unset on a fresh installation leaves jobs unassigned until first selection. +# QBIT_URL=http://127.0.0.1:8081 +# QBIT_USERNAME=admin +# QBIT_PASSWORD=replace-me +# Path as seen by qBittorrent, not by this Rust process. +# QBIT_SAVE_PATH=/downloads diff --git a/.gitignore b/.gitignore index d4ef7dd..b2cf7a7 100644 --- a/.gitignore +++ b/.gitignore @@ -51,4 +51,11 @@ Icon? desktop.ini # For Cargo documentation -/doc/ \ No newline at end of file +/doc/ + +# Local state and credentials +/data/ +.env +*.db +*.db-shm +*.db-wal diff --git a/Cargo.lock b/Cargo.lock index 9824aa0..b974d60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,3847 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-files" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + +[[package]] +name = "actix-http" +version = "3.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7926860314cbe2fb5d1f13731e387ab43bd32bca224e82e6e2db85de0a3dba49" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "base64 0.22.1", + "bitflags", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash", + "futures-core", + "h2", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.9.2", + "sha1", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn 2.0.116", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92589714878ca59a7626ea19734f0e07a6a875197eec751bb5d3f99e64998c63" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a65064ea4a457eaf07f2fba30b4c695bf43b721790e9530d26cb6f9019ff7502" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio", + "socket2 0.5.10", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1654a77ba142e37f049637a3e5685f864514af11fcbc51cb51eb6596afe5b8d6" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie 0.16.2", + "derive_more", + "encoding_rs", + "foldhash", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2 0.6.2", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bendy" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8133e404c8bec821e531f347dab1247bf64f60882826e7228f8ffeb33a35a658" +dependencies = [ + "failure", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytestring" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +dependencies = [ + "bytes", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie 0.18.2", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.116", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.116", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "ego-tree" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "failure" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d32e9bd16cc02eae7db7ef620b392808b89f6a5e16bb3497d159c6b92a0f4f86" +dependencies = [ + "backtrace", + "failure_derive", +] + +[[package]] +name = "failure_derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4da3c766cd7a0db8242e326e9e4e081edd567072893ed320008189715366a4" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "synstructure 0.12.6", +] + +[[package]] +name = "feed-rs" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "369995dae0733f1fe5ab0e3f345f6503a5f384179df5d8da333702031a131cf9" +dependencies = [ + "chrono", + "mediatype", + "quick-xml", + "regex", + "serde", + "serde_json", + "siphasher", + "url", + "uuid", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http 1.5.0", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http 1.5.0", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "impl-more" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "mediatype" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" +dependencies = [ + "serde", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.8", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "encoding_rs", + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie 0.18.2", + "cookie_store", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust_project" version = "0.1.0" +dependencies = [ + "actix-files", + "actix-web", + "bendy", + "cap-std", + "data-encoding", + "feed-rs", + "regex", + "reqwest", + "scraper", + "serde", + "serde_json", + "sha1", + "sqlx", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scraper" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f3a24d916e78954af99281a455168d4a9515d65eca99a18da1b813689c4ad9" +dependencies = [ + "cssparser", + "ego-tree", + "getopts", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "selectors" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5685b6ae43bfcf7d2e7dfcfb5d8e8f61b46442c902531e41a32a9a8bf0ee0fb6" +dependencies = [ + "bitflags", + "cssparser", + "derive_more", + "fxhash", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.116", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-sqlite", + "syn 2.0.116", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-xid", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.5.0", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.52.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", + "synstructure 0.13.2", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", + "synstructure 0.13.2", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.116", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 1fdb625..861eb59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,26 @@ [package] name = "rust_project" version = "0.1.0" -edition = "2024" +edition = "2021" +license = "GPL-2.0-only" +description = "A Rust RSS subscription and download automation service" [dependencies] +actix-web = "4.9" +actix-files = "0.6" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = ["sync", "time", "signal", "rt-multi-thread", "macros"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "cookies", "multipart", "socks"] } +scraper = "0.24" +cap-std = "4.0" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "derive", "migrate", "macros"] } +feed-rs = "2.3" +regex = "1" +uuid = { version = "1", features = ["v4"] } +thiserror = "2" +sha1 = "0.10" +bendy = "0.3" +data-encoding = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..545e311 --- /dev/null +++ b/README.md @@ -0,0 +1,259 @@ +# Rust RSS 追番服务 + +使用 Rust 独立设计实现的 RSS 订阅与下载自动化项目,采用 **GPL-2.0-only**。 +功能方向参考 [ani-rss](https://github.com/wushuo894/ani-rss),没有移植其 Java 业务代码或前端资源;本项目不是上游的官方 Rust 版本。 + +当前为后端 MVP:蜜柑搜索 / 手动 RSS → 订阅管理 → RSS/Atom 预览 → 持久化任务 → qBittorrent 提交与状态同步。 +现有 Actix 示例接口继续保留。 + +## 运行 + +```sh +cargo run --locked +``` + +默认监听 `127.0.0.1:8080`,自动创建 `data/ani.db` 并运行数据库迁移。 +当前 API 供本机使用,没有用户认证;监听地址固定为回环地址。 +首次运行需要下载 Cargo 依赖。已在 Rust 1.93.1 上进行编译验证。 + +按需配置环境变量,参见 [.env.example](.env.example)。程序不自动读取 `.env`: + +```sh +export QBIT_URL='http://127.0.0.1:8081' +export QBIT_USERNAME='admin' +export QBIT_PASSWORD='你的密码' +export QBIT_SAVE_PATH='/downloads' +cargo run --locked +``` + +qBittorrent 需要启用 Web UI。`QBIT_SAVE_PATH` 是下载器看到的路径。 +`QBIT_*` 只在数据库尚无下载器实例时导入一次,并选为全局下载器。已有实例时,重启或修改这些环境变量不会覆盖保存的实例或选择。 +全新安装不设置 `QBIT_URL` 时,仍可管理订阅、预览和生成未分配任务;首次通过接口选中下载器后绑定这些任务。清空环境变量不会停用数据库中已选中的下载器。 +程序启动后立即执行一轮检查,之后默认每 300 秒检查一次;`POLL_INTERVAL_SECONDS` 最小为 10。 + +## 使用流程 + +### 从蜜柑发现订阅 + +```sh +curl -G 'http://127.0.0.1:8080/api/sources/mikan/search' --data-urlencode 'q=葬送' +curl 'http://127.0.0.1:8080/api/sources/mikan/series/3141' +curl 'http://127.0.0.1:8080/api/sources/mikan/resolve' \ + -H 'Content-Type: application/json' \ + -d '{"url":"https://mikanani.me/Home/Bangumi/3141"}' +``` + +搜索返回各季度的独立候选及关键词 RSS。详情返回整部番剧 RSS、字幕组 RSS、站点信息和可用封面地址。 +将详情中的字幕组 `rss_url` 传给 `resolve`,可以获得对应字幕组候选。 +`resolve` 返回的 `subscription` 对象可直接作为下面预览、保存接口的请求体;默认禁用,`season=1` 只是初始值,需核对季度、偏移和筛选规则。 +候选中的来源 ID、字幕组 ID 和范围用于发现流程;当前订阅保存标题、RSS 与规则,尚不单独持久化这些来源元数据。 + +关键词 RSS 可能跨多部作品,`resolve` 返回 `scope=keyword`、提示和 RSS 地址,`subscription=null`,不会生成单番剧草稿。 +建议选择具体番剧;若手工使用关键词 RSS,需先用规则将内容限定为一部作品并预览。 +搜索、查看详情、解析链接和预览均不创建下载任务。发布条目通过 RSS 预览获取,不从详情 HTML 重复解析。 +这阶段提供后端 API,尚无搜索管理页面或蜜柑账号同步。 + +### 网站代理 + +```sh +export WEBSITE_PROXY_URL='http://127.0.0.1:7897' +# 或 socks5h://127.0.0.1:7897,让代理解析目标域名 +export MIKAN_BASE_URL='https://mikanani.me' +cargo run --locked +``` + +网站客户端支持 HTTP、HTTPS、SOCKS5 和 SOCKS5H 代理 URL,可在 URL 内携带编码后的用户名和密码。 +`socks5` 本地解析目标域名,`socks5h` 由代理解析。配置在启动时读取,修改后重启。 +**不继承** `HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY` 或 `NO_PROXY`;未设置 `WEBSITE_PROXY_URL` 时直连。 +可用 `WEBSITE_DIRECT_HOSTS` 设置逗号分隔的显式直连域名/IP/CIDR,例如 `localhost,127.0.0.1,::1`,默认没有例外。 +每次重定向重新应用代理及例外;代理失败返回错误,不自动直连。 + +蜜柑搜索、详情、封面、RSS 和种子元数据请求共用网站策略。封面返回本应用地址,由后端获取,浏览器无需直连蜜柑。 +当前仅识别蜜柑同源 `/images/` 下的封面,支持 PNG/JPEG/WebP/GIF;其他图片布局会显示无封面。 +qBittorrent 控制连接独立且始终直连;BT 传输由下载器控制。容器中的 `127.0.0.1` 指容器自身。 +网站基址必须是无路径、查询参数或凭据的 HTTP(S) origin;粘贴解析仅接受该站点的公开搜索/番剧页面或 RSS。 + +### 预览、保存和启用 + +1. 预览真实的 RSS 地址,查看匹配结果。预览不会保存订阅或提交下载。 + +```sh +curl -s http://127.0.0.1:8080/api/preview \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "示例番剧", + "rss_url": "https://example.com/anime.xml", + "season": 1, + "episode_offset": 0, + "include": ["1080p"], + "exclude": ["合集"], + "enabled": false + }' +``` + +2. 将相同 JSON 提交到 `POST /api/subscriptions` 保存订阅。订阅默认禁用。 +3. 确认预览结果后,向 `PUT /api/subscriptions/{id}` 提交完整 JSON,将 `enabled` 改为 `true`。 +4. 等待自动检查,或使用 `POST /api/run` 立即执行一轮 RSS 检查和下载同步。 +5. 使用 `GET /api/jobs` 查看状态、失败原因和重试时间。 + +首次启用会考虑 RSS 当前返回的全部匹配条目,尚未实现“仅从当前最新集开始”。 +`include` 为不区分大小写的关键词“全部满足”,`exclude` 为“任意命中则排除”,不是正则表达式。 +修改订阅规则只影响后续扫描;已入队任务保留创建时的季度、集数和资源地址。禁用订阅会暂停未提交任务。 + +## API + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| GET | `/api/status` | 版本及下载器是否配置,不返回凭据 | +| GET / POST | `/api/downloaders` | 列出 / 创建下载器实例,响应不返回密码 | +| PUT | `/api/downloaders/active` | `{"downloader_id":"实例 ID"}` 选择接收新任务的全局下载器 | +| POST | `/api/downloaders/{id}/claim-legacy-jobs` | 将升级后尚无归属的旧任务明确关联到恢复的原实例 | +| GET / POST | `/api/destinations` | 列出 / 创建本地媒体目的地 | +| PUT | `/api/destinations/active` | `{"destination_id":"目的地 ID"}` 设置全局目的地 | +| GET / POST | `/api/destinations/{id}/mappings` | 查看历史映射 / 新增映射版本 | +| GET | `/api/destinations/{id}/entries?path=相对目录` | 只读浏览目录,省略 path 为根目录 | +| GET | `/api/sources/mikan/search?q=关键词` | 番剧搜索和关键词 RSS | +| GET | `/api/sources/mikan/series/{id}` | 番剧、字幕组及对应 RSS | +| GET | `/api/sources/mikan/series/{id}/cover` | 经网站网络策略获取封面 | +| POST | `/api/sources/mikan/resolve` | `{"url":"蜜柑地址"}` → 订阅候选 | +| POST | `/api/preview` | 临时预览,不写库、不下载 | +| GET / POST | `/api/subscriptions` | 列出 / 创建订阅 | +| GET / PUT / DELETE | `/api/subscriptions/{id}` | 查询 / 完整替换 / 删除订阅 | +| GET | `/api/subscriptions/{id}/preview` | 预览已保存订阅,包含去重判断 | +| POST | `/api/subscriptions/{id}/scan` | 扫描并入队,要求订阅已启用 | +| GET | `/api/subscriptions/{id}/releases` | 最近最多 500 条扫描记录 | +| GET | `/api/jobs` | 最近最多 500 个任务 | +| GET | `/api/jobs/{id}/files` | 读取已保存的文件清单与核对状态 | +| POST | `/api/jobs/{id}/files/check` | 发现文件清单(首次)并核对本地文件是否存在 | +| POST | `/api/jobs/{id}/retry` | 手动重试 failed / missing 任务 | +| POST | `/api/run` | 执行完整扫描及下载同步 | + +后台处理与手动执行互斥;处理期间修改订阅或再次执行会返回 `409`,稍后重试即可。 +删除订阅为软删除,保留去重与任务记录,不删除下载器任务或媒体文件。已提交任务仍会同步状态。 + +## 下载器实例与切换 + +```sh +curl 'http://127.0.0.1:8080/api/downloaders' \ + -H 'Content-Type: application/json' \ + -d '{"name":"NAS qBittorrent","kind":"qbittorrent","connection":{"url":"http://nas:8081","username":"admin","password":"你的密码","save_path":"/downloads"}}' + +# 将返回的 id 填入下面的 downloader_id。 +curl -X PUT 'http://127.0.0.1:8080/api/downloaders/active' \ + -H 'Content-Type: application/json' -d '{"downloader_id":"返回的实例 ID"}' +``` + +创建实例不会自动切换全局选择。任务在扫描入队的同一个事务中保存 `downloader_id`;已有归属的排队任务、重试和重启恢复都使用原实例。 +切换后继续同步原实例;一个实例连接失败会报告该实例 ID,并继续处理其他实例,不把连接失败标成任务丢失。 +原实例的地址与写入路径不随全局选择改变。本阶段只实现多个 qBittorrent 实例,不提供实例编辑/删除;第二种工具与凭据维护接口另行扩展。 +实例凭据保存在 SQLite 数据库中,尚未加密;数据库及其备份应按含凭据的配置文件管理。列表和创建响应只返回 `password_configured`。 + +### 从旧版本升级 + +升级迁移为旧任务增加实例归属字段。首次导入时,应提供**旧任务实际使用的原 `QBIT_*` 配置**,应用会将它与旧任务关联;不能在升级时把这些值替换成另一台下载器。 +若升级时没有提供原配置,历史任务保留 `legacy_unassigned=true`,不会自动绑定以后新选中的实例;`/api/status` 的 `legacy_unassigned_jobs` 及运行报告会提示数量。 +恢复原实例后,显式调用 `POST /api/downloaders/{原实例ID}/claim-legacy-jobs` 才建立关联。该操作仅认领没有归属的旧任务,不迁移已有归属,也不立即提交下载;后续同步先核对原实例的任务。 + +## 全局媒体目的地与本地文件核对 + +先配置并选中下载器,再创建一个本地目的地,配置该下载器的写入路径映射,最后激活目的地: + +```sh +curl 'http://127.0.0.1:8080/api/destinations' \ + -H 'Content-Type: application/json' \ + -d '{"name":"番剧目录","kind":"local","root_path":"/mnt/nas/anime"}' + +curl 'http://127.0.0.1:8080/api/destinations/目的地ID/mappings' \ + -H 'Content-Type: application/json' \ + -d '{"downloader_id":"下载器ID","download_root":"/downloads/anime"}' + +curl -X PUT 'http://127.0.0.1:8080/api/destinations/active' \ + -H 'Content-Type: application/json' -d '{"destination_id":"目的地ID"}' +``` + +`root_path` 是 Rust 应用所在环境可访问的绝对目录;`download_root` 是同一个位置在下载器环境中的绝对路径。 +配置映射不会挂载、复制或上传文件,也不会创建这些目录。两个路径必须实际指向同一批文件。 +同一时刻所有新任务统一使用全局目的地,订阅不能覆盖。切换目的地或下载器前,必须存在所需映射,否则返回 `400`,不会静默使用默认目录。 + +每次 `POST mappings` 都追加一个版本;新任务使用最新版本,旧任务保留 `destination_id`、`mapping_id` 和 `download_save_path`。 +目的地读取根目录和已有映射版本保持不变;更改读取根需创建新目的地。即使任务尚未提交、后来重试或应用重启,也不重新解释其存储位置。 +同一下载器里已存在相同 hash,但保存路径与任务快照不一致时,任务报告冲突,不复用错误位置的下载、不自动移动种子。 + +未启用目的地时继续使用原下载器 `save_path`,保持旧配置可用。旧任务不会自动绑定后来创建的目的地;没有可靠位置的任务核对为 `unchecked`。本阶段没有旧任务目的地补录或文件迁移接口。 + +```sh +curl -G 'http://127.0.0.1:8080/api/destinations/目的地ID/entries' --data-urlencode 'path=Series' +curl -X POST 'http://127.0.0.1:8080/api/jobs/任务ID/files/check' +curl 'http://127.0.0.1:8080/api/jobs/任务ID/files' +``` + +首次核对向原下载器读取文件清单,并核实其保存路径与快照一致。清单使用 [qBittorrent 文件接口](https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-%28qBittorrent-5.0%29#get-torrent-contents) 提供的相对文件名和大小,排除优先级为 0 的文件;不从发布标题猜文件名。 +清单成功保存后,后续核对直接访问记录的本地路径,下载器离线也能执行。清单不会随下载器外部改名或移动而自动改写;本阶段不提供清单刷新或路径迁移。 +核对由上述 POST 接口显式触发,后台下载同步暂不自动扫描文件系统。 + +| 状态 | 含义 | +| --- | --- | +| `unchecked` | 尚未核对,或未记录目的地、种子元数据/已选文件尚不可用 | +| `present` | 成功访问并找到所有记录的普通文件 | +| `absent` | 成功访问根目录,至少一个记录的文件不存在 | +| `unavailable` | 根目录不可访问、权限不足、路径越界、映射不匹配或首次读取下载器清单失败 | + +汇总时 `unavailable` 优先于 `absent`;每个文件分别记录结果。响应含本次尝试时间 `checked_at` 和最后成功核对时间 `last_success_at`,失败不会抹去后者。 +存在状态独立于下载状态;文件缺失不会触发重下载或删除。返回预期/观察大小供查看,`present` 不保证大小一致或内容完整,也不将下载标记为完成。 +目录浏览最多返回 1000 项,达到上限时 `truncated=true`;单个任务清单上限 5000 项。不读取文件内容、不提供播放。 + +本地访问通过 [cap-std 的目录能力接口](https://docs.rs/cap-std/4.0.3/cap_std/fs/struct.Dir.html) 限定在配置根目录内。相对路径拒绝绝对地址、`..`、盘符和反斜杠;符号链接不能越出根目录。根目录若离线而仍保留可访问的空挂载点,本阶段不具备挂载状态识别能力。 +P2 仅支持进程可访问的本地目录。SMB 应用直连属于 P3,尚未实现。 + +## 当前规则与恢复行为 + +- 支持常见 `[03]`、`- 03`、`EP03`、`第03话`、`S01E03` 以及 `03v2` 标题。 +- 合集、分数集数、模糊或冲突的集数标记为 `needs_review`;目前可通过调整源或规则解决,尚无人工集数映射接口。 +- RSS enclosure、Atom enclosure、磁力链接及 `.torrent` 链接可作为资源入口;尚未覆盖所有站点私有 XML 扩展。 +- 支持 BitTorrent v1 磁力链接(十六进制或 Base32)及 v1/hybrid 种子;纯 v2 尚不支持。 +- 同一订阅内按资源地址及“季度 + 集数”去重。相同集数有多个版本时采用 RSS 中先出现的条目,尚不自动洗版。 +- 保存原始资源标题、地址、识别结果、过滤原因;种子文件按原始 info 字节计算 hash,并保存实际提交的种子数据。 +- 下载前先持久化 hash 和提交意图,再调用下载器。超时或重启后先查询 qBittorrent,按 hash 核对,不盲目重复提交。 +- 网络失败按 30 秒起、最多一小时的退避间隔重试;实际执行时间还受扫描周期影响。无效种子进入 `failed`,需手动重试。 +- 已提交任务若从下载器消失,标记为 `missing`,不会自动重新下载。完成任务记录保留,不因再次扫描而重建。 +- RSS/种子响应限制为 4 MiB,请求超时 30 秒。下载器 Cookie 与 RSS 请求使用不同客户端。 +- 蜜柑 HTML 上限 8 MiB,封面上限 4 MiB;成功搜索/详情缓存 60 秒、最多 64 项,最多同时执行 4 个蜜柑请求,超限返回 `429`。HTTP 重定向最多 5 次。 +- 无搜索结果返回空列表;页面无法识别、访问受限及上游失败返回 `502`,不会伪装成空列表。公开页面结构变化可能需要更新蜜柑适配器。 + +本地事务不能与下载器 API 组成原子事务。恢复依赖原下载器实例中的稳定 info hash 和下载器去重;同一数据库仅运行一个服务实例。使用全局选择接口切换不会改变恢复目标。 + +## 结构与验证 + +```text +src/api.rs HTTP 接口 +src/domain.rs 订阅、资源、任务模型与输入校验 +src/parser.rs 标题与集数识别 +src/feeds.rs RSS/Atom 获取、解析、过滤 +src/mikan.rs 蜜柑搜索、详情、订阅候选与封面 +src/media.rs 本地文件访问、路径校验与媒体数据结构 +src/media_store.rs 目的地、版本化映射、文件清单与核对记录持久化 +src/storage.rs SQLite 持久化与去重 +src/downloader.rs 种子标识与 qBittorrent 适配 +src/service.rs 扫描、任务恢复与后台调度 +migrations/ 数据库迁移 +tests/fixtures/ 人工构造的 RSS 测试样本 +``` + +```sh +cargo fmt --check +cargo test --locked +cargo clippy --locked --all-targets -- -D warnings +``` + +集成测试使用回环地址上的模拟蜜柑 / RSS / 代理 / qBittorrent 服务和临时 SQLite 数据库,不连接真实下载器、不下载媒体。 +覆盖过滤、保守集数解析、重复扫描、数据库重开、提交成功但响应失败后的恢复、下载器不可用、禁用订阅、任务移除与 API CRUD。 +新增覆盖中文搜索编码、不同季度、字幕组 ID、RSS 候选到保存、访问限制页面、封面与缓存、代理认证及跨域重定向、直连例外和代理失败处理。 +下载器归属测试覆盖切换时已排队任务、同 hash 不同实例、单实例离线、重试、重启,以及真实旧版 SQLite 数据库升级后的显式归属恢复。 +P2 使用临时本地文件与模拟下载器,覆盖目的地/映射版本快照、实际提交路径、同 hash 不同路径冲突、文件四态、成功核对时间保留、下载器离线核对、目录浏览与路径/符号链接限制。没有连接真实 NAS 或下载器。 + +已实现全局媒体目的地与本地文件存在核对。后续将支持 SMB 应用直连、第二种下载工具、MySQL、管理页面和部署。 + +## 许可证 + +本项目按 GNU General Public License version 2 only 发布,完整条款见 [LICENSE](LICENSE)。 +新增 Rust 源文件使用 `SPDX-License-Identifier: GPL-2.0-only` 标识。第三方依赖遵循各自许可证。 diff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql new file mode 100644 index 0000000..74b1dc5 --- /dev/null +++ b/migrations/0001_initial.sql @@ -0,0 +1,34 @@ +CREATE TABLE subscriptions ( + id TEXT PRIMARY KEY, + spec TEXT NOT NULL, + deleted INTEGER NOT NULL DEFAULT 0, + last_checked_at INTEGER, + last_error TEXT +); + +CREATE TABLE releases ( + subscription_id TEXT NOT NULL REFERENCES subscriptions(id), + resource_key TEXT NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (subscription_id, resource_key) +); + +CREATE TABLE download_jobs ( + id TEXT PRIMARY KEY, + subscription_id TEXT NOT NULL REFERENCES subscriptions(id), + resource_key TEXT NOT NULL, + title TEXT NOT NULL, + season INTEGER NOT NULL, + episode INTEGER NOT NULL, + download_url TEXT NOT NULL, + torrent_bytes BLOB, + info_hash TEXT, + state TEXT NOT NULL DEFAULT 'queued' + CHECK (state IN ('queued', 'submitting', 'submitted', 'downloading', 'completed', 'missing', 'failed')), + attempts INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + next_attempt_at INTEGER NOT NULL DEFAULT 0, + UNIQUE(subscription_id, resource_key), + UNIQUE(subscription_id, season, episode) +); +CREATE INDEX jobs_state ON download_jobs(state, next_attempt_at); diff --git a/migrations/0002_downloader_instances.sql b/migrations/0002_downloader_instances.sql new file mode 100644 index 0000000..d97f065 --- /dev/null +++ b/migrations/0002_downloader_instances.sql @@ -0,0 +1,18 @@ +CREATE TABLE downloader_instances ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind = 'qbittorrent'), + endpoint TEXT NOT NULL UNIQUE, + config TEXT NOT NULL +); +CREATE TABLE downloader_selection ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + downloader_id TEXT REFERENCES downloader_instances(id) +); +INSERT INTO downloader_selection(singleton) VALUES (1); + +ALTER TABLE download_jobs ADD COLUMN downloader_id TEXT REFERENCES downloader_instances(id); +ALTER TABLE download_jobs ADD COLUMN legacy_unassigned INTEGER NOT NULL DEFAULT 0; +-- Existing jobs have no recorded owner. Do not assign them to an arbitrary new instance. +UPDATE download_jobs SET legacy_unassigned = 1; +CREATE INDEX jobs_downloader ON download_jobs(downloader_id, state); diff --git a/migrations/0003_media_destinations.sql b/migrations/0003_media_destinations.sql new file mode 100644 index 0000000..1f82969 --- /dev/null +++ b/migrations/0003_media_destinations.sql @@ -0,0 +1,37 @@ +CREATE TABLE media_destinations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + kind TEXT NOT NULL CHECK (kind = 'local'), + root_path TEXT NOT NULL +); +CREATE TABLE destination_selection ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + destination_id TEXT REFERENCES media_destinations(id) +); +INSERT INTO destination_selection(singleton) VALUES (1); +CREATE TABLE destination_mappings ( + id TEXT PRIMARY KEY, + destination_id TEXT NOT NULL REFERENCES media_destinations(id), + downloader_id TEXT NOT NULL REFERENCES downloader_instances(id), + version INTEGER NOT NULL, + download_root TEXT NOT NULL, + UNIQUE(destination_id, downloader_id, version) +); +ALTER TABLE download_jobs ADD COLUMN destination_id TEXT REFERENCES media_destinations(id); +ALTER TABLE download_jobs ADD COLUMN mapping_id TEXT REFERENCES destination_mappings(id); +ALTER TABLE download_jobs ADD COLUMN download_save_path TEXT; +ALTER TABLE download_jobs ADD COLUMN file_status TEXT NOT NULL DEFAULT 'unchecked' + CHECK (file_status IN ('unchecked', 'present', 'absent', 'unavailable')); +ALTER TABLE download_jobs ADD COLUMN file_checked_at INTEGER; +ALTER TABLE download_jobs ADD COLUMN file_last_success_at INTEGER; +ALTER TABLE download_jobs ADD COLUMN file_error TEXT; +CREATE TABLE media_files ( + job_id TEXT NOT NULL REFERENCES download_jobs(id), + relative_path TEXT NOT NULL, + expected_size INTEGER NOT NULL, + observed_size INTEGER, + status TEXT NOT NULL DEFAULT 'unchecked' + CHECK (status IN ('unchecked', 'present', 'absent', 'unavailable')), + error TEXT, + PRIMARY KEY(job_id, relative_path) +); diff --git a/src/api.rs b/src/api.rs new file mode 100644 index 0000000..6429952 --- /dev/null +++ b/src/api.rs @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::{ + domain::{DownloaderInput, SubscriptionInput}, + error::{Error, Result}, + media::{self, DestinationInput, MappingInput}, + service::Service, +}; +use actix_web::{web, HttpResponse}; +use std::sync::Arc; + +type State = web::Data>; + +pub fn configure(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api") + .route("/sources/mikan/search", web::get().to(mikan_search)) + .route("/sources/mikan/series/{id}", web::get().to(mikan_series)) + .route( + "/sources/mikan/series/{id}/cover", + web::get().to(mikan_cover), + ) + .route("/sources/mikan/resolve", web::post().to(mikan_resolve)) + .route("/status", web::get().to(status)) + .route("/destinations", web::get().to(destinations)) + .route("/destinations", web::post().to(create_destination)) + .route("/destinations/active", web::put().to(activate_destination)) + .route("/destinations/{id}/mappings", web::get().to(mappings)) + .route( + "/destinations/{id}/mappings", + web::post().to(create_mapping), + ) + .route( + "/destinations/{id}/entries", + web::get().to(directory_entries), + ) + .route("/downloaders", web::get().to(downloaders)) + .route("/downloaders", web::post().to(create_downloader)) + .route("/downloaders/active", web::put().to(activate_downloader)) + .route( + "/downloaders/{id}/claim-legacy-jobs", + web::post().to(claim_legacy_jobs), + ) + .route("/preview", web::post().to(preview)) + .route("/subscriptions", web::get().to(list)) + .route("/subscriptions", web::post().to(create)) + .route("/subscriptions/{id}", web::get().to(get)) + .route("/subscriptions/{id}", web::put().to(update)) + .route("/subscriptions/{id}", web::delete().to(delete)) + .route("/subscriptions/{id}/preview", web::get().to(preview_saved)) + .route("/subscriptions/{id}/scan", web::post().to(scan)) + .route("/subscriptions/{id}/releases", web::get().to(releases)) + .route("/jobs", web::get().to(jobs)) + .route("/jobs/{id}/files", web::get().to(job_files)) + .route("/jobs/{id}/files/check", web::post().to(check_job_files)) + .route("/jobs/{id}/retry", web::post().to(retry)) + .route("/run", web::post().to(run)), + ); +} + +#[derive(serde::Deserialize)] +struct SearchQuery { + q: String, +} + +async fn mikan_search(state: State, query: web::Query) -> Result { + Ok(HttpResponse::Ok().json(state.mikan.search(&query.q).await?)) +} + +async fn mikan_series(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.mikan.series(&id).await?)) +} + +async fn mikan_cover(state: State, id: web::Path) -> Result { + let (mime, bytes) = state.mikan.cover(&id).await?; + Ok(HttpResponse::Ok() + .content_type(mime) + .insert_header(("X-Content-Type-Options", "nosniff")) + .insert_header(("Cache-Control", "private, max-age=60")) + .body(bytes)) +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ResolveInput { + url: String, +} + +async fn mikan_resolve(state: State, body: web::Json) -> Result { + Ok(HttpResponse::Ok().json(state.mikan.resolve(&body.url).await?)) +} + +async fn status(state: State) -> Result { + let active = state.store.active_downloader_id().await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "downloader_configured": active.is_some(), + "active_downloader_id": active, + "active_destination_id": state.store.active_destination_id().await?, + "legacy_unassigned_jobs": state.store.legacy_unassigned_count().await?, + "version": env!("CARGO_PKG_VERSION") + }))) +} + +async fn destinations(state: State) -> Result { + Ok(HttpResponse::Ok().json(state.store.destinations().await?)) +} + +async fn create_destination( + state: State, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Created().json(state.store.create_destination(body.into_inner()).await?)) +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct DestinationSelection { + destination_id: String, +} + +async fn activate_destination( + state: State, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + state + .store + .activate_destination(&body.destination_id) + .await?; + Ok(HttpResponse::NoContent().finish()) +} + +async fn mappings(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.store.mappings(&id).await?)) +} + +async fn create_mapping( + state: State, + id: web::Path, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Created().json(state.store.create_mapping(&id, body.into_inner()).await?)) +} + +#[derive(serde::Deserialize)] +struct DirectoryQuery { + #[serde(default)] + path: String, +} + +async fn directory_entries( + state: State, + id: web::Path, + query: web::Query, +) -> Result { + media::validate_relative(&query.path, true)?; + let root = state.store.destination(&id).await?.root_path; + let path = query.into_inner().path; + let entries = tokio::task::spawn_blocking(move || media::list_local(&root, &path)) + .await + .map_err(|_| Error::Upstream("local directory query failed".into()))?; + Ok(HttpResponse::Ok().json(entries)) +} + +async fn job_files(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.store.file_report(&id).await?)) +} + +async fn check_job_files(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.check_job_files(&id).await?)) +} + +async fn downloaders(state: State) -> Result { + Ok(HttpResponse::Ok().json(state.store.downloaders().await?)) +} + +async fn create_downloader(state: State, body: web::Json) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Created().json(state.store.create_downloader(body.into_inner()).await?)) +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct DownloaderSelection { + downloader_id: String, +} + +async fn activate_downloader( + state: State, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + state.store.activate_downloader(&body.downloader_id).await?; + Ok(HttpResponse::NoContent().finish()) +} + +async fn claim_legacy_jobs(state: State, id: web::Path) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Ok() + .json(serde_json::json!({"claimed": state.store.claim_legacy_jobs(&id).await?}))) +} +async fn list(state: State) -> Result { + Ok(HttpResponse::Ok().json(state.store.subscriptions().await?)) +} +async fn get(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.store.subscription(&id).await?)) +} +async fn create(state: State, body: web::Json) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Created().json(state.store.create(body.into_inner()).await?)) +} +async fn update( + state: State, + id: web::Path, + body: web::Json, +) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + Ok(HttpResponse::Ok().json(state.store.update(&id, body.into_inner()).await?)) +} +async fn delete(state: State, id: web::Path) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + state.store.delete(&id).await?; + Ok(HttpResponse::NoContent().finish()) +} +async fn preview(state: State, body: web::Json) -> Result { + Ok(HttpResponse::Ok().json(state.preview(body.into_inner()).await?)) +} +async fn preview_saved(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.preview_subscription(&id).await?)) +} +async fn scan(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(serde_json::json!({"queued": state.scan_subscription(&id).await?}))) +} +async fn releases(state: State, id: web::Path) -> Result { + Ok(HttpResponse::Ok().json(state.store.releases(&id).await?)) +} +async fn jobs(state: State) -> Result { + Ok(HttpResponse::Ok().json(state.store.jobs().await?)) +} +async fn retry(state: State, id: web::Path) -> Result { + let _guard = state.gate.try_lock().map_err(|_| Error::Busy)?; + state.store.retry(&id).await?; + Ok(HttpResponse::NoContent().finish()) +} +async fn run(state: State) -> Result { + Ok(HttpResponse::Ok().json(state.cycle().await?)) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..d667949 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::error::{Error, Result}; +use std::{env, time::Duration}; + +#[derive(Clone)] +pub struct Config { + pub database_url: String, + pub port: u16, + pub poll_interval: Duration, + pub qbit: Option, + pub mikan_base_url: String, + pub website: WebsiteConfig, +} + +#[derive(Clone, Default)] +pub struct WebsiteConfig { + pub proxy_url: Option, + pub direct_hosts: String, +} + +// Deliberately omit Debug: these fields can contain credentials. +#[derive(Clone, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct QbitConfig { + pub url: String, + pub username: String, + pub password: String, + pub save_path: String, +} + +impl QbitConfig { + pub fn validate(&mut self) -> Result<()> { + self.url = self.url.trim().trim_end_matches('/').to_owned(); + crate::domain::validate_http_url(&self.url)?; + let url = reqwest::Url::parse(&self.url).expect("validated URL"); + if url.query().is_some() || url.fragment().is_some() { + return Err(Error::Invalid( + "downloader URL cannot contain query parameters or a fragment".into(), + )); + } + self.url = url.to_string().trim_end_matches('/').to_owned(); + if self.username.len() > 1024 + || self.password.len() > 4096 + || self.save_path.trim().is_empty() + || self.save_path.len() > 4096 + || self.save_path.chars().any(char::is_control) + { + return Err(Error::Invalid( + "invalid downloader credentials or save path".into(), + )); + } + Ok(()) + } +} + +impl Config { + pub fn from_env() -> Result { + let port = env::var("APP_PORT") + .unwrap_or_else(|_| "8080".into()) + .parse() + .map_err(|_| Error::Invalid("APP_PORT must be a valid port".into()))?; + let seconds: u64 = env::var("POLL_INTERVAL_SECONDS") + .unwrap_or_else(|_| "300".into()) + .parse() + .map_err(|_| Error::Invalid("POLL_INTERVAL_SECONDS must be an integer".into()))?; + if seconds < 10 { + return Err(Error::Invalid( + "POLL_INTERVAL_SECONDS must be at least 10".into(), + )); + } + let qbit = match env::var("QBIT_URL") { + Ok(url) if !url.is_empty() => { + crate::domain::validate_http_url(&url)?; + let base = reqwest::Url::parse(&url).expect("validated URL"); + if base.query().is_some() || base.fragment().is_some() { + return Err(Error::Invalid( + "QBIT_URL cannot contain query parameters or a fragment".into(), + )); + } + Some(QbitConfig { + url: url.trim_end_matches('/').into(), + username: env::var("QBIT_USERNAME").unwrap_or_default(), + password: env::var("QBIT_PASSWORD").unwrap_or_default(), + save_path: env::var("QBIT_SAVE_PATH").unwrap_or_else(|_| "/downloads".into()), + }) + } + _ => None, + }; + Ok(Self { + database_url: env::var("DATABASE_URL") + .unwrap_or_else(|_| "sqlite://data/ani.db".into()), + port, + poll_interval: Duration::from_secs(seconds), + qbit, + mikan_base_url: env::var("MIKAN_BASE_URL") + .unwrap_or_else(|_| "https://mikanani.me".into()), + website: WebsiteConfig { + proxy_url: env::var("WEBSITE_PROXY_URL") + .ok() + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()), + direct_hosts: env::var("WEBSITE_DIRECT_HOSTS").unwrap_or_default(), + }, + }) + } +} diff --git a/src/domain.rs b/src/domain.rs new file mode 100644 index 0000000..9785202 --- /dev/null +++ b/src/domain.rs @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::error::{Error, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SubscriptionInput { + pub title: String, + pub rss_url: String, + #[serde(default = "one")] + pub season: i32, + #[serde(default)] + pub episode_offset: i32, + #[serde(default)] + pub include: Vec, + #[serde(default)] + pub exclude: Vec, + // Opt in after previewing: the first scan considers all entries in the feed. + #[serde(default)] + pub enabled: bool, +} + +fn one() -> i32 { + 1 +} + +impl SubscriptionInput { + pub fn validate(&mut self) -> Result<()> { + self.title = self.title.trim().to_owned(); + self.rss_url = self.rss_url.trim().to_owned(); + if self.title.is_empty() || self.title.chars().count() > 200 { + return Err(Error::Invalid("title must contain 1–200 characters".into())); + } + validate_http_url(&self.rss_url)?; + if !(1..=999).contains(&self.season) || self.episode_offset.abs_diff(0) > 10000 { + return Err(Error::Invalid( + "season or episode_offset is out of range".into(), + )); + } + for rules in [&mut self.include, &mut self.exclude] { + if rules.len() > 32 { + return Err(Error::Invalid("at most 32 rules per list".into())); + } + for rule in rules { + *rule = rule.trim().to_owned(); + if rule.is_empty() || rule.len() > 200 { + return Err(Error::Invalid( + "filter keywords must contain 1–200 bytes".into(), + )); + } + } + } + Ok(()) + } +} + +pub fn validate_http_url(value: &str) -> Result<()> { + let url = reqwest::Url::parse(value).map_err(|_| Error::Invalid("invalid URL".into()))?; + if !matches!(url.scheme(), "http" | "https") + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || value.len() > 8192 + { + return Err(Error::Invalid( + "expected an HTTP(S) URL without embedded credentials".into(), + )); + } + Ok(()) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Subscription { + pub id: String, + #[serde(flatten)] + pub input: SubscriptionInput, + pub last_checked_at: Option, + pub last_error: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Decision { + Ready, + Filtered, + NeedsReview, + AlreadyQueued, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Release { + pub key: String, + pub title: String, + pub download_url: Option, + pub episode: Option, + pub decision: Decision, + pub reason: String, +} + +#[derive(Clone, Debug, Serialize, sqlx::FromRow)] +pub struct DownloadJob { + pub id: String, + pub subscription_id: String, + pub title: String, + pub season: i32, + pub episode: i32, + pub download_url: String, + pub info_hash: Option, + pub state: String, + pub attempts: i32, + pub last_error: Option, + pub next_attempt_at: i64, + pub downloader_id: Option, + pub legacy_unassigned: bool, + pub destination_id: Option, + pub mapping_id: Option, + pub download_save_path: Option, + pub file_status: String, + pub file_checked_at: Option, + pub file_last_success_at: Option, + pub file_error: Option, +} + +#[derive(Clone, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DownloaderInput { + pub name: String, + pub kind: DownloaderKind, + pub connection: crate::config::QbitConfig, +} + +#[derive(Clone, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DownloaderKind { + Qbittorrent, +} + +impl DownloaderInput { + pub fn validate(&mut self) -> Result<()> { + self.name = self.name.trim().to_owned(); + if self.name.is_empty() || self.name.chars().count() > 100 { + return Err(Error::Invalid( + "downloader name must contain 1–100 characters".into(), + )); + } + self.connection.validate() + } +} + +#[derive(serde::Serialize)] +pub struct DownloaderView { + pub id: String, + pub name: String, + pub kind: DownloaderKind, + pub url: String, + pub username: String, + pub save_path: String, + pub password_configured: bool, + pub is_active: bool, +} diff --git a/src/downloader.rs b/src/downloader.rs new file mode 100644 index 0000000..04c1b1e --- /dev/null +++ b/src/downloader.rs @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::{ + config::QbitConfig, + domain::validate_http_url, + error::{Error, Result}, + feeds::bounded_body, +}; +use bendy::decoding::{Decoder, Object}; +use reqwest::{multipart, Client, Url}; +use serde::Deserialize; +use sha1::{Digest, Sha1}; +use std::time::Duration; + +pub struct PreparedTorrent { + pub hash: String, + pub bytes: Option>, +} + +pub fn magnet_hash(value: &str) -> Result { + let url = Url::parse(value).map_err(|_| Error::Invalid("invalid magnet URI".into()))?; + if url.scheme() != "magnet" { + return Err(Error::Invalid("expected magnet URI".into())); + } + for (key, value) in url.query_pairs() { + if key != "xt" { + continue; + } + if let Some(hash) = value.strip_prefix("urn:btih:") { + if hash.len() == 40 && hash.bytes().all(|b| b.is_ascii_hexdigit()) { + return Ok(hash.to_ascii_lowercase()); + } + if hash.len() == 32 { + if let Ok(bytes) = + data_encoding::BASE32.decode(hash.to_ascii_uppercase().as_bytes()) + { + return Ok(data_encoding::HEXLOWER.encode(&bytes)); + } + } + } + } + Err(Error::Invalid( + "magnet must contain a valid BitTorrent v1 info hash".into(), + )) +} + +pub fn torrent_hash(bytes: &[u8]) -> Result { + let invalid = |_| Error::Invalid("invalid or unsupported torrent metadata".into()); + let mut decoder = Decoder::new(bytes).with_max_depth(32); + let mut root = decoder + .next_object() + .map_err(invalid)? + .ok_or_else(|| Error::Invalid("empty torrent metadata".into()))? + .try_into_dictionary() + .map_err(invalid)?; + let mut raw_info = None; + while let Some((key, object)) = root.next_pair().map_err(invalid)? { + if key == b"info" { + raw_info = Some( + object + .try_into_dictionary() + .map_err(invalid)? + .into_raw() + .map_err(invalid)?, + ); + } + } + drop(root); + if decoder.next_object().map_err(invalid)?.is_some() { + return Err(Error::Invalid("trailing torrent data".into())); + } + let raw = + raw_info.ok_or_else(|| Error::Invalid("torrent is missing info dictionary".into()))?; + let mut info_decoder = Decoder::new(raw).with_max_depth(32); + let mut info = info_decoder + .next_object() + .map_err(invalid)? + .unwrap() + .try_into_dictionary() + .map_err(invalid)?; + let mut v1 = false; + while let Some((key, object)) = info.next_pair().map_err(invalid)? { + if let (b"pieces", Object::Bytes(pieces)) = (key, object) { + v1 = !pieces.is_empty() && pieces.len() % 20 == 0; + } + } + if !v1 { + return Err(Error::Invalid( + "only BitTorrent v1/hybrid torrents are supported".into(), + )); + } + // Hash the exact encoded bytes, never a decoded/re-encoded dictionary. + Ok(format!("{:x}", Sha1::digest(raw))) +} + +pub async fn prepare(client: &Client, url: &str) -> Result { + if url.starts_with("magnet:") { + return Ok(PreparedTorrent { + hash: magnet_hash(url)?, + bytes: None, + }); + } + validate_http_url(url)?; + let response = + crate::feeds::website_get(client, Url::parse(url).expect("validated URL")).await?; + let bytes = bounded_body(response, 4 * 1024 * 1024).await?; + Ok(PreparedTorrent { + hash: torrent_hash(&bytes)?, + bytes: Some(bytes), + }) +} + +#[derive(Clone)] +pub struct Qbit { + client: Client, + config: QbitConfig, +} + +#[derive(Deserialize)] +pub struct TorrentStatus { + pub hash: String, + pub progress: f64, + pub state: String, + #[serde(default)] + pub save_path: Option, +} + +#[derive(Deserialize)] +pub struct TorrentFile { + pub name: String, + pub size: i64, + pub priority: i32, +} + +impl Qbit { + pub fn new(config: QbitConfig) -> Result { + // Keep downloader cookies in a separate client from untrusted RSS hosts. + let client = Client::builder() + .no_proxy() + .cookie_store(true) + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build()?; + Ok(Self { client, config }) + } + + pub async fn login(&self) -> Result<()> { + let response = self + .client + .post(format!("{}/api/v2/auth/login", self.config.url)) + .header("Referer", format!("{}/", self.config.url)) + .form(&[ + ("username", &self.config.username), + ("password", &self.config.password), + ]) + .send() + .await?; + let bytes = bounded_body(response, 1024).await?; + if bytes != b"Ok." { + return Err(Error::Upstream("qBittorrent login failed".into())); + } + Ok(()) + } + + pub async fn torrents(&self) -> Result> { + let response = self + .client + .get(format!("{}/api/v2/torrents/info", self.config.url)) + .header("Referer", format!("{}/", self.config.url)) + .send() + .await?; + let bytes = bounded_body(response, 8 * 1024 * 1024).await?; + serde_json::from_slice(&bytes) + .map_err(|_| Error::Upstream("invalid qBittorrent task response".into())) + } + + pub async fn files(&self, hash: &str) -> Result> { + let response = self + .client + .get(format!("{}/api/v2/torrents/files", self.config.url)) + .header("Referer", format!("{}/", self.config.url)) + .query(&[("hash", hash)]) + .send() + .await?; + let bytes = bounded_body(response, 8 * 1024 * 1024).await?; + let files: Vec = serde_json::from_slice(&bytes) + .map_err(|_| Error::Upstream("invalid qBittorrent file list".into()))?; + if files.len() > 5000 || files.iter().any(|file| file.size < 0) { + return Err(Error::Upstream( + "qBittorrent file list exceeds limits or contains invalid sizes".into(), + )); + } + Ok(files) + } + + pub async fn add( + &self, + url: &str, + bytes: Option>, + save_path: Option<&str>, + ) -> Result<()> { + let mut form = multipart::Form::new() + .text( + "savepath", + save_path.unwrap_or(&self.config.save_path).to_owned(), + ) + .text("tags", "rust-project") + .text("autoTMM", "false"); + form = if let Some(bytes) = bytes { + form.part( + "torrents", + multipart::Part::bytes(bytes) + .file_name("release.torrent") + .mime_str("application/x-bittorrent")?, + ) + } else { + form.text("urls", url.to_owned()) + }; + let response = self + .client + .post(format!("{}/api/v2/torrents/add", self.config.url)) + .header("Referer", format!("{}/", self.config.url)) + .multipart(form) + .send() + .await?; + let bytes = bounded_body(response, 1024).await?; + if bytes != b"Ok." { + return Err(Error::Upstream("qBittorrent rejected torrent".into())); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn handles_hex_and_base32_magnets() { + assert_eq!( + magnet_hash("magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap(), + "a".repeat(40) + ); + assert_eq!( + magnet_hash("magnet:?xt=urn:btih:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap(), + "0".repeat(40) + ); + assert!(magnet_hash("magnet:?xt=urn:btih:bad").is_err()); + } + #[test] + fn hashes_original_info_bytes_and_rejects_bad_metadata() { + let info = b"d6:lengthi1e4:name1:a12:piece lengthi16384e6:pieces20:aaaaaaaaaaaaaaaaaaaae"; + let torrent = [b"d4:info".as_slice(), info, b"e"].concat(); + assert_eq!( + torrent_hash(&torrent).unwrap(), + format!("{:x}", Sha1::digest(info)) + ); + for bad in [ + b"error".as_slice(), + b"d4:infodee", + b"d4:info", + &[torrent.as_slice(), b"junk"].concat(), + ] { + assert!(torrent_hash(bad).is_err()); + } + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..16fb18b --- /dev/null +++ b/src/error.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-2.0-only +use actix_web::{http::StatusCode, HttpResponse, ResponseError}; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + Invalid(String), + #[error("resource not found")] + NotFound, + #[error("a scan or download synchronization is already running")] + Busy, + #[error("too many Mikan requests; retry shortly")] + SourceBusy, + #[error("{0}")] + Upstream(String), + #[error("database operation failed")] + Database(#[from] sqlx::Error), + #[error("stored data could not be decoded")] + Json(#[from] serde_json::Error), + #[error("database migration failed")] + Migration(#[from] sqlx::migrate::MigrateError), +} + +impl From for Error { + fn from(value: reqwest::Error) -> Self { + Self::Upstream(value.without_url().to_string()) + } +} + +impl ResponseError for Error { + fn status_code(&self) -> StatusCode { + match self { + Self::Invalid(_) => StatusCode::BAD_REQUEST, + Self::NotFound => StatusCode::NOT_FOUND, + Self::Busy => StatusCode::CONFLICT, + Self::SourceBusy => StatusCode::TOO_MANY_REQUESTS, + Self::Upstream(_) => StatusCode::BAD_GATEWAY, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(serde_json::json!({"error": self.to_string()})) + } +} diff --git a/src/feeds.rs b/src/feeds.rs new file mode 100644 index 0000000..6b31912 --- /dev/null +++ b/src/feeds.rs @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::{ + domain::*, + error::{Error, Result}, + parser, +}; +use reqwest::{Client, Url}; +use sha1::{Digest, Sha1}; +use std::time::Duration; + +pub fn http_client() -> Result { + website_client(&crate::config::WebsiteConfig::default()) +} + +pub fn website_client(config: &crate::config::WebsiteConfig) -> Result { + let mut builder = Client::builder() + .no_proxy() + .timeout(Duration::from_secs(30)) + .connect_timeout(Duration::from_secs(10)) + .user_agent(concat!("rust_project/", env!("CARGO_PKG_VERSION"))) + // Rebuild each redirected request so proxy auth and direct-host rules are reapplied. + .redirect(reqwest::redirect::Policy::none()); + if let Some(value) = &config.proxy_url { + let invalid = || { + Error::Invalid( + "WEBSITE_PROXY_URL must be an HTTP(S), SOCKS5 or SOCKS5H proxy URL".into(), + ) + }; + let url = Url::parse(value).map_err(|_| invalid())?; + if !matches!(url.scheme(), "http" | "https" | "socks5" | "socks5h") + || url.host_str().is_none() + || url.query().is_some() + || url.fragment().is_some() + || !matches!(url.path(), "" | "/") + { + return Err(invalid()); + } + let proxy = reqwest::Proxy::all(url) + .map_err(|_| invalid())? + .no_proxy(reqwest::NoProxy::from_string(&config.direct_hosts)); + builder = builder.proxy(proxy); + } + Ok(builder.build()?) +} + +pub async fn website_get(client: &Client, mut url: Url) -> Result { + tokio::time::timeout(Duration::from_secs(30), async { + for hop in 0..=5 { + validate_http_url(url.as_str())?; + let response = client.get(url.clone()).send().await?; + if !matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) { + return Ok(response); + } + if hop == 5 { + return Err(Error::Upstream("website redirect limit exceeded".into())); + } + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| Error::Upstream("invalid website redirect".into()))?; + url = url + .join(location) + .map_err(|_| Error::Upstream("invalid website redirect URL".into()))?; + validate_http_url(url.as_str()) + .map_err(|_| Error::Upstream("unsupported website redirect URL".into()))?; + } + unreachable!("redirect count is bounded") + }) + .await + .map_err(|_| Error::Upstream("website request timed out".into()))? +} + +pub async fn bounded_body(mut response: reqwest::Response, limit: usize) -> Result> { + response = response.error_for_status()?; + if response.content_length().is_some_and(|n| n > limit as u64) { + return Err(Error::Upstream("response exceeds size limit".into())); + } + let mut data = Vec::new(); + while let Some(chunk) = response.chunk().await? { + if data.len() + chunk.len() > limit { + return Err(Error::Upstream("response exceeds size limit".into())); + } + data.extend_from_slice(&chunk); + } + Ok(data) +} + +pub async fn fetch(client: &Client, sub: &SubscriptionInput) -> Result> { + let response = website_get( + client, + Url::parse(&sub.rss_url).map_err(|_| Error::Invalid("invalid RSS URL".into()))?, + ) + .await?; + let base = response.url().clone(); + let bytes = bounded_body(response, 4 * 1024 * 1024).await?; + parse(&bytes, &base, sub) +} + +pub fn parse(bytes: &[u8], base: &Url, sub: &SubscriptionInput) -> Result> { + let feed = feed_rs::parser::Builder::new() + .base_uri(Some(base.as_str())) + .build() + .parse(bytes) + .map_err(|_| Error::Upstream("invalid RSS/Atom feed".into()))?; + if feed.entries.len() > 2000 { + return Err(Error::Upstream("feed exceeds 2000 entries".into())); + } + Ok(feed + .entries + .into_iter() + .map(|entry| { + let title = entry.title.map(|t| t.content).unwrap_or_default(); + let media = entry + .media + .iter() + .flat_map(|m| m.content.iter()) + .filter_map(|c| c.url.as_ref().map(|u| u.as_str())); + let links = entry + .links + .iter() + .filter(|l| { + l.rel.as_deref() == Some("enclosure") + || l.media_type.as_deref() == Some("application/x-bittorrent") + || l.href.starts_with("magnet:") + || Url::parse(&l.href) + .ok() + .is_some_and(|u| u.path().ends_with(".torrent")) + }) + .map(|l| l.href.as_str()); + let download_url = media.chain(links).find_map(|link| { + let url = base.join(link).ok()?; + match url.scheme() { + "magnet" => Some(url.to_string()), + "http" | "https" if validate_http_url(url.as_str()).is_ok() => { + Some(url.to_string()) + } + _ => None, + } + }); + let key = format!( + "{:x}", + Sha1::digest(download_url.as_deref().unwrap_or(&entry.id).as_bytes()) + ); + let mut release = Release { + key, + title, + download_url, + episode: None, + decision: Decision::Ready, + reason: "matched subscription rules".into(), + }; + let lower = release.title.to_lowercase(); + if sub + .exclude + .iter() + .any(|v| lower.contains(&v.to_lowercase())) + { + release.decision = Decision::Filtered; + release.reason = "matched an exclusion keyword".into(); + } else if !sub + .include + .iter() + .all(|v| lower.contains(&v.to_lowercase())) + { + release.decision = Decision::Filtered; + release.reason = "missing a required inclusion keyword".into(); + } else if release.download_url.is_none() { + release.decision = Decision::NeedsReview; + release.reason = "no supported torrent enclosure or magnet link".into(); + } else { + match parser::episode(&release.title, sub.season, sub.episode_offset) { + Ok(ep) => release.episode = Some(ep), + Err(reason) => { + release.decision = Decision::NeedsReview; + release.reason = reason.into(); + } + } + } + release + }) + .collect()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..bdaf1a4 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: GPL-2.0-only +pub mod api; +pub mod config; +pub mod domain; +pub mod downloader; +pub mod error; +pub mod feeds; +pub mod media; +mod media_store; +pub mod mikan; +pub mod parser; +pub mod service; +pub mod storage; diff --git a/src/main.rs b/src/main.rs index 5977f7f..b0ae52d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,45 +1,152 @@ -use std::io; +// SPDX-License-Identifier: GPL-2.0-only +use actix_web::{web, App, HttpResponse, HttpServer, Responder}; +use rust_project::{ + api, + config::Config, + service::{worker, Service}, + storage::Store, +}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; -fn main() { - println!("欢迎来到增强版 Rust 程序!"); - - // 演示变量声明和类型d - let name = "Rustacean"; // Rustacean 是 Rust 开发者的昵称 - let age: u32 = 10; - - println!("你好,{}!你使用 Rust 已经 {} 年了。", name, age); - - // 演示数组和循环 - let numbers = [1, 2, 3, 4, 5]; - println!("\n数字列表:"); - for (index, &value) in numbers.iter().enumerate() { - println!("索引 {}: 值 {}", index, value); - } - - // 计算数组元素总和 - let sum: i32 = numbers.iter().sum(); - println!("\n数组元素总和: {}", sum); - - // 条件判断 - if sum > 10 { - println!("总和大于 10"); - } else { - println!("总和小于或等于 10"); - } - - // 与用户交互 - println!("\n请输入您的名字:"); - let mut input = String::new(); - - match io::stdin().read_line(&mut input) { - Ok(_) => { - let input = input.trim(); // 移除换行符 - if !input.is_empty() { - println!("很高兴认识您,{}!", input); - } else { - println!("您没有输入任何内容。"); +#[derive(Debug, Serialize, Deserialize)] +struct Greeting { + message: String, + name: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Health { + status: String, + version: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CalcRequest { + a: f64, + b: f64, + operation: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct CalcResponse { + result: f64, + operation: String, +} + +async fn hello() -> impl Responder { + HttpResponse::Ok().body("Hello World! 欢迎使用 Rust Web 服务器") +} + +async fn health_check() -> impl Responder { + let health = Health { + status: "ok".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }; + HttpResponse::Ok().json(health) +} + +async fn greet(name: web::Path) -> impl Responder { + let greeting = Greeting { + message: format!("你好,{}!", name), + name: name.into_inner(), + }; + HttpResponse::Ok().json(greeting) +} + +async fn calculate(web::Json(req): web::Json) -> impl Responder { + let result = match req.operation.as_str() { + "add" => req.a + req.b, + "subtract" => req.a - req.b, + "multiply" => req.a * req.b, + "divide" => { + if req.b == 0.0 { + return HttpResponse::BadRequest().json("除数不能为 0"); } + req.a / req.b } - Err(error) => println!("读取输入时发生错误: {}", error), - } + _ => return HttpResponse::BadRequest().json("不支持的操作"), + }; + + let response = CalcResponse { + result, + operation: req.operation, + }; + HttpResponse::Ok().json(response) +} + +async fn echo(data: web::Json) -> impl Responder { + HttpResponse::Ok().json(data) +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + let config = Config::from_env().map_err(std::io::Error::other)?; + std::fs::create_dir_all("data")?; + let store = Store::open(&config.database_url) + .await + .map_err(std::io::Error::other)?; + let service = Arc::new( + Service::new(store, &config) + .await + .map_err(std::io::Error::other)?, + ); + let app_service = service.clone(); + let server = HttpServer::new(move || { + App::new() + .app_data(web::Data::new(app_service.clone())) + .app_data(web::JsonConfig::default().limit(32 * 1024)) + .route("/", web::get().to(hello)) + .route("/health", web::get().to(health_check)) + .route("/greet/{name}", web::get().to(greet)) + .route("/api/echo", web::post().to(echo)) + .route("/api/calculate", web::post().to(calculate)) + .configure(api::configure) + .route( + "/info", + web::get().to(|| async { + HttpResponse::Ok().json(serde_json::json!({ + "project": env!("CARGO_PKG_NAME"), + "version": env!("CARGO_PKG_VERSION"), + "endpoints": [ + { "method": "GET", "path": "/", "description": "欢迎页面" }, + { "method": "GET", "path": "/health", "description": "健康检查" }, + { "method": "GET", "path": "/greet/{name}", "description": "问候接口" }, + { "method": "POST", "path": "/api/echo", "description": "回显数据" }, + { "method": "POST", "path": "/api/calculate", "description": "计算接口" } + ] + })) + }), + ) + }) + .bind(("127.0.0.1", config.port))?; + + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let background = tokio::spawn(worker(service, config.poll_interval, shutdown_rx)); + + println!("\n=================================================="); + println!("🚀 Rust Web 服务器已启动"); + println!("📡 监听地址: http://127.0.0.1:{}", config.port); + println!(" GET/POST /api/subscriptions - 订阅管理"); + println!(" POST /api/preview - RSS 解析预览"); + println!(" POST /api/run - 执行订阅与下载同步"); + println!(" GET /api/jobs - 下载任务"); + println!("📋 可用端点:"); + println!(" GET / - 欢迎页面"); + println!(" GET /health - 健康检查"); + println!(" GET /info - API 信息"); + println!(" GET /greet/{{name}} - 问候接口"); + println!(" POST /api/echo - 回显数据"); + println!(" POST /api/calculate - 计算接口"); + println!("==================================================\n"); + + let result = server.run().await; + let _ = shutdown_tx.send(true); + let _ = background.await; + result } diff --git a/src/media.rs b/src/media.rs new file mode 100644 index 0000000..1bb1e0a --- /dev/null +++ b/src/media.rs @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::error::{Error, Result}; +use cap_std::{ambient_authority, fs::Dir}; +use serde::{Deserialize, Serialize}; +use std::{ + io, + path::{Component, Path}, +}; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DestinationInput { + pub name: String, + pub kind: LocalKind, + pub root_path: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LocalKind { + Local, +} + +impl DestinationInput { + pub fn validate(&mut self) -> Result<()> { + self.name = self.name.trim().to_owned(); + if self.name.is_empty() + || self.name.chars().count() > 100 + || self.root_path.len() > 4096 + || self.root_path.chars().any(char::is_control) + || !Path::new(&self.root_path).is_absolute() + || Path::new(&self.root_path) + .components() + .any(|c| matches!(c, Component::ParentDir)) + { + return Err(Error::Invalid( + "expected a name and an absolute local root without parent traversal".into(), + )); + } + Ok(()) + } +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct Destination { + pub id: String, + pub name: String, + pub kind: String, + pub root_path: String, + pub is_active: bool, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MappingInput { + pub downloader_id: String, + pub download_root: String, +} + +#[derive(Clone, Serialize, sqlx::FromRow)] +pub struct Mapping { + pub id: String, + pub destination_id: String, + pub downloader_id: String, + pub version: i64, + pub download_root: String, +} + +#[derive(Clone, Serialize, sqlx::FromRow)] +pub struct MediaFile { + pub relative_path: String, + pub expected_size: i64, + pub observed_size: Option, + pub status: String, + pub error: Option, +} + +#[derive(Serialize)] +pub struct FileReport { + pub job_id: String, + pub destination_id: Option, + pub status: String, + pub checked_at: Option, + pub last_success_at: Option, + pub error: Option, + pub files: Vec, +} + +// Wire paths always use '/' and must be relative on both Unix and Windows. +pub fn validate_relative(path: &str, allow_empty: bool) -> Result<()> { + if (path.is_empty() && !allow_empty) + || path.len() > 4096 + || path.contains(['\\', ':']) + || path.chars().any(char::is_control) + || (!path.is_empty() + && path + .split('/') + .any(|part| part.is_empty() || matches!(part, "." | ".."))) + { + return Err(Error::Invalid( + "expected a relative path without empty, dot, parent, drive or backslash components" + .into(), + )); + } + Ok(()) +} + +// qBittorrent may run on a different OS. Do not interpret its paths using the host OS. +pub fn normalize_download_root(value: &str) -> Result { + let path = value.replace('\\', "/"); + let drive = path.len() >= 3 + && path.as_bytes()[0].is_ascii_alphabetic() + && path.as_bytes()[1] == b':' + && path.as_bytes()[2] == b'/'; + if path.len() > 4096 || path.chars().any(char::is_control) || !(path.starts_with('/') || drive) + { + return Err(Error::Invalid( + "downloader root must be an absolute Unix or Windows path".into(), + )); + } + let rest = if drive { + &path[3..] + } else { + path.trim_start_matches('/') + }; + if rest.contains(':') || rest.split('/').any(|s| matches!(s, "." | "..")) { + return Err(Error::Invalid("invalid downloader root components".into())); + } + let path = path.trim_end_matches('/'); + Ok(if path.is_empty() { + "/".into() + } else if drive && path.len() == 2 { + format!("{path}/") + } else { + path.into() + }) +} + +pub fn same_download_root(expected: &str, actual: &str) -> bool { + match ( + normalize_download_root(expected), + normalize_download_root(actual), + ) { + (Ok(a), Ok(b)) => a == b, + _ => false, + } +} + +fn access_error(error: &io::Error) -> String { + match error.kind() { + io::ErrorKind::PermissionDenied => "access denied or path escapes the configured root", + io::ErrorKind::NotFound => "configured root or directory is unavailable", + _ => "filesystem access failed", + } + .into() +} + +pub fn check_local(root: &str, mut files: Vec) -> Vec { + let dir = Dir::open_ambient_dir(root, ambient_authority()); + for file in &mut files { + file.observed_size = None; + file.error = None; + let result = match &dir { + Ok(dir) => { + if validate_relative(&file.relative_path, false).is_err() { + file.status = "unavailable".into(); + file.error = Some("invalid stored relative path".into()); + continue; + } + dir.metadata(&file.relative_path) + } + Err(error) => { + file.status = "unavailable".into(); + file.error = Some(access_error(error)); + continue; + } + }; + match result { + Ok(metadata) if metadata.is_file() => { + file.status = "present".into(); + file.observed_size = i64::try_from(metadata.len()).ok(); + } + Ok(_) => { + file.status = "unavailable".into(); + file.error = Some("expected a regular file".into()); + } + Err(error) if error.kind() == io::ErrorKind::NotFound => file.status = "absent".into(), + Err(error) => { + file.status = "unavailable".into(); + file.error = Some(access_error(&error)); + } + } + } + files +} + +#[derive(Serialize)] +pub struct DirectoryEntry { + pub name: String, + pub kind: &'static str, + pub size: Option, +} +#[derive(Serialize)] +pub struct DirectoryListing { + pub status: &'static str, + pub error: Option, + pub entries: Vec, + pub truncated: bool, +} + +pub fn list_local(root: &str, path: &str) -> DirectoryListing { + if let Err(error) = validate_relative(path, true) { + return DirectoryListing { + status: "unavailable", + error: Some(error.to_string()), + entries: vec![], + truncated: false, + }; + } + let result = (|| -> io::Result<_> { + let dir = Dir::open_ambient_dir(root, ambient_authority())?; + let mut entries = Vec::new(); + let mut truncated = false; + for (index, item) in dir + .read_dir(if path.is_empty() { "." } else { path })? + .enumerate() + { + if index >= 1000 { + truncated = true; + break; + } + let item = item?; + let kind = item.file_type()?; + entries.push(DirectoryEntry { + name: item.file_name().to_string_lossy().into_owned(), + size: if kind.is_file() { + Some(item.metadata()?.len()) + } else { + None + }, + kind: if kind.is_symlink() { + "symlink" + } else if kind.is_dir() { + "directory" + } else if kind.is_file() { + "file" + } else { + "other" + }, + }); + } + entries.sort_by(|a, b| a.name.cmp(&b.name)); + Ok((entries, truncated)) + })(); + match result { + Ok((entries, truncated)) => DirectoryListing { + status: "available", + error: None, + entries, + truncated, + }, + Err(error) => DirectoryListing { + status: "unavailable", + error: Some(access_error(&error)), + entries: vec![], + truncated: false, + }, + } +} diff --git a/src/media_store.rs b/src/media_store.rs new file mode 100644 index 0000000..b5e8b92 --- /dev/null +++ b/src/media_store.rs @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::{ + domain::DownloadJob, + error::{Error, Result}, + media::*, + storage::{now, Store}, +}; + +impl Store { + pub async fn destinations(&self) -> Result> { + Ok(sqlx::query_as("SELECT d.*, COALESCE(s.destination_id = d.id, 0) AS is_active FROM media_destinations d CROSS JOIN destination_selection s ORDER BY d.rowid") + .fetch_all(&self.pool).await?) + } + + pub async fn destination(&self, id: &str) -> Result { + self.destinations() + .await? + .into_iter() + .find(|d| d.id == id) + .ok_or(Error::NotFound) + } + + pub async fn create_destination(&self, mut input: DestinationInput) -> Result { + input.validate()?; + let id = uuid::Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO media_destinations(id, name, kind, root_path) VALUES (?, ?, 'local', ?)", + ) + .bind(&id) + .bind(input.name) + .bind(input.root_path) + .execute(&self.pool) + .await?; + self.destination(&id).await + } + + pub async fn active_destination_id(&self) -> Result> { + Ok(sqlx::query_scalar( + "SELECT destination_id FROM destination_selection WHERE singleton = 1", + ) + .fetch_one(&self.pool) + .await?) + } + + pub async fn activate_destination(&self, id: &str) -> Result<()> { + let mut tx = self.pool.begin().await?; + let exists: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM media_destinations WHERE id = ?)") + .bind(id) + .fetch_one(&mut *tx) + .await?; + if !exists { + return Err(Error::NotFound); + } + let mapped: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM destination_mappings m JOIN downloader_selection s ON s.downloader_id = m.downloader_id WHERE m.destination_id = ?)") + .bind(id).fetch_one(&mut *tx).await?; + if !mapped { + return Err(Error::Invalid( + "select a downloader and configure its mapping before activating this destination" + .into(), + )); + } + sqlx::query("UPDATE destination_selection SET destination_id = ? WHERE singleton = 1") + .bind(id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } + + pub async fn mappings(&self, id: &str) -> Result> { + self.destination(id).await?; + Ok(sqlx::query_as("SELECT * FROM destination_mappings WHERE destination_id = ? ORDER BY version DESC, downloader_id") + .bind(id).fetch_all(&self.pool).await?) + } + + pub async fn create_mapping( + &self, + destination_id: &str, + input: MappingInput, + ) -> Result { + self.destination(destination_id).await?; + self.downloader_config(&input.downloader_id).await?; + let root = normalize_download_root(&input.download_root)?; + let id = uuid::Uuid::new_v4().to_string(); + let mut tx = self.pool.begin().await?; + let version: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(version), 0) + 1 FROM destination_mappings WHERE destination_id = ? AND downloader_id = ?") + .bind(destination_id).bind(&input.downloader_id).fetch_one(&mut *tx).await?; + sqlx::query("INSERT INTO destination_mappings(id, destination_id, downloader_id, version, download_root) VALUES (?, ?, ?, ?, ?)") + .bind(&id).bind(destination_id).bind(&input.downloader_id).bind(version).bind(&root).execute(&mut *tx).await?; + tx.commit().await?; + Ok(Mapping { + id, + destination_id: destination_id.into(), + downloader_id: input.downloader_id, + version, + download_root: root, + }) + } + + pub async fn job(&self, id: &str) -> Result { + sqlx::query_as("SELECT * FROM download_jobs WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await? + .ok_or(Error::NotFound) + } + + pub async fn file_report(&self, id: &str) -> Result { + let job = self.job(id).await?; + let files = sqlx::query_as("SELECT relative_path, expected_size, observed_size, status, error FROM media_files WHERE job_id = ? ORDER BY relative_path") + .bind(id).fetch_all(&self.pool).await?; + Ok(FileReport { + job_id: id.into(), + destination_id: job.destination_id, + status: job.file_status, + checked_at: job.file_checked_at, + last_success_at: job.file_last_success_at, + error: job.file_error, + files, + }) + } + + pub(crate) async fn save_manifest(&self, id: &str, files: &[MediaFile]) -> Result<()> { + let mut tx = self.pool.begin().await?; + for file in files { + validate_relative(&file.relative_path, false)?; + sqlx::query("INSERT INTO media_files(job_id, relative_path, expected_size) VALUES (?, ?, ?) ON CONFLICT(job_id, relative_path) DO NOTHING") + .bind(id).bind(&file.relative_path).bind(file.expected_size).execute(&mut *tx).await?; + } + tx.commit().await?; + Ok(()) + } + + pub(crate) async fn save_file_check( + &self, + id: &str, + status: &str, + error: Option<&str>, + files: &[MediaFile], + ) -> Result<()> { + let mut tx = self.pool.begin().await?; + let checked_at = now(); + for file in files { + sqlx::query("UPDATE media_files SET observed_size = ?, status = ?, error = ? WHERE job_id = ? AND relative_path = ?") + .bind(file.observed_size).bind(&file.status).bind(&file.error).bind(id).bind(&file.relative_path).execute(&mut *tx).await?; + } + sqlx::query("UPDATE download_jobs SET file_status = ?, file_error = ?, file_checked_at = ?, file_last_success_at = CASE WHEN ? IN ('present', 'absent') THEN ? ELSE file_last_success_at END WHERE id = ?") + .bind(status).bind(error).bind(checked_at).bind(status).bind(checked_at).bind(id).execute(&mut *tx).await?; + tx.commit().await?; + Ok(()) + } +} diff --git a/src/mikan.rs b/src/mikan.rs new file mode 100644 index 0000000..b65627d --- /dev/null +++ b/src/mikan.rs @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: GPL-2.0-only +//! Public Mikan discovery; HTML details stay inside this adapter. +use crate::{ + domain::{validate_http_url, SubscriptionInput}, + error::{Error, Result}, + feeds::{bounded_body, website_get}, +}; +use reqwest::{Client, Url}; +use scraper::{Html, Selector}; +use serde::Serialize; +use std::{ + collections::{HashMap, HashSet}, + sync::OnceLock, + time::{Duration, Instant}, +}; +use tokio::sync::{Mutex, Semaphore}; + +pub struct Mikan { + client: Client, + base: Url, + cache: Mutex>, + requests: Semaphore, +} + +#[derive(Clone)] +enum Cached { + Search(SearchResult), + Series(Box), +} + +#[derive(Clone, Serialize)] +pub struct SeriesSummary { + pub series_id: String, + pub title: String, + pub page_url: String, + pub cover_url: Option, +} + +#[derive(Clone, Serialize)] +pub struct SearchResult { + pub source_id: &'static str, + pub query: String, + pub series: Vec, + pub keyword_rss_url: String, +} + +#[derive(Clone, Serialize)] +pub struct SubtitleGroup { + pub group_id: String, + pub name: String, + pub rss_url: String, +} + +#[derive(Clone, Serialize)] +pub struct SeriesDetail { + pub source_id: &'static str, + pub series_id: String, + pub title: String, + pub page_url: String, + pub rss_url: String, + pub groups: Vec, + pub information: Vec, + pub bangumi_id: Option, + pub cover_url: Option, + #[serde(skip)] + poster: Option, +} + +#[derive(Serialize)] +pub struct Candidate { + pub source_id: &'static str, + pub scope: &'static str, + pub series_id: Option, + pub group_id: Option, + pub page_url: String, + pub rss_url: String, + pub subscription: Option, + pub warnings: Vec<&'static str>, +} + +fn text(element: scraper::ElementRef<'_>) -> String { + element + .text() + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn parameters(url: &Url) -> Result> { + let mut params = HashMap::new(); + for (key, value) in url.query_pairs() { + if params + .insert(key.to_ascii_lowercase(), value.into_owned()) + .is_some() + { + return Err(Error::Invalid("duplicate URL parameter".into())); + } + } + Ok(params) +} + +fn selector(value: &str) -> Selector { + Selector::parse(value).expect("static selector") +} + +fn valid_id(value: &str) -> bool { + !value.is_empty() && value.len() <= 20 && value.bytes().all(|b| b.is_ascii_digit()) +} + +impl Mikan { + pub fn new(client: Client, base: &str) -> Result { + validate_http_url(base)?; + let base = Url::parse(base).expect("validated URL"); + if base.path() != "/" || base.query().is_some() || base.fragment().is_some() { + return Err(Error::Invalid( + "MIKAN_BASE_URL must be an HTTP(S) origin without a path or query".into(), + )); + } + Ok(Self { + client, + base, + cache: Mutex::new(HashMap::new()), + requests: Semaphore::new(4), + }) + } + + async fn cached(&self, key: &str) -> Option { + self.cache + .lock() + .await + .get(key) + .filter(|(created, _)| created.elapsed() < Duration::from_secs(60)) + .map(|(_, value)| value.clone()) + } + + async fn remember(&self, key: String, value: Cached) { + let mut cache = self.cache.lock().await; + cache.retain(|_, (created, _)| created.elapsed() < Duration::from_secs(60)); + if cache.len() >= 64 { + if let Some(oldest) = cache + .iter() + .min_by_key(|(_, (time, _))| *time) + .map(|(key, _)| key.clone()) + { + cache.remove(&oldest); + } + } + cache.insert(key, (Instant::now(), value)); + } + + async fn html(&self, url: Url) -> Result { + let response = website_get(&self.client, url).await?; + let bytes = bounded_body(response, 8 * 1024 * 1024).await?; + let text = std::str::from_utf8(&bytes) + .map_err(|_| Error::Upstream("Mikan returned invalid UTF-8".into()))?; + Ok(Html::parse_document(text)) + } + + fn same_origin_url(&self, value: &str) -> Result { + let url = self + .base + .join(value) + .map_err(|_| Error::Invalid("invalid Mikan URL".into()))?; + validate_http_url(url.as_str())?; + if url.origin() != self.base.origin() { + return Err(Error::Invalid( + "URL must belong to the configured Mikan origin".into(), + )); + } + Ok(url) + } + + fn rss_link( + &self, + element: scraper::ElementRef<'_>, + series_id: &str, + ) -> Option<(Url, Option)> { + let url = self.same_origin_url(element.value().attr("href")?).ok()?; + if url.path() != "/RSS/Bangumi" { + return None; + } + let params = parameters(&url).ok()?; + if params.get("bangumiid").map(String::as_str) != Some(series_id) { + return None; + } + let group = params.get("subgroupid").cloned(); + if group.as_ref().is_some_and(|id| !valid_id(id)) { + return None; + } + Some((url, group)) + } + + pub async fn series(&self, id: &str) -> Result { + if !valid_id(id) { + return Err(Error::Invalid("invalid Mikan series ID".into())); + } + let key = format!("series:{id}"); + if let Some(Cached::Series(detail)) = self.cached(&key).await { + return Ok(*detail); + } + let _permit = self.requests.try_acquire().map_err(|_| Error::SourceBusy)?; + let page = self + .base + .join(&format!("/Home/Bangumi/{id}")) + .expect("numeric ID"); + let html = self.html(page.clone()).await?; + let title = html + .select(&selector(".bangumi-title")) + .next() + .map(text) + .filter(|name| !name.is_empty()) + .ok_or_else(|| { + Error::Upstream("Mikan detail page is unrecognized or access is restricted".into()) + })?; + let rss_url = html + .select(&selector(".bangumi-title a.mikan-rss[href]")) + .filter_map(|link| self.rss_link(link, id)) + .find(|(_, group)| group.is_none()) + .map(|(url, _)| url.to_string()) + .ok_or_else(|| Error::Upstream("Mikan series RSS link is missing or invalid".into()))?; + let mut groups = Vec::new(); + let mut seen = HashSet::new(); + for block in html.select(&selector(".subgroup-text")) { + let Some((url, Some(group_id))) = block + .select(&selector("a.mikan-rss[href]")) + .find_map(|link| self.rss_link(link, id)) + else { + continue; + }; + let Some(name) = block + .select(&selector("a[href*='/Home/PublishGroup/']")) + .next() + .map(text) + .filter(|name| !name.is_empty()) + else { + continue; + }; + if seen.insert(group_id.clone()) { + groups.push(SubtitleGroup { + group_id, + name, + rss_url: url.into(), + }); + } + } + let information = html.select(&selector(".bangumi-info")).map(text).collect(); + let bangumi_id = html + .select(&selector(".bangumi-info a[href]")) + .find_map(|link| { + let url = Url::parse(link.value().attr("href")?).ok()?; + if !matches!(url.host_str()?, "bgm.tv" | "bangumi.tv" | "chii.in") { + return None; + } + url.path() + .strip_prefix("/subject/") + .filter(|id| valid_id(id)) + .map(str::to_owned) + }); + static CSS_URL: OnceLock = OnceLock::new(); + let pattern = CSS_URL.get_or_init(|| { + regex::Regex::new(r#"url\(\s*['"]?([^'"\s)]+)['"]?\s*\)"#).expect("static regex") + }); + let poster = html + .select(&selector(".bangumi-poster[style]")) + .find_map(|element| { + let captures = pattern.captures(element.value().attr("style")?)?; + let url = self.same_origin_url(captures.get(1)?.as_str()).ok()?; + url.path().starts_with("/images/").then_some(url) + }); + let detail = SeriesDetail { + source_id: "mikan", + series_id: id.into(), + title, + page_url: page.into(), + rss_url, + groups, + information, + bangumi_id, + cover_url: poster + .as_ref() + .map(|_| format!("/api/sources/mikan/series/{id}/cover")), + poster, + }; + self.remember(key, Cached::Series(Box::new(detail.clone()))) + .await; + Ok(detail) + } + + pub async fn cover(&self, id: &str) -> Result<(String, Vec)> { + let url = self.series(id).await?.poster.ok_or(Error::NotFound)?; + let _permit = self.requests.try_acquire().map_err(|_| Error::SourceBusy)?; + let response = website_get(&self.client, url).await?; + let mime = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + if !matches!( + mime.as_str(), + "image/png" | "image/jpeg" | "image/webp" | "image/gif" + ) { + return Err(Error::Upstream( + "Mikan cover is not a supported raster image".into(), + )); + } + Ok((mime, bounded_body(response, 4 * 1024 * 1024).await?)) + } + + pub async fn resolve(&self, value: &str) -> Result { + if value.len() > 8192 { + return Err(Error::Invalid("Mikan URL is too long".into())); + } + let url = self.same_origin_url(value.trim())?; + let params = parameters(&url)?; + if matches!(url.path(), "/RSS/Search" | "/Home/Search") { + let query = params + .get("searchstr") + .map(|s| s.trim()) + .filter(|s| !s.is_empty() && s.chars().count() <= 100) + .ok_or_else(|| { + Error::Invalid("keyword RSS requires a query of 1–100 characters".into()) + })?; + if params.len() != 1 { + return Err(Error::Invalid("unsupported keyword RSS parameters".into())); + } + let mut rss = self.base.join("/RSS/Search").expect("static path"); + rss.query_pairs_mut().append_pair("searchstr", query); + let mut page = rss.clone(); + page.set_path("/Home/Search"); + return Ok(Candidate { source_id: "mikan", scope: "keyword", series_id: None, group_id: None, + page_url: page.into(), rss_url: rss.into(), subscription: None, + warnings: vec!["Keyword RSS can contain multiple series. Select a specific series, or manually preview with filters that restrict the feed to one series before creating a subscription."] }); + } + let (id, group) = if let Some(id) = url.path().strip_prefix("/Home/Bangumi/") { + (id.to_owned(), None) + } else if url.path() == "/RSS/Bangumi" { + ( + params + .get("bangumiid") + .cloned() + .ok_or_else(|| Error::Invalid("missing bangumiId".into()))?, + params.get("subgroupid").cloned(), + ) + } else { + return Err(Error::Invalid( + "expected a Mikan series page or series RSS URL".into(), + )); + }; + if group.as_ref().is_some_and(|id| !valid_id(id)) { + return Err(Error::Invalid("invalid subtitle group ID".into())); + } + let detail = self.series(&id).await?; + let rss_url = match &group { + Some(group) => detail + .groups + .iter() + .find(|g| &g.group_id == group) + .map(|g| g.rss_url.clone()) + .ok_or_else(|| { + Error::Invalid("subtitle group is not available for this series".into()) + })?, + None => detail.rss_url, + }; + Ok(Candidate { + source_id: "mikan", scope: if group.is_some() { "group" } else { "series" }, + series_id: Some(id), group_id: group, page_url: detail.page_url, rss_url: rss_url.clone(), + subscription: Some(SubscriptionInput { title: detail.title, rss_url, season: 1, episode_offset: 0, + include: Vec::new(), exclude: Vec::new(), enabled: false }), + warnings: vec!["Confirm season, episode offset and filters in preview before enabling; season 1 is a default, not inferred metadata."], + }) + } + + pub async fn search(&self, query: &str) -> Result { + let query = query.trim(); + if query.is_empty() || query.chars().count() > 100 { + return Err(Error::Invalid( + "search query must contain 1–100 characters".into(), + )); + } + let key = format!("search:{query}"); + if let Some(Cached::Search(result)) = self.cached(&key).await { + return Ok(result); + } + let _permit = self.requests.try_acquire().map_err(|_| Error::SourceBusy)?; + let mut url = self.base.join("/Home/Search").expect("static path"); + url.query_pairs_mut().append_pair("searchstr", query); + let html = self.html(url.clone()).await?; + let unrecognized = + || Error::Upstream("Mikan search page is unrecognized or access is restricted".into()); + let search_form = html + .select(&selector( + "form[action='/Home/Search'] input[name='searchstr']", + )) + .next() + .is_some(); + let results = html.select(&selector(".an-ul")).next().is_some() + || html + .select(&selector(".header2-text")) + .any(|element| text(element).contains("搜索结果")); + if !search_form || !results { + return Err(unrecognized()); + } + let mut seen = HashSet::new(); + let mut series = Vec::new(); + for link in html.select(&selector(".an-ul a[href]")) { + let Some(url) = link + .value() + .attr("href") + .and_then(|s| self.base.join(s).ok()) + else { + continue; + }; + if url.origin() != self.base.origin() { + continue; + } + let Some(id) = url + .path() + .strip_prefix("/Home/Bangumi/") + .filter(|id| valid_id(id)) + else { + continue; + }; + let Some(name) = link.select(&selector(".an-text")).next() else { + return Err(unrecognized()); + }; + let text = name.text().collect::(); + let title = name.value().attr("title").unwrap_or(&text).trim(); + if title.is_empty() { + return Err(unrecognized()); + } + if !seen.insert(id.to_owned()) { + continue; + } + series.push(SeriesSummary { + series_id: id.into(), + title: title.into(), + page_url: url.to_string(), + cover_url: link + .select(&selector("[data-src], img[src]")) + .next() + .map(|_| format!("/api/sources/mikan/series/{id}/cover")), + }); + } + url.set_path("/RSS/Search"); + let result = SearchResult { + source_id: "mikan", + query: query.into(), + series, + keyword_rss_url: url.into(), + }; + self.remember(key, Cached::Search(result.clone())).await; + Ok(result) + } +} diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..a95058c --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-2.0-only +use regex::Regex; +use std::{collections::BTreeSet, sync::LazyLock}; + +static UNSUPPORTED: LazyLock = LazyLock::new(|| { + Regex::new( + r"(?i)(合集|全集|\bbatch\b|\bcomplete\b|\b(?:OVA|SP|NCOP|NCED)(?:[0-9]+\b|\b)|(?:\s-\s*|\[|第|\bEP?\s*)[0-9]+\.[0-9]+|[0-9]+\s*[-~~]\s*[0-9]+)" +).unwrap() +}); +static SEASON_EPISODE: LazyLock = + LazyLock::new(|| Regex::new(r"(?i)\bS([0-9]{1,3})E([0-9]{1,4})(?:v[0-9]+)?(?:\b|_)").unwrap()); +static EPISODE: LazyLock> = LazyLock::new(|| { + [ + r"(?i)\[([0-9]{1,3})(?:v[0-9]+)?(?:\s*END)?\]", + r"第\s*([0-9]{1,4})\s*[话話集]", + r"(?i)\bEP?\s*([0-9]{1,4})(?:v[0-9]+)?\b", + r"(?i)\s-\s*([0-9]{1,4})(?:v[0-9]+)?(?:\s|\[|$)", + ] + .into_iter() + .map(|r| Regex::new(r).unwrap()) + .collect() +}); + +/// Conservative single-episode parsing. Ambiguity is visible, never guessed. +pub fn episode(title: &str, season: i32, offset: i32) -> std::result::Result { + if UNSUPPORTED.is_match(title) { + return Err("batch, special episode or fractional episode requires manual review"); + } + let mut candidates = BTreeSet::new(); + for captures in SEASON_EPISODE.captures_iter(title) { + if captures[1].parse::().ok() != Some(season) { + return Err("title season differs from subscription season"); + } + candidates.insert(captures[2].parse::().unwrap()); + } + for pattern in EPISODE.iter() { + for captures in pattern.captures_iter(title) { + candidates.insert(captures[1].parse::().unwrap()); + } + } + if candidates.len() != 1 { + return Err("episode is missing or ambiguous"); + } + let value = candidates + .first() + .copied() + .unwrap() + .checked_add(offset) + .ok_or("episode offset overflow")?; + if !(1..=10000).contains(&value) { + return Err("episode is outside supported range after offset"); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn single_episode_formats() { + for title in [ + "[Group] Example [03][1080p]", + "Example - 03v2 [1080p]", + "Example 第03话", + "Example S01E03", + "Example EP03", + "Example - 03 [1080p HEVC AAC2.0]", + ] { + assert_eq!(episode(title, 1, 0), Ok(3), "{title}"); + } + assert_eq!(episode("Example - 13 [1080p]", 2, -12), Ok(1)); + } + #[test] + fn uncertain_titles_are_not_downloaded() { + for title in [ + "Example [1080p]", + "Example [01-12]", + "Example - 12.5", + "Example SP01", + "Example SP01 [03]", + "Example [03] [04]", + "Example S02E03", + "Example S01E03E04", + ] { + assert!(episode(title, 1, 0).is_err(), "{title}"); + } + assert!(episode("Example [01]", 1, -1).is_err()); + } +} diff --git a/src/service.rs b/src/service.rs new file mode 100644 index 0000000..71e22f9 --- /dev/null +++ b/src/service.rs @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::{ + config::Config, + domain::*, + downloader::{self, Qbit}, + error::{Error, Result}, + feeds, + media::{self, FileReport, MediaFile}, + storage::Store, +}; +use serde::Serialize; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; +use tokio::sync::{watch, Mutex}; + +pub struct Service { + pub store: Store, + pub gate: Mutex<()>, + pub mikan: crate::mikan::Mikan, + client: reqwest::Client, +} + +#[derive(Default, Serialize)] +pub struct CycleReport { + pub scanned: usize, + pub queued: usize, + pub errors: Vec, + pub downloader_configured: bool, +} + +impl Service { + pub async fn new(store: Store, config: &Config) -> Result { + let client = feeds::website_client(&config.website)?; + let mikan = crate::mikan::Mikan::new(client.clone(), &config.mikan_base_url)?; + store.bootstrap_downloader(config.qbit.as_ref()).await?; + Ok(Self { + store, + gate: Mutex::new(()), + mikan, + client, + }) + } + + pub async fn downloader_configured(&self) -> Result { + Ok(self.store.active_downloader_id().await?.is_some()) + } + + pub async fn preview(&self, mut input: SubscriptionInput) -> Result> { + input.validate()?; + feeds::fetch(&self.client, &input).await + } + + pub async fn preview_subscription(&self, id: &str) -> Result> { + let sub = self.store.subscription(id).await?; + let mut releases = self.preview(sub.input.clone()).await?; + let mut episodes = HashSet::new(); + // Feed order determines the candidate selected in v0.1; priority rules come later. + for release in &mut releases { + if release.decision == Decision::Ready { + let duplicate = self + .store + .already_queued(id, sub.input.season, release) + .await?; + if duplicate || !episodes.insert(release.episode) { + release.decision = Decision::AlreadyQueued; + release.reason = if duplicate { + "resource or episode already has a task" + } else { + "an earlier feed entry already represents this episode" + } + .into(); + } + } + } + Ok(releases) + } + + async fn scan(&self, sub: &Subscription) -> Result { + let result = async { + let releases = self.preview_subscription(&sub.id).await?; + self.store.record_scan(sub, &releases).await + } + .await; + self.store + .checked( + &sub.id, + result.as_ref().err().map(ToString::to_string).as_deref(), + ) + .await?; + result + } + + pub async fn scan_subscription(&self, id: &str) -> Result { + let _guard = self.gate.try_lock().map_err(|_| Error::Busy)?; + let sub = self.store.subscription(id).await?; + if !sub.input.enabled { + return Err(Error::Invalid( + "enable the subscription before scanning; preview is available while disabled" + .into(), + )); + } + self.scan(&sub).await + } + + pub async fn cycle(&self) -> Result { + let _guard = self.gate.try_lock().map_err(|_| Error::Busy)?; + let mut report = CycleReport { + downloader_configured: self.downloader_configured().await?, + ..Default::default() + }; + let legacy = self.store.legacy_unassigned_count().await?; + if legacy > 0 { + report.errors.push(format!("{legacy} legacy jobs have no recorded downloader; restore their original instance and explicitly claim them")); + } + for sub in self.store.subscriptions().await? { + if !sub.input.enabled { + continue; + } + match self.scan(&sub).await { + Ok(count) => { + report.scanned += 1; + report.queued += count; + } + Err(error) => report + .errors + .push(format!("subscription {}: {error}", sub.id)), + } + } + match self.sync_downloads().await { + Ok(errors) => report.errors.extend(errors), + Err(error) => report.errors.push(error.to_string()), + } + Ok(report) + } + + async fn sync_downloads(&self) -> Result> { + let jobs = self.store.active_jobs().await?; + let mut groups: std::collections::BTreeMap> = Default::default(); + for job in jobs { + if let Some(id) = &job.downloader_id { + groups.entry(id.clone()).or_default().push(job); + } + } + let mut errors = Vec::new(); + for (id, jobs) in groups { + let result = async { + let qbit = Qbit::new(self.store.downloader_config(&id).await?)?; + self.sync_instance(&qbit, jobs).await + } + .await; + if let Err(error) = result { + if matches!(error, Error::Database(_)) { + return Err(error); + } + errors.push(format!("downloader {id}: {error}")); + } + } + Ok(errors) + } + + async fn sync_instance(&self, qbit: &Qbit, jobs: Vec) -> Result<()> { + qbit.login().await?; + // Never submit when querying the remote state fails. + let remote: HashMap<_, _> = qbit + .torrents() + .await? + .into_iter() + .map(|torrent| (torrent.hash.to_ascii_lowercase(), torrent)) + .collect(); + let mut added: HashMap> = HashMap::new(); + for mut job in jobs { + let sub = self.store.subscription(&job.subscription_id).await; + let enabled = match sub { + Ok(sub) => sub.input.enabled, + Err(Error::NotFound) => false, + Err(error) => return Err(error), + }; + let result: Result<()> = async { + if job.info_hash.is_none() { + if !enabled { return Ok(()); } + let prepared = downloader::prepare(&self.client, &job.download_url).await?; + self.store.prepare(&job.id, &prepared.hash, prepared.bytes.as_deref()).await?; + job.info_hash = Some(prepared.hash); + } + let hash = job.info_hash.as_ref().expect("prepared hash"); + if let Some(torrent) = remote.get(hash) { + if job.destination_id.is_some() && !job.download_save_path.as_deref().zip(torrent.save_path.as_deref()) + .is_some_and(|(expected, actual)| media::same_download_root(expected, actual)) { + return Err(Error::Invalid("remote save path differs from the job snapshot; refusing to move or reuse this torrent".into())); + } + if matches!(torrent.state.as_str(), "error" | "missingFiles") { + return Err(Error::Invalid("qBittorrent reports an error or missing files; inspect the downloader before retrying".into())); + } + let state = if torrent.progress >= 1.0 { "completed" } + else if torrent.progress > 0.0 { "downloading" } else { "submitted" }; + self.store.state(&job.id, state).await?; + } else if let Some(path) = added.get(hash) { + if job.destination_id.is_some() && path != &job.download_save_path { + return Err(Error::Invalid("this torrent was submitted to a different path; refusing to reuse it".into())); + } + self.store.state(&job.id, "submitted").await?; + } else if matches!(job.state.as_str(), "submitted" | "downloading") { + // Respect removal in qBittorrent; recreating a task needs an explicit retry. + self.store.state(&job.id, "missing").await?; + } else if enabled { + let bytes = self.store.torrent_bytes(&job.id).await?; + // Commit the intent before the external side effect. + self.store.state(&job.id, "submitting").await?; + qbit.add(&job.download_url, bytes, job.download_save_path.as_deref()).await?; + added.insert(hash.clone(), job.download_save_path.clone()); + self.store.state(&job.id, "submitted").await?; + } + Ok(()) + }.await; + if let Err(error) = result { + if matches!(error, Error::Database(_)) { + return Err(error); + } + if matches!(error, Error::Invalid(_)) { + self.store.state(&job.id, "failed").await?; + } + self.store.failed(&job, &error.to_string()).await?; + tracing::warn!(job_id = %job.id, error = %error, "download job failed"); + } + } + Ok(()) + } + + async fn discover_files(&self, job: &DownloadJob) -> Result> { + let hash = job + .info_hash + .as_ref() + .ok_or_else(|| Error::Invalid("torrent metadata is not available yet".into()))?; + let owner = job + .downloader_id + .as_ref() + .ok_or_else(|| Error::Invalid("downloader ownership is unknown".into()))?; + let qbit = Qbit::new(self.store.downloader_config(owner).await?)?; + qbit.login().await?; + let torrent = qbit + .torrents() + .await? + .into_iter() + .find(|torrent| torrent.hash.eq_ignore_ascii_case(hash)) + .ok_or_else(|| { + Error::Upstream("torrent is not available in its original downloader".into()) + })?; + if !job + .download_save_path + .as_deref() + .zip(torrent.save_path.as_deref()) + .is_some_and(|(expected, actual)| media::same_download_root(expected, actual)) + { + return Err(Error::Upstream( + "remote save path does not match the frozen destination mapping".into(), + )); + } + let mut files = Vec::new(); + let mut paths = HashSet::new(); + for file in qbit + .files(hash) + .await? + .into_iter() + .filter(|file| file.priority != 0) + { + media::validate_relative(&file.name, false).map_err(|_| { + Error::Upstream("downloader returned an unsafe relative file path".into()) + })?; + if !paths.insert(file.name.clone()) { + return Err(Error::Upstream( + "duplicate path in downloader file list".into(), + )); + } + files.push(MediaFile { + relative_path: file.name, + expected_size: file.size, + observed_size: None, + status: "unchecked".into(), + error: None, + }); + } + Ok(files) + } + + pub async fn check_job_files(&self, id: &str) -> Result { + // Serialize manifest discovery/checks with task synchronization and configuration changes. + let _guard = self.gate.try_lock().map_err(|_| Error::Busy)?; + let job = self.store.job(id).await?; + let Some(destination) = &job.destination_id else { + self.store + .save_file_check( + id, + "unchecked", + Some("job has no recorded media destination"), + &[], + ) + .await?; + return self.store.file_report(id).await; + }; + let root = self.store.destination(destination).await?.root_path; + let mut files = self.store.file_report(id).await?.files; + if files.is_empty() { + if job.info_hash.is_none() { + self.store + .save_file_check( + id, + "unchecked", + Some("torrent metadata is not available yet"), + &[], + ) + .await?; + return self.store.file_report(id).await; + } + match self.discover_files(&job).await { + Ok(manifest) => files = manifest, + Err(error @ Error::Database(_)) => return Err(error), + Err(error) => { + self.store + .save_file_check(id, "unavailable", Some(&error.to_string()), &[]) + .await?; + return self.store.file_report(id).await; + } + } + if files.is_empty() { + self.store + .save_file_check( + id, + "unchecked", + Some("no selected files or torrent metadata is still pending"), + &[], + ) + .await?; + return self.store.file_report(id).await; + } + self.store.save_manifest(id, &files).await?; + } + let files = tokio::task::spawn_blocking(move || media::check_local(&root, files)) + .await + .map_err(|_| Error::Upstream("local file check failed".into()))?; + let status = if files.iter().any(|f| f.status == "unavailable") { + "unavailable" + } else if files.iter().any(|f| f.status == "absent") { + "absent" + } else { + "present" + }; + let error = files.iter().find_map(|file| file.error.as_deref()); + self.store + .save_file_check(id, status, error, &files) + .await?; + self.store.file_report(id).await + } +} + +pub async fn worker( + service: Arc, + interval: std::time::Duration, + mut shutdown: watch::Receiver, +) { + let mut timer = tokio::time::interval(interval); + timer.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = shutdown.changed() => break, + _ = timer.tick() => { + // Cancellation can interrupt network I/O; persisted submission intent allows recovery. + tokio::select! { + _ = shutdown.changed() => break, + result = service.cycle() => match result { + Ok(report) => for error in report.errors { tracing::warn!(%error, "background cycle issue"); }, + Err(Error::Busy) => {}, + Err(error) => tracing::error!(%error, "background cycle failed"), + } + } + } + } + } +} diff --git a/src/storage.rs b/src/storage.rs new file mode 100644 index 0000000..2983c96 --- /dev/null +++ b/src/storage.rs @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: GPL-2.0-only +use crate::{ + domain::*, + error::{Error, Result}, +}; +use sqlx::{ + sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}, + SqlitePool, +}; +use std::{ + str::FromStr, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +#[derive(Clone)] +pub struct Store { + pub pool: SqlitePool, +} + +pub fn now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +impl Store { + pub async fn active_downloader_id(&self) -> Result> { + Ok( + sqlx::query_scalar( + "SELECT downloader_id FROM downloader_selection WHERE singleton = 1", + ) + .fetch_one(&self.pool) + .await?, + ) + } + + pub async fn downloaders(&self) -> Result> { + let rows: Vec<(String, String, String, bool)> = sqlx::query_as( + "SELECT d.id, d.name, d.config, COALESCE(s.downloader_id = d.id, 0) FROM downloader_instances d CROSS JOIN downloader_selection s ORDER BY d.rowid") + .fetch_all(&self.pool).await?; + rows.into_iter() + .map(|(id, name, json, is_active)| { + let config: crate::config::QbitConfig = serde_json::from_str(&json)?; + Ok(DownloaderView { + id, + name, + kind: DownloaderKind::Qbittorrent, + url: config.url, + username: config.username, + save_path: config.save_path, + password_configured: !config.password.is_empty(), + is_active, + }) + }) + .collect() + } + + pub async fn create_downloader(&self, mut input: DownloaderInput) -> Result { + input.validate()?; + let id = uuid::Uuid::new_v4().to_string(); + let result = sqlx::query("INSERT INTO downloader_instances(id, name, kind, endpoint, config) VALUES (?, ?, 'qbittorrent', ?, ?) ON CONFLICT(endpoint) DO NOTHING") + .bind(&id).bind(&input.name).bind(&input.connection.url) + .bind(serde_json::to_string(&input.connection)?).execute(&self.pool).await?; + if result.rows_affected() == 0 { + return Err(Error::Invalid( + "this downloader endpoint is already registered".into(), + )); + } + self.downloaders() + .await? + .into_iter() + .find(|d| d.id == id) + .ok_or(Error::NotFound) + } + + pub(crate) async fn downloader_config(&self, id: &str) -> Result { + let json: String = + sqlx::query_scalar("SELECT config FROM downloader_instances WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await? + .ok_or(Error::NotFound)?; + Ok(serde_json::from_str(&json)?) + } + + pub async fn activate_downloader(&self, id: &str) -> Result<()> { + let mut tx = self.pool.begin().await?; + let exists: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM downloader_instances WHERE id = ?)") + .bind(id) + .fetch_one(&mut *tx) + .await?; + if !exists { + return Err(Error::NotFound); + } + let destination: Option = sqlx::query_scalar( + "SELECT destination_id FROM destination_selection WHERE singleton = 1", + ) + .fetch_one(&mut *tx) + .await?; + if let Some(destination) = destination { + let mapped: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM destination_mappings WHERE destination_id = ? AND downloader_id = ?)") + .bind(destination).bind(id).fetch_one(&mut *tx).await?; + if !mapped { + return Err(Error::Invalid("configure a mapping to the active destination before selecting this downloader".into())); + } + } + sqlx::query("UPDATE downloader_selection SET downloader_id = ? WHERE singleton = 1") + .bind(id) + .execute(&mut *tx) + .await?; + // Bind only new, never-owned work. A switch never rewrites an existing owner. + sqlx::query("UPDATE download_jobs SET downloader_id = ? WHERE downloader_id IS NULL AND legacy_unassigned = 0") + .bind(id).execute(&mut *tx).await?; + tx.commit().await?; + Ok(()) + } + + pub async fn claim_legacy_jobs(&self, id: &str) -> Result { + self.downloader_config(id).await?; + Ok(sqlx::query("UPDATE download_jobs SET downloader_id = ?, legacy_unassigned = 0 WHERE downloader_id IS NULL AND legacy_unassigned = 1") + .bind(id).execute(&self.pool).await?.rows_affected()) + } + + pub async fn legacy_unassigned_count(&self) -> Result { + Ok(sqlx::query_scalar("SELECT COUNT(*) FROM download_jobs WHERE legacy_unassigned = 1 AND downloader_id IS NULL") + .fetch_one(&self.pool).await?) + } + + pub(crate) async fn bootstrap_downloader( + &self, + config: Option<&crate::config::QbitConfig>, + ) -> Result<()> { + let Some(config) = config else { + return Ok(()); + }; + let mut config = config.clone(); + config.validate()?; + let mut tx = self.pool.begin().await?; + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM downloader_instances") + .fetch_one(&mut *tx) + .await?; + if count == 0 { + let id = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO downloader_instances(id, name, kind, endpoint, config) VALUES (?, 'Initial qBittorrent', 'qbittorrent', ?, ?)") + .bind(&id).bind(&config.url).bind(serde_json::to_string(&config)?).execute(&mut *tx).await?; + sqlx::query("UPDATE downloader_selection SET downloader_id = ? WHERE singleton = 1") + .bind(&id) + .execute(&mut *tx) + .await?; + // Initial import uses the operator-supplied original QBIT_* connection. + sqlx::query("UPDATE download_jobs SET downloader_id = ?, legacy_unassigned = 0 WHERE downloader_id IS NULL") + .bind(&id).execute(&mut *tx).await?; + } + tx.commit().await?; + Ok(()) + } + + pub async fn open(url: &str) -> Result { + let options = SqliteConnectOptions::from_str(url)? + .create_if_missing(true) + .foreign_keys(true) + .journal_mode(SqliteJournalMode::Wal) + .busy_timeout(Duration::from_secs(5)); + // A single connection also makes sqlite::memory: deterministic in tests. + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await?; + sqlx::migrate!().run(&pool).await?; + Ok(Self { pool }) + } + + pub async fn subscriptions(&self) -> Result> { + let rows = sqlx::query_as::<_, (String, String, Option, Option)>( + "SELECT id, spec, last_checked_at, last_error FROM subscriptions WHERE deleted = 0 ORDER BY rowid" + ).fetch_all(&self.pool).await?; + rows.into_iter() + .map(|(id, spec, last_checked_at, last_error)| { + Ok(Subscription { + id, + input: serde_json::from_str(&spec)?, + last_checked_at, + last_error, + }) + }) + .collect() + } + + pub async fn subscription(&self, id: &str) -> Result { + self.subscriptions() + .await? + .into_iter() + .find(|s| s.id == id) + .ok_or(Error::NotFound) + } + + pub async fn create(&self, mut input: SubscriptionInput) -> Result { + input.validate()?; + let id = uuid::Uuid::new_v4().to_string(); + sqlx::query("INSERT INTO subscriptions(id, spec) VALUES (?, ?)") + .bind(&id) + .bind(serde_json::to_string(&input)?) + .execute(&self.pool) + .await?; + self.subscription(&id).await + } + + pub async fn update(&self, id: &str, mut input: SubscriptionInput) -> Result { + input.validate()?; + let result = sqlx::query("UPDATE subscriptions SET spec = ? WHERE id = ? AND deleted = 0") + .bind(serde_json::to_string(&input)?) + .bind(id) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(Error::NotFound); + } + self.subscription(id).await + } + + pub async fn delete(&self, id: &str) -> Result<()> { + let result = + sqlx::query("UPDATE subscriptions SET deleted = 1 WHERE id = ? AND deleted = 0") + .bind(id) + .execute(&self.pool) + .await?; + if result.rows_affected() == 0 { + return Err(Error::NotFound); + } + Ok(()) + } + + pub async fn checked(&self, id: &str, error: Option<&str>) -> Result<()> { + sqlx::query("UPDATE subscriptions SET last_checked_at = ?, last_error = ? WHERE id = ?") + .bind(now()) + .bind(error) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn already_queued(&self, id: &str, season: i32, release: &Release) -> Result { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM download_jobs WHERE subscription_id = ? AND (resource_key = ? OR (season = ? AND episode = ?))") + .bind(id).bind(&release.key).bind(season).bind(release.episode).fetch_one(&self.pool).await?; + Ok(count > 0) + } + + pub async fn record_scan(&self, sub: &Subscription, releases: &[Release]) -> Result { + let mut tx = self.pool.begin().await?; + let downloader_id: Option = sqlx::query_scalar( + "SELECT downloader_id FROM downloader_selection WHERE singleton = 1", + ) + .fetch_one(&mut *tx) + .await?; + let destination_id: Option = sqlx::query_scalar( + "SELECT destination_id FROM destination_selection WHERE singleton = 1", + ) + .fetch_one(&mut *tx) + .await?; + let mut mapping_id = None; + let mut download_save_path = None; + if let Some(destination) = &destination_id { + let mapping: crate::media::Mapping = sqlx::query_as("SELECT * FROM destination_mappings WHERE destination_id = ? AND downloader_id = ? ORDER BY version DESC LIMIT 1") + .bind(destination).bind(&downloader_id).fetch_optional(&mut *tx).await? + .ok_or_else(|| Error::Invalid("active destination has no mapping for the selected downloader".into()))?; + mapping_id = Some(mapping.id); + download_save_path = Some(mapping.download_root); + } else if let Some(downloader) = &downloader_id { + let config: String = + sqlx::query_scalar("SELECT config FROM downloader_instances WHERE id = ?") + .bind(downloader) + .fetch_one(&mut *tx) + .await?; + download_save_path = + Some(serde_json::from_str::(&config)?.save_path); + } + let mut queued = 0; + for release in releases { + sqlx::query("INSERT INTO releases(subscription_id, resource_key, payload) VALUES (?, ?, ?) ON CONFLICT(subscription_id, resource_key) DO UPDATE SET payload = excluded.payload") + .bind(&sub.id).bind(&release.key).bind(serde_json::to_string(release)?).execute(&mut *tx).await?; + if release.decision == Decision::Ready { + if let (Some(episode), Some(url)) = (release.episode, &release.download_url) { + let result = sqlx::query("INSERT OR IGNORE INTO download_jobs(id, subscription_id, resource_key, title, season, episode, download_url, downloader_id, destination_id, mapping_id, download_save_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + .bind(uuid::Uuid::new_v4().to_string()).bind(&sub.id).bind(&release.key) + .bind(&release.title).bind(sub.input.season).bind(episode).bind(url).bind(&downloader_id) + .bind(&destination_id).bind(&mapping_id).bind(&download_save_path).execute(&mut *tx).await?; + queued += result.rows_affected() as usize; + } + } + } + tx.commit().await?; + Ok(queued) + } + + pub async fn releases(&self, id: &str) -> Result> { + self.subscription(id).await?; + let rows: Vec = sqlx::query_scalar( + "SELECT payload FROM releases WHERE subscription_id = ? ORDER BY rowid DESC LIMIT 500", + ) + .bind(id) + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|s| serde_json::from_str(&s).map_err(Error::from)) + .collect() + } + + pub async fn jobs(&self) -> Result> { + Ok( + sqlx::query_as("SELECT * FROM download_jobs ORDER BY rowid DESC LIMIT 500") + .fetch_all(&self.pool) + .await?, + ) + } + + pub async fn active_jobs(&self) -> Result> { + Ok(sqlx::query_as("SELECT * FROM download_jobs WHERE state NOT IN ('completed', 'missing', 'failed') AND next_attempt_at <= ? ORDER BY rowid") + .bind(now()).fetch_all(&self.pool).await?) + } + + pub async fn prepare(&self, id: &str, hash: &str, bytes: Option<&[u8]>) -> Result<()> { + sqlx::query("UPDATE download_jobs SET info_hash = ?, torrent_bytes = ? WHERE id = ?") + .bind(hash) + .bind(bytes) + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn torrent_bytes(&self, id: &str) -> Result>> { + Ok( + sqlx::query_scalar("SELECT torrent_bytes FROM download_jobs WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await?, + ) + } + + pub async fn state(&self, id: &str, state: &str) -> Result<()> { + sqlx::query("UPDATE download_jobs SET state = ?, last_error = NULL, next_attempt_at = 0 WHERE id = ?") + .bind(state).bind(id).execute(&self.pool).await?; + Ok(()) + } + + pub async fn failed(&self, job: &DownloadJob, error: &str) -> Result<()> { + // Keep 'submitting' on an ambiguous network failure, so reconciliation comes first. + let delay = 30 * (1_i64 << job.attempts.clamp(0, 7)); + sqlx::query("UPDATE download_jobs SET attempts = attempts + 1, last_error = ?, next_attempt_at = ? WHERE id = ?") + .bind(error).bind(now() + delay.min(3600)).bind(&job.id).execute(&self.pool).await?; + Ok(()) + } + + pub async fn retry(&self, id: &str) -> Result<()> { + let result = sqlx::query("UPDATE download_jobs SET state = 'queued', attempts = 0, next_attempt_at = 0, last_error = NULL WHERE id = ? AND state IN ('failed', 'missing')") + .bind(id).execute(&self.pool).await?; + if result.rows_affected() == 0 { + return Err(Error::Invalid( + "only failed or missing jobs can be retried".into(), + )); + } + Ok(()) + } +} diff --git a/tests/fixtures/releases.xml b/tests/fixtures/releases.xml new file mode 100644 index 0000000..3fdb01f --- /dev/null +++ b/tests/fixtures/releases.xml @@ -0,0 +1,28 @@ + + + + Local test releases + https://example.invalid/ + Synthetic fixtures; no real media + + release-3 + [Group] Example - 03 [1080p] + + + + release-3-other + [Group] Example - 03v2 [1080p] + + + + batch + [Group] Example [01-12] [1080p] + + + + low-resolution + [Group] Example - 04 [720p] + + + + diff --git a/tests/local_media.rs b/tests/local_media.rs new file mode 100644 index 0000000..044cd2a --- /dev/null +++ b/tests/local_media.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-2.0-only +use actix_web::{test, web, App}; +use rust_project::{api, config::Config, service::Service, storage::Store}; +use serde_json::{json, Value}; +use std::{sync::Arc, time::Duration}; + +#[actix_web::test] +async fn directory_api_rejects_traversal_and_does_not_follow_links_outside_root() { + let directory = std::env::temp_dir().join(format!("media-browse-{}", uuid::Uuid::new_v4())); + let root = directory.join("root"); + std::fs::create_dir_all(root.join("Series")).unwrap(); + std::fs::create_dir(directory.join("outside")).unwrap(); + std::fs::write(root.join("Series/ep01.mkv"), b"abc").unwrap(); + std::fs::write(directory.join("outside/secret"), b"outside").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(directory.join("outside"), root.join("escape")).unwrap(); + let config = Config { + database_url: "sqlite::memory:".into(), + port: 8080, + poll_interval: Duration::from_secs(300), + qbit: None, + mikan_base_url: "https://mikanani.me".into(), + website: Default::default(), + }; + let service = Arc::new( + Service::new(Store::open("sqlite::memory:").await.unwrap(), &config) + .await + .unwrap(), + ); + let app = test::init_service( + App::new() + .app_data(web::Data::new(service)) + .configure(api::configure), + ) + .await; + for body in [ + json!({"name":"bad", "kind":"local", "root_path":"relative"}), + json!({"name":"bad", "kind":"smb", "root_path":"/share"}), + ] { + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/api/destinations") + .set_json(body) + .to_request(), + ) + .await; + assert_eq!(response.status(), 400); + } + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/api/destinations") + .set_json(json!({"name":"Local", "kind":"local", "root_path":root.to_str().unwrap()})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 201); + let destination: Value = test::read_body_json(response).await; + let id = destination["id"].as_str().unwrap(); + let response = test::call_service( + &app, + test::TestRequest::put() + .uri("/api/destinations/active") + .set_json(json!({"destination_id":id})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 400); // No downloader mapping: never silently use a fallback. + let downloader: Value = test::call_and_read_body_json(&app, test::TestRequest::post().uri("/api/downloaders") + .set_json(json!({"name":"Local qBit", "kind":"qbittorrent", "connection":{ + "url":"http://127.0.0.1:1", "username":"", "password":"", "save_path":"/downloads" + }})).to_request()).await; + let response = test::call_service( + &app, + test::TestRequest::put() + .uri("/api/downloaders/active") + .set_json(json!({"downloader_id":downloader["id"]})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 204); + let response = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!("/api/destinations/{id}/mappings")) + .set_json(json!({"downloader_id":downloader["id"],"download_root":"/downloads/local"})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 201); + let mapping: Value = test::read_body_json(response).await; + assert_eq!(mapping["version"], 1); + let response = test::call_service( + &app, + test::TestRequest::put() + .uri("/api/destinations/active") + .set_json(json!({"destination_id":id})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 204); + let status: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get().uri("/api/status").to_request(), + ) + .await; + assert_eq!(status["active_destination_id"], id); + let base = format!("/api/destinations/{id}/entries"); + let listing: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get() + .uri(&format!("{base}?path=Series")) + .to_request(), + ) + .await; + assert_eq!(listing["status"], "available"); + assert_eq!(listing["entries"][0]["name"], "ep01.mkv"); + assert_eq!(listing["entries"][0]["size"], 3); + for path in [ + "../outside", + "%2Fetc", + "C%3A%2FWindows", + "Series%5C..%5Coutside", + "Series/../outside", + "Series//bad", + ] { + let response = test::call_service( + &app, + test::TestRequest::get() + .uri(&format!("{base}?path={path}")) + .to_request(), + ) + .await; + assert_eq!(response.status(), 400, "{path}"); + } + #[cfg(unix)] + { + let listing: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get() + .uri(&format!("{base}?path=escape")) + .to_request(), + ) + .await; + assert_eq!(listing["status"], "unavailable"); + assert_eq!(listing["entries"], json!([])); + } + std::fs::rename(&root, directory.join("offline")).unwrap(); + let listing: Value = + test::call_and_read_body_json(&app, test::TestRequest::get().uri(&base).to_request()).await; + assert_eq!(listing["status"], "unavailable"); + std::fs::remove_dir_all(directory).unwrap(); +} diff --git a/tests/mikan.rs b/tests/mikan.rs new file mode 100644 index 0000000..0a4796f --- /dev/null +++ b/tests/mikan.rs @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: GPL-2.0-only +use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer}; +use rust_project::{api, config::Config, service::Service, storage::Store}; +use serde_json::Value; +use std::{net::TcpListener, sync::Arc, time::Duration}; + +const SEARCH: &str = r#"
+"#; + +// Independently authored fixtures; publish-group ID deliberately differs from RSS group ID. +const DETAIL: &str = r#"

测试番剧 +

+

放送日期:星期五

+

Bangumi番组计划链接:详情

+"#; + +async fn mock() -> (String, ServerHandle) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = HttpServer::new(|| { + App::new().default_service(web::to(|req: HttpRequest| async move { + if req.path() == "/Home/Bangumi/202" { + return HttpResponse::Ok().content_type("text/html").body(format!( + "{}
", + DETAIL.replace("101", "202"))); + } + if req.path() == "/images/Bangumi/cover.png" { + return HttpResponse::Ok().content_type("image/png").body(b"\x89PNG\r\n\x1a\nfixture".as_slice()); + } + if req.path() == "/Home/Bangumi/101" { + return HttpResponse::Ok().content_type("text/html").body(DETAIL); + } + if req.path() == "/RSS/Bangumi" { + assert_eq!(req.query_string(), "bangumiId=101&subgroupid=12"); + return HttpResponse::Ok() + .content_type("application/rss+xml") + .body(include_str!("fixtures/releases.xml")); + } + assert_eq!(req.path(), "/Home/Search"); + let query = web::Query::>::from_query( + req.query_string(), + ) + .unwrap(); + match query.get("searchstr").map(String::as_str) { + Some("empty") => return HttpResponse::Ok().content_type("text/html") + .body(r#"
    "#), + Some("challenge") => return HttpResponse::Ok().content_type("text/html").body("

    Verify your browser

    "), + Some("changed") => return HttpResponse::Ok().content_type("text/html") + .body(r#"
    "#), + _ => {} + } + assert_eq!(query.get("searchstr").unwrap(), "测试 & S2"); + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(SEARCH) + })) + }) + .workers(1) + .listen(listener) + .unwrap() + .run(); + let handle = server.handle(); + actix_web::rt::spawn(server); + (url, handle) +} + +#[actix_web::test] +async fn covers_use_an_application_url_and_details_remain_available_in_bounded_cache() { + let (base, server) = mock().await; + let service = Service::new( + Store::open("sqlite::memory:").await.unwrap(), + &config(&base), + ) + .await + .unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(Arc::new(service))) + .configure(api::configure), + ) + .await; + let detail: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get() + .uri("/api/sources/mikan/series/202") + .to_request(), + ) + .await; + assert_eq!(detail["cover_url"], "/api/sources/mikan/series/202/cover"); + let response = test::call_service( + &app, + test::TestRequest::get() + .uri(detail["cover_url"].as_str().unwrap()) + .to_request(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!(response.headers().get("Content-Type").unwrap(), "image/png"); + assert_eq!( + test::read_body(response).await.as_ref(), + b"\x89PNG\r\n\x1a\nfixture" + ); + server.stop(false).await; + let cached: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get() + .uri("/api/sources/mikan/series/202") + .to_request(), + ) + .await; + assert_eq!(cached, detail); +} + +#[actix_web::test] +async fn empty_results_are_distinct_from_challenges_or_changed_markup() { + let (base, server) = mock().await; + let service = Service::new( + Store::open("sqlite::memory:").await.unwrap(), + &config(&base), + ) + .await + .unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(Arc::new(service))) + .configure(api::configure), + ) + .await; + for (query, status) in [ + ("empty", 200), + ("challenge", 502), + ("changed", 502), + ("%20", 400), + ] { + let response = test::call_service( + &app, + test::TestRequest::get() + .uri(&format!("/api/sources/mikan/search?q={query}")) + .to_request(), + ) + .await; + assert_eq!(response.status(), status, "query {query}"); + if status == 200 { + let value: Value = test::read_body_json(response).await; + assert_eq!(value["series"], serde_json::json!([])); + } + } + server.stop(false).await; +} + +#[actix_web::test] +async fn group_candidate_can_be_previewed_and_saved_disabled_without_downloading() { + let (base, server) = mock().await; + let service = Service::new( + Store::open("sqlite::memory:").await.unwrap(), + &config(&base), + ) + .await + .unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(Arc::new(service))) + .configure(api::configure), + ) + .await; + let response = test::call_service( + &app, + test::TestRequest::get() + .uri("/api/sources/mikan/series/101") + .to_request(), + ) + .await; + assert_eq!(response.status(), 200); + let detail: Value = test::read_body_json(response).await; + assert_eq!(detail["title"], "测试番剧"); + assert_eq!(detail["bangumi_id"], "400602"); + assert_eq!(detail["groups"][0]["group_id"], "12"); + assert_eq!(detail["groups"][0]["name"], "字幕组 & A"); + assert!(detail["cover_url"].is_null()); + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/api/sources/mikan/resolve") + .set_json(serde_json::json!({"url": detail["groups"][0]["rss_url"]})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 200); + let candidate: Value = test::read_body_json(response).await; + assert_eq!(candidate["scope"], "group"); + assert_eq!(candidate["series_id"], "101"); + assert_eq!(candidate["group_id"], "12"); + assert_eq!(candidate["subscription"]["enabled"], false); + let preview: Value = test::call_and_read_body_json( + &app, + test::TestRequest::post() + .uri("/api/preview") + .set_json(&candidate["subscription"]) + .to_request(), + ) + .await; + assert_eq!(preview[0]["episode"], 3); + let saved = test::call_service( + &app, + test::TestRequest::post() + .uri("/api/subscriptions") + .set_json(&candidate["subscription"]) + .to_request(), + ) + .await; + assert_eq!(saved.status(), 201); + let jobs: Value = + test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) + .await; + assert_eq!(jobs, serde_json::json!([])); + server.stop(true).await; +} + +fn config(base: &str) -> Config { + Config { + database_url: "sqlite::memory:".into(), + port: 8080, + poll_interval: Duration::from_secs(300), + qbit: None, + mikan_base_url: base.into(), + website: Default::default(), + } +} + +#[actix_web::test] +async fn resolver_marks_keyword_scope_and_rejects_foreign_or_ambiguous_urls() { + let (base, server) = mock().await; + let service = Service::new( + Store::open("sqlite::memory:").await.unwrap(), + &config(&base), + ) + .await + .unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(Arc::new(service))) + .configure(api::configure), + ) + .await; + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/api/sources/mikan/resolve") + .set_json(serde_json::json!({"url": format!("{base}/RSS/Search?searchstr=test")})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 200); + let candidate: Value = test::read_body_json(response).await; + assert_eq!(candidate["scope"], "keyword"); + assert!(candidate["series_id"].is_null()); + assert!(candidate["subscription"].is_null()); + assert!(!candidate["warnings"].as_array().unwrap().is_empty()); + for url in [ + "https://other.invalid/Home/Bangumi/101".into(), + format!("{base}/RSS/Bangumi?bangumiId=101&bangumiId=202"), + format!("{base}/RSS/Bangumi?bangumiId=101&subgroupid=999"), + format!("{base}/Home/Bangumi/not-an-id"), + ] { + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/api/sources/mikan/resolve") + .set_json(serde_json::json!({"url": url})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 400); + } + server.stop(false).await; +} + +#[actix_web::test] +async fn search_discovers_distinct_series_and_encoded_keyword_rss_without_creating_jobs() { + let (base, server) = mock().await; + let service = Service::new( + Store::open("sqlite::memory:").await.unwrap(), + &config(&base), + ) + .await + .unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(Arc::new(service))) + .configure(api::configure), + ) + .await; + let response = test::call_service( + &app, + test::TestRequest::get() + .uri("/api/sources/mikan/search?q=%E6%B5%8B%E8%AF%95%20%26%20S2") + .to_request(), + ) + .await; + assert_eq!(response.status(), 200); + let body: Value = test::read_body_json(response).await; + assert_eq!(body["series"][0]["series_id"], "101"); + assert_eq!(body["series"][1]["title"], "测试番剧 第二季"); + let rss = reqwest::Url::parse(body["keyword_rss_url"].as_str().unwrap()).unwrap(); + assert_eq!(rss.path(), "/RSS/Search"); + assert_eq!( + rss.query_pairs().collect::>(), + vec![("searchstr".into(), "测试 & S2".into())] + ); + let jobs: Value = + test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) + .await; + assert_eq!(jobs, serde_json::json!([])); + server.stop(true).await; +} diff --git a/tests/network.rs b/tests/network.rs new file mode 100644 index 0000000..8b1b546 --- /dev/null +++ b/tests/network.rs @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: GPL-2.0-only +use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer}; +use rust_project::{ + api, + config::{Config, QbitConfig}, + service::Service, + storage::Store, +}; +use serde_json::{json, Value}; +use std::{net::TcpListener, sync::Arc, time::Duration}; + +async fn server(proxy: bool) -> (String, ServerHandle) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server = HttpServer::new(move || App::new().default_service(web::to(move |req: HttpRequest| async move { + if proxy { + assert_eq!(req.headers().get("Proxy-Authorization").and_then(|h| h.to_str().ok()), Some("Basic dXNlcjpwYXNz"), "request {}", req.uri()); + if req.uri().host() == Some("127.0.0.1") { return HttpResponse::BadGateway().finish(); } + match req.path() { + "/to-direct" => { + let query = web::Query::>::from_query(req.query_string()).unwrap(); + HttpResponse::Found().insert_header(("Location", query.get("target").unwrap().as_str())).finish() + }, + "/Home/Search" => HttpResponse::Found().insert_header(("Location", "http://assets.invalid/results")).finish(), + "/results" => { + assert_eq!(req.uri().host(), Some("assets.invalid")); + HttpResponse::Ok().content_type("text/html").body(r#"
    "#) + }, + "/feed" => HttpResponse::Ok().body(include_str!("fixtures/releases.xml")), + _ => HttpResponse::BadGateway().finish(), + } + } else { + assert!(req.headers().get("Proxy-Authorization").is_none()); + match req.path() { + "/api/v2/auth/login" => HttpResponse::Ok().insert_header(("Set-Cookie", "SID=test; Path=/")).body("Ok."), + "/api/v2/torrents/info" => HttpResponse::Ok().json(json!([])), + "/feed" => HttpResponse::Ok().body(include_str!("fixtures/releases.xml")), + _ => HttpResponse::NotFound().finish(), + } + } + }))).workers(1).listen(listener).unwrap().run(); + let handle = server.handle(); + actix_web::rt::spawn(server); + (base, handle) +} + +#[actix_web::test] +async fn direct_exceptions_are_explicit_and_proxy_failure_never_falls_back() { + let (proxy, proxy_server) = server(true).await; + let (direct, direct_server) = server(false).await; + let proxy = proxy.replacen("http://", "http://user:pass@", 1); + for (direct_hosts, status) in [("127.0.0.1", 200), ("", 502)] { + let config = Config { + database_url: "sqlite::memory:".into(), + port: 8080, + poll_interval: Duration::from_secs(300), + qbit: None, + mikan_base_url: "http://mikan.invalid".into(), + website: rust_project::config::WebsiteConfig { + proxy_url: Some(proxy.clone()), + direct_hosts: direct_hosts.into(), + }, + }; + let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config) + .await + .unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(Arc::new(service))) + .configure(api::configure), + ) + .await; + let mut redirect = reqwest::Url::parse("http://unresolvable.invalid/to-direct").unwrap(); + redirect + .query_pairs_mut() + .append_pair("target", &format!("{direct}/feed")); + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/api/preview") + .set_json(json!({"title":"Test", "rss_url": redirect.as_str()})) + .to_request(), + ) + .await; + assert_eq!(response.status(), status); + if status == 200 { + let preview: Value = test::read_body_json(response).await; + assert_eq!(preview[0]["episode"], 3); + } else { + let body = String::from_utf8(test::read_body(response).await.to_vec()).unwrap(); + assert!(!body.contains("user:pass")); + } + } + proxy_server.stop(false).await; + direct_server.stop(false).await; +} + +#[actix_web::test] +async fn website_proxy_handles_auth_redirects_and_rss_while_downloader_stays_direct() { + let (proxy, proxy_server) = server(true).await; + let (direct, direct_server) = server(false).await; + let mut proxy_url = reqwest::Url::parse(&proxy).unwrap(); + proxy_url.set_username("user").unwrap(); + proxy_url.set_password(Some("pass")).unwrap(); + let config = Config { + database_url: "sqlite::memory:".into(), + port: 8080, + poll_interval: Duration::from_secs(300), + mikan_base_url: "http://mikan.invalid".into(), + website: rust_project::config::WebsiteConfig { + proxy_url: Some(proxy_url.into()), + direct_hosts: String::new(), + }, + qbit: Some(QbitConfig { + url: direct.clone(), + username: "test".into(), + password: "test".into(), + save_path: "/downloads".into(), + }), + }; + let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config) + .await + .unwrap(); + let app = test::init_service( + App::new() + .app_data(web::Data::new(Arc::new(service))) + .configure(api::configure), + ) + .await; + let search: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get() + .uri("/api/sources/mikan/search?q=test") + .to_request(), + ) + .await; + assert_eq!(search["series"][0]["title"], "经代理搜索"); + let preview: Value = test::call_and_read_body_json( + &app, + test::TestRequest::post() + .uri("/api/preview") + .set_json(json!({"title":"Test", "rss_url":"http://unresolvable.invalid/feed"})) + .to_request(), + ) + .await; + assert_eq!(preview[0]["episode"], 3); + let cycle: Value = + test::call_and_read_body_json(&app, test::TestRequest::post().uri("/api/run").to_request()) + .await; + assert_eq!(cycle["errors"], json!([])); + assert_eq!(cycle["downloader_configured"], true); + proxy_server.stop(false).await; + direct_server.stop(false).await; +} diff --git a/tests/pipeline.rs b/tests/pipeline.rs new file mode 100644 index 0000000..c6c21d9 --- /dev/null +++ b/tests/pipeline.rs @@ -0,0 +1,1100 @@ +// SPDX-License-Identifier: GPL-2.0-only +use actix_web::{dev::ServerHandle, web, App, HttpRequest, HttpResponse, HttpServer}; +use rust_project::{ + api, + config::{Config, QbitConfig}, + domain::{Decision, SubscriptionInput}, + feeds, + service::Service, + storage::Store, +}; +use serde_json::{json, Value}; +use std::{ + net::TcpListener, + sync::{Arc, Mutex}, + time::Duration, +}; + +const FEED: &str = include_str!("fixtures/releases.xml"); +const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const TORRENT: &[u8] = + b"d4:infod6:lengthi1e4:name1:a12:piece lengthi16384e6:pieces20:aaaaaaaaaaaaaaaaaaaaee"; + +#[derive(Default)] +struct Remote { + adds: usize, + torrents: Vec, + fail_list: bool, + fail_after_add: bool, + feed: Option, + files: Option>, + save_paths: Vec, +} + +async fn login() -> HttpResponse { + HttpResponse::Ok() + .insert_header(("Set-Cookie", "SID=test; Path=/")) + .body("Ok.") +} + +async fn rss(state: web::Data>>) -> HttpResponse { + HttpResponse::Ok().content_type("application/rss+xml").body( + state + .lock() + .unwrap() + .feed + .clone() + .unwrap_or_else(|| FEED.into()), + ) +} + +fn authenticated(req: &HttpRequest) -> bool { + req.cookie("SID").is_some_and(|c| c.value() == "test") +} + +async fn info(req: HttpRequest, state: web::Data>>) -> HttpResponse { + if !authenticated(&req) { + return HttpResponse::Forbidden().finish(); + } + let remote = state.lock().unwrap(); + if remote.fail_list { + return HttpResponse::ServiceUnavailable().finish(); + } + HttpResponse::Ok().json(&remote.torrents) +} + +async fn add( + req: HttpRequest, + body: web::Bytes, + state: web::Data>>, +) -> HttpResponse { + if !authenticated(&req) { + return HttpResponse::Forbidden().finish(); + } + let text = String::from_utf8_lossy(&body); + let hash = if text.contains("name=\"torrents\"") { + assert!(body.windows(TORRENT.len()).any(|w| w == TORRENT)); + rust_project::downloader::torrent_hash(TORRENT).unwrap() + } else { + assert!(text.contains("name=\"urls\"")); + regex::Regex::new("urn:btih:([a-f0-9]{40})") + .unwrap() + .captures(&text) + .unwrap()[1] + .to_owned() + }; + assert!(text.contains("name=\"savepath\"")); + let save_path = text + .split("name=\"savepath\"") + .nth(1) + .unwrap() + .split("\r\n\r\n") + .nth(1) + .unwrap() + .split("\r\n") + .next() + .unwrap() + .to_owned(); + let mut remote = state.lock().unwrap(); + remote.adds += 1; + remote.save_paths.push(save_path.clone()); + remote + .torrents + .push(json!({"hash": hash, "progress": 0.0, "state": "metaDL", "save_path": save_path})); + if remote.fail_after_add { + remote.fail_after_add = false; + return HttpResponse::InternalServerError().finish(); + } + HttpResponse::Ok().body("Ok.") +} + +async fn mock() -> (String, Arc>, ServerHandle) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let remote = Arc::new(Mutex::new(Remote::default())); + let data = remote.clone(); + let server = HttpServer::new(move || { + App::new() + .app_data(web::Data::new(data.clone())) + .route("/feed", web::get().to(rss)) + .route( + "/release.torrent", + web::get().to(|| async { HttpResponse::Ok().body(TORRENT) }), + ) + .route("/api/v2/auth/login", web::post().to(login)) + .route("/api/v2/torrents/info", web::get().to(info)) + .route( + "/api/v2/torrents/files", + web::get().to( + |req: HttpRequest, state: web::Data>>| async move { + assert!(authenticated(&req)); + HttpResponse::Ok().json(state.lock().unwrap().files.clone().unwrap_or_else( + || { + vec![ + json!({"name":"Series/ep03.mkv", "size":3, "priority":1}), + json!({"name":"skipped.mkv", "size":10, "priority":0}), + ] + }, + )) + }, + ), + ) + .route("/api/v2/torrents/add", web::post().to(add)) + }) + .workers(1) + .listen(listener) + .unwrap() + .run(); + let handle = server.handle(); + actix_web::rt::spawn(server); + (url, remote, handle) +} + +fn input(url: &str, enabled: bool) -> SubscriptionInput { + SubscriptionInput { + title: "Example".into(), + rss_url: format!("{url}/feed"), + season: 1, + episode_offset: 0, + include: vec!["1080p".into()], + exclude: vec![], + enabled, + } +} + +fn config(url: &str) -> Config { + Config { + database_url: "sqlite::memory:".into(), + port: 8080, + poll_interval: Duration::from_secs(300), + mikan_base_url: "https://mikanani.me".into(), + website: Default::default(), + qbit: Some(QbitConfig { + url: url.into(), + username: "test".into(), + password: "test".into(), + save_path: "/downloads".into(), + }), + } +} + +#[actix_web::test] +async fn parses_rss_enclosures_and_filter_reasons() { + let releases = feeds::parse( + FEED.as_bytes(), + &"https://example.invalid/".parse().unwrap(), + &input("https://example.invalid", false), + ) + .unwrap(); + assert_eq!(releases[0].episode, Some(3)); + assert_eq!(releases[0].decision, Decision::Ready); + assert_eq!(releases[2].decision, Decision::NeedsReview); + assert_eq!(releases[3].decision, Decision::Filtered); + assert!(releases[0] + .download_url + .as_ref() + .unwrap() + .starts_with("magnet:")); +} + +#[actix_web::test] +async fn restart_and_repeated_scans_do_not_resubmit() { + let (url, remote, server) = mock().await; + let path = std::env::temp_dir().join(format!("rust-project-{}.db", uuid::Uuid::new_v4())); + let db = format!("sqlite://{}", path.display()); + let service = Service::new(Store::open(&db).await.unwrap(), &config(&url)) + .await + .unwrap(); + let sub = service.store.create(input(&url, false)).await.unwrap(); + let preview = service.preview_subscription(&sub.id).await.unwrap(); + assert_eq!(preview[1].decision, Decision::AlreadyQueued); + assert!(service.store.jobs().await.unwrap().is_empty()); + assert!(service.scan_subscription(&sub.id).await.is_err()); + service + .store + .update(&sub.id, input(&url, true)) + .await + .unwrap(); + assert_eq!(service.cycle().await.unwrap().queued, 1); + assert_eq!(service.cycle().await.unwrap().queued, 0); + assert_eq!(remote.lock().unwrap().adds, 1); + let jobs = service.store.jobs().await.unwrap(); + assert_eq!(jobs[0].state, "submitted"); + service.store.pool.close().await; + drop(service); + + let recovered = Service::new(Store::open(&db).await.unwrap(), &config(&url)) + .await + .unwrap(); + recovered.cycle().await.unwrap(); + assert_eq!(remote.lock().unwrap().adds, 1); + // Removing a task remotely does not silently recreate it. + remote.lock().unwrap().torrents.clear(); + recovered.cycle().await.unwrap(); + assert_eq!(recovered.store.jobs().await.unwrap()[0].state, "missing"); + assert_eq!(remote.lock().unwrap().adds, 1); + recovered.store.retry(&jobs[0].id).await.unwrap(); + recovered.cycle().await.unwrap(); + assert_eq!(remote.lock().unwrap().adds, 2); + remote.lock().unwrap().torrents[0]["progress"] = json!(1.0); + recovered.cycle().await.unwrap(); + assert_eq!(recovered.store.jobs().await.unwrap()[0].state, "completed"); + recovered.store.pool.close().await; + drop(recovered); + std::fs::remove_file(path).unwrap(); + server.stop(true).await; +} + +#[actix_web::test] +async fn reconciles_uncertain_submission_and_respects_disabled_subscriptions() { + let (url, remote, server) = mock().await; + let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config(&url)) + .await + .unwrap(); + let sub = service.store.create(input(&url, true)).await.unwrap(); + remote.lock().unwrap().fail_list = true; + assert!(!service.cycle().await.unwrap().errors.is_empty()); + assert_eq!(remote.lock().unwrap().adds, 0); + remote.lock().unwrap().fail_list = false; + service + .store + .update(&sub.id, input(&url, false)) + .await + .unwrap(); + service.cycle().await.unwrap(); + assert_eq!(remote.lock().unwrap().adds, 0); + service + .store + .update(&sub.id, input(&url, true)) + .await + .unwrap(); + remote.lock().unwrap().fail_after_add = true; + service.cycle().await.unwrap(); + assert_eq!(remote.lock().unwrap().adds, 1); + let job = &service.store.jobs().await.unwrap()[0]; + assert_eq!(job.state, "submitting"); + assert!(job.last_error.is_some()); + sqlx::query("UPDATE download_jobs SET next_attempt_at = 0") + .execute(&service.store.pool) + .await + .unwrap(); + service.cycle().await.unwrap(); + assert_eq!(remote.lock().unwrap().adds, 1); + assert_eq!(service.store.jobs().await.unwrap()[0].state, "submitted"); + service.store.delete(&sub.id).await.unwrap(); + assert!(service.store.subscriptions().await.unwrap().is_empty()); + assert_eq!(service.store.jobs().await.unwrap().len(), 1); + server.stop(true).await; +} + +#[actix_web::test] +async fn api_validates_input_and_persists_crud() { + use actix_web::{http::StatusCode, test}; + let mut settings = config("http://127.0.0.1:1"); + settings.qbit = None; + let service = Arc::new( + Service::new(Store::open("sqlite::memory:").await.unwrap(), &settings) + .await + .unwrap(), + ); + let app = test::init_service( + App::new() + .app_data(web::Data::new(service.clone())) + .configure(api::configure), + ) + .await; + let mut invalid = input("http://example.invalid", false); + invalid.rss_url = "file:///etc/passwd".into(); + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/api/subscriptions") + .set_json(invalid) + .to_request(), + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let response = test::call_service( + &app, + test::TestRequest::post() + .uri("/api/subscriptions") + .set_json(input("https://example.invalid", false)) + .to_request(), + ) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let body: Value = test::read_body_json(response).await; + let uri = format!("/api/subscriptions/{}", body["id"].as_str().unwrap()); + let mut changed = input("https://example.invalid", false); + changed.title = "Updated".into(); + let response = test::call_service( + &app, + test::TestRequest::put() + .uri(&uri) + .set_json(changed) + .to_request(), + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + let response = test::call_service(&app, test::TestRequest::get().uri(&uri).to_request()).await; + let body: Value = test::read_body_json(response).await; + assert_eq!(body["title"], "Updated"); + let response = + test::call_service(&app, test::TestRequest::delete().uri(&uri).to_request()).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let response = test::call_service(&app, test::TestRequest::get().uri(&uri).to_request()).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[actix_web::test] +async fn downloads_relative_torrent_enclosure_and_submits_original_bytes() { + let (url, remote, server) = mock().await; + remote.lock().unwrap().feed = Some( + r#" + Testhttps://example.invalidTest + torrentExample - 05 [1080p] + + "# + .into(), + ); + let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config(&url)) + .await + .unwrap(); + service.store.create(input(&url, true)).await.unwrap(); + let report = service.cycle().await.unwrap(); + assert!(report.errors.is_empty(), "{:?}", report.errors); + let jobs = service.store.jobs().await.unwrap(); + assert_eq!(jobs.len(), 1); + assert_eq!(jobs[0].state, "submitted", "{:?}", jobs[0]); + assert_eq!( + jobs[0].info_hash.as_deref(), + Some( + rust_project::downloader::torrent_hash(TORRENT) + .unwrap() + .as_str() + ) + ); + assert_eq!( + service + .store + .torrent_bytes(&jobs[0].id) + .await + .unwrap() + .unwrap(), + TORRENT + ); + service.cycle().await.unwrap(); + assert_eq!(remote.lock().unwrap().adds, 1); + server.stop(true).await; +} + +#[actix_web::test] +async fn switching_keeps_queued_jobs_and_retries_with_their_original_downloader_after_restart() { + use actix_web::test; + let (url_a, remote_a, server_a) = mock().await; + let (url_b, remote_b, server_b) = mock().await; + let path = std::env::temp_dir().join(format!("downloader-switch-{}.db", uuid::Uuid::new_v4())); + let db = format!("sqlite://{}", path.display()); + let settings = config(&url_a); + let service = Arc::new( + Service::new(Store::open(&db).await.unwrap(), &settings) + .await + .unwrap(), + ); + let app = test::init_service( + App::new() + .app_data(web::Data::new(service.clone())) + .configure(api::configure), + ) + .await; + let instances: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get() + .uri("/api/downloaders") + .to_request(), + ) + .await; + let id_a = instances[0]["id"].as_str().unwrap().to_owned(); + let response = test::call_service(&app, test::TestRequest::post().uri("/api/downloaders") + .set_json(json!({"name":"Downloader B", "kind":"qbittorrent", "connection":{ + "url":url_b, "username":"test", "password":"B-private-password", "save_path":"/downloads" + }})).to_request()).await; + assert_eq!(response.status(), 201); + let body = String::from_utf8(test::read_body(response).await.to_vec()).unwrap(); + assert!(!body.contains("B-private-password")); + let instance: Value = serde_json::from_str(&body).unwrap(); + assert_eq!(instance["password_configured"], true); + assert_eq!(instance["is_active"], false); + let id_b = instance["id"].as_str().unwrap().to_owned(); + let sub_a: Value = test::call_and_read_body_json( + &app, + test::TestRequest::post() + .uri("/api/subscriptions") + .set_json(input(&url_a, true)) + .to_request(), + ) + .await; + let response = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/subscriptions/{}/scan", + sub_a["id"].as_str().unwrap() + )) + .to_request(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!(remote_a.lock().unwrap().adds, 0); // Still queued when switching. + let response = test::call_service( + &app, + test::TestRequest::put() + .uri("/api/downloaders/active") + .set_json(json!({"downloader_id":id_b})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 204); + let sub_b: Value = test::call_and_read_body_json( + &app, + test::TestRequest::post() + .uri("/api/subscriptions") + .set_json(input(&url_a, true)) + .to_request(), + ) + .await; + let run: Value = + test::call_and_read_body_json(&app, test::TestRequest::post().uri("/api/run").to_request()) + .await; + assert_eq!(run["errors"], json!([])); + assert_eq!(remote_a.lock().unwrap().adds, 1); + assert_eq!(remote_b.lock().unwrap().adds, 1); // Same hash in different instances must stay separate. + let jobs: Vec = + test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) + .await; + let job_a = jobs + .iter() + .find(|j| j["subscription_id"] == sub_a["id"]) + .unwrap() + .clone(); + assert_eq!(job_a["downloader_id"], id_a); + assert_eq!( + jobs.iter() + .find(|j| j["subscription_id"] == sub_b["id"]) + .unwrap()["downloader_id"], + id_b + ); + + remote_a.lock().unwrap().fail_list = true; + remote_b.lock().unwrap().torrents[0]["progress"] = json!(0.5); + let run: Value = + test::call_and_read_body_json(&app, test::TestRequest::post().uri("/api/run").to_request()) + .await; + assert!(run["errors"][0].as_str().unwrap().contains(&id_a)); + let jobs: Vec = + test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) + .await; + assert_eq!( + jobs.iter().find(|j| j["id"] == job_a["id"]).unwrap()["state"], + "submitted" + ); + assert_eq!( + jobs.iter() + .find(|j| j["subscription_id"] == sub_b["id"]) + .unwrap()["state"], + "downloading" + ); + + remote_a.lock().unwrap().fail_list = false; + remote_a.lock().unwrap().torrents.clear(); + test::call_service(&app, test::TestRequest::post().uri("/api/run").to_request()).await; + let response = test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/jobs/{}/retry", + job_a["id"].as_str().unwrap() + )) + .to_request(), + ) + .await; + assert_eq!(response.status(), 204); + test::call_service(&app, test::TestRequest::post().uri("/api/run").to_request()).await; + assert_eq!(remote_a.lock().unwrap().adds, 2); + assert_eq!(remote_b.lock().unwrap().adds, 1); + service.store.pool.close().await; + drop(app); + drop(service); + + // Startup's original QBIT_URL must not overwrite the persisted global selection. + let recovered = Arc::new( + Service::new(Store::open(&db).await.unwrap(), &settings) + .await + .unwrap(), + ); + let app = test::init_service( + App::new() + .app_data(web::Data::new(recovered.clone())) + .configure(api::configure), + ) + .await; + let status: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get().uri("/api/status").to_request(), + ) + .await; + assert_eq!(status["active_downloader_id"], id_b); + remote_a.lock().unwrap().torrents[0]["progress"] = json!(1.0); + remote_b.lock().unwrap().torrents[0]["progress"] = json!(1.0); + test::call_service(&app, test::TestRequest::post().uri("/api/run").to_request()).await; + let jobs: Vec = + test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) + .await; + assert!(jobs.iter().all(|j| j["state"] == "completed")); + assert_eq!(remote_a.lock().unwrap().adds, 2); + assert_eq!(remote_b.lock().unwrap().adds, 1); + recovered.store.pool.close().await; + drop(app); + drop(recovered); + std::fs::remove_file(path).unwrap(); + server_a.stop(false).await; + server_b.stop(false).await; +} + +#[actix_web::test] +async fn upgrading_without_original_config_requires_explicit_legacy_claim() { + use actix_web::test; + use sqlx::sqlite::SqliteConnectOptions; + let (url, remote, server) = mock().await; + let directory = + std::env::temp_dir().join(format!("legacy-downloader-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&directory).unwrap(); + let migration_dir = directory.join("migrations"); + std::fs::create_dir(&migration_dir).unwrap(); + std::fs::write( + migration_dir.join("0001_initial.sql"), + include_str!("../migrations/0001_initial.sql"), + ) + .unwrap(); + let db_path = directory.join("old.db"); + // Build an actual v1 database with its migration checksum, then upgrade through Store::open. + let old = sqlx::SqlitePool::connect_with( + SqliteConnectOptions::new() + .filename(&db_path) + .create_if_missing(true), + ) + .await + .unwrap(); + sqlx::migrate::Migrator::new(migration_dir.as_path()) + .await + .unwrap() + .run(&old) + .await + .unwrap(); + sqlx::query("INSERT INTO subscriptions(id, spec) VALUES ('legacy-sub', ?)") + .bind(serde_json::to_string(&input(&url, true)).unwrap()) + .execute(&old) + .await + .unwrap(); + sqlx::query("INSERT INTO download_jobs(id, subscription_id, resource_key, title, season, episode, download_url, info_hash, state) VALUES ('legacy-job', 'legacy-sub', 'legacy-resource', 'Example - 03', 1, 3, ?, ?, 'submitted')") + .bind(format!("magnet:?xt=urn:btih:{HASH}")).bind(HASH).execute(&old).await.unwrap(); + old.close().await; + let mut settings = config(&url); + settings.qbit = None; + let service = Arc::new( + Service::new( + Store::open(&format!("sqlite://{}", db_path.display())) + .await + .unwrap(), + &settings, + ) + .await + .unwrap(), + ); + let app = test::init_service( + App::new() + .app_data(web::Data::new(service.clone())) + .configure(api::configure), + ) + .await; + let status: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get().uri("/api/status").to_request(), + ) + .await; + assert_eq!(status["legacy_unassigned_jobs"], 1); + let sub: Value = test::call_and_read_body_json( + &app, + test::TestRequest::post() + .uri("/api/subscriptions") + .set_json(input(&url, true)) + .to_request(), + ) + .await; + test::call_service( + &app, + test::TestRequest::post() + .uri(&format!( + "/api/subscriptions/{}/scan", + sub["id"].as_str().unwrap() + )) + .to_request(), + ) + .await; + let instance: Value = test::call_and_read_body_json( + &app, + test::TestRequest::post() + .uri("/api/downloaders") + .set_json( + json!({"name":"Restored original", "kind":"qbittorrent", "connection":{ + "url":url, "username":"test", "password":"test", "save_path":"/downloads" + }}), + ) + .to_request(), + ) + .await; + let id = instance["id"].as_str().unwrap(); + let response = test::call_service( + &app, + test::TestRequest::put() + .uri("/api/downloaders/active") + .set_json(json!({"downloader_id":id})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 204); + let jobs: Vec = + test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) + .await; + assert!(jobs.iter().find(|j| j["id"] == "legacy-job").unwrap()["downloader_id"].is_null()); + assert_eq!( + jobs.iter() + .find(|j| j["subscription_id"] == sub["id"]) + .unwrap()["downloader_id"], + id + ); + // Restore the original remote task: claiming must reconcile it, not add it again. + remote + .lock() + .unwrap() + .torrents + .push(json!({"hash": HASH, "progress": 1.0, "state":"uploading"})); + let claimed: Value = test::call_and_read_body_json( + &app, + test::TestRequest::post() + .uri(&format!("/api/downloaders/{id}/claim-legacy-jobs")) + .to_request(), + ) + .await; + assert_eq!(claimed["claimed"], 1); + test::call_service(&app, test::TestRequest::post().uri("/api/run").to_request()).await; + let jobs: Vec = + test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) + .await; + let legacy = jobs.iter().find(|j| j["id"] == "legacy-job").unwrap(); + assert_eq!(legacy["downloader_id"], id); + assert_eq!(legacy["state"], "completed"); + assert_eq!(legacy["legacy_unassigned"], false); + assert_eq!(remote.lock().unwrap().adds, 0); + let response = test::call_service( + &app, + test::TestRequest::put() + .uri("/api/downloaders/active") + .set_json(json!({"downloader_id":"does-not-exist"})) + .to_request(), + ) + .await; + assert_eq!(response.status(), 404); + let status: Value = test::call_and_read_body_json( + &app, + test::TestRequest::get().uri("/api/status").to_request(), + ) + .await; + assert_eq!(status["active_downloader_id"], id); + assert_eq!(status["legacy_unassigned_jobs"], 0); + service.store.pool.close().await; + drop(app); + drop(service); + std::fs::remove_dir_all(directory).unwrap(); + server.stop(false).await; +} + +#[actix_web::test] +async fn destination_and_mapping_changes_preserve_queued_paths_and_restart_snapshots() { + use rust_project::media::{DestinationInput, LocalKind, MappingInput}; + let (url, remote, server) = mock().await; + let path = + std::env::temp_dir().join(format!("destination-snapshots-{}.db", uuid::Uuid::new_v4())); + let db = format!("sqlite://{}", path.display()); + let settings = config(&url); + let service = Service::new(Store::open(&db).await.unwrap(), &settings) + .await + .unwrap(); + let owner = service.store.active_downloader_id().await.unwrap().unwrap(); + let a = service + .store + .create_destination(DestinationInput { + name: "A".into(), + kind: LocalKind::Local, + root_path: "/media/a".into(), + }) + .await + .unwrap(); + let b = service + .store + .create_destination(DestinationInput { + name: "B".into(), + kind: LocalKind::Local, + root_path: "/media/b".into(), + }) + .await + .unwrap(); + assert!(service.store.activate_destination(&a.id).await.is_err()); + let v1 = service + .store + .create_mapping( + &a.id, + MappingInput { + downloader_id: owner.clone(), + download_root: "/downloads/one".into(), + }, + ) + .await + .unwrap(); + service.store.activate_destination(&a.id).await.unwrap(); + let first = service.store.create(input(&url, true)).await.unwrap(); + service.scan_subscription(&first.id).await.unwrap(); + let v2 = service + .store + .create_mapping( + &a.id, + MappingInput { + downloader_id: owner.clone(), + download_root: "/downloads/revised".into(), + }, + ) + .await + .unwrap(); + assert_eq!(v2.version, 2); + remote.lock().unwrap().feed = Some(FEED.replace(HASH, &"b".repeat(40))); + let second = service.store.create(input(&url, true)).await.unwrap(); + service.scan_subscription(&second.id).await.unwrap(); + service + .store + .create_mapping( + &b.id, + MappingInput { + downloader_id: owner.clone(), + download_root: "/downloads/two".into(), + }, + ) + .await + .unwrap(); + service.store.activate_destination(&b.id).await.unwrap(); + remote.lock().unwrap().feed = Some(FEED.replace(HASH, &"c".repeat(40))); + let third = service.store.create(input(&url, true)).await.unwrap(); + service.scan_subscription(&third.id).await.unwrap(); + let report = service.cycle().await.unwrap(); + assert!(report.errors.is_empty(), "{:?}", report.errors); + assert_eq!( + remote.lock().unwrap().save_paths, + vec!["/downloads/one", "/downloads/revised", "/downloads/two"] + ); + let jobs = service.store.jobs().await.unwrap(); + let original = jobs + .iter() + .find(|job| job.subscription_id == first.id) + .unwrap(); + assert_eq!(original.destination_id.as_deref(), Some(a.id.as_str())); + assert_eq!(original.mapping_id.as_deref(), Some(v1.id.as_str())); + let revised = jobs + .iter() + .find(|job| job.subscription_id == second.id) + .unwrap(); + assert_eq!(revised.mapping_id.as_deref(), Some(v2.id.as_str())); + service.store.pool.close().await; + drop(service); + let recovered = Service::new(Store::open(&db).await.unwrap(), &settings) + .await + .unwrap(); + assert_eq!( + recovered + .store + .active_destination_id() + .await + .unwrap() + .as_deref(), + Some(b.id.as_str()) + ); + let original = recovered.store.job(&original.id).await.unwrap(); + assert_eq!( + original.download_save_path.as_deref(), + Some("/downloads/one") + ); + assert_eq!(original.destination_id.as_deref(), Some(a.id.as_str())); + recovered.cycle().await.unwrap(); + assert_eq!(remote.lock().unwrap().adds, 3); + // Identical hash in the same instance but another destination cannot be reused or moved. + remote.lock().unwrap().feed = Some(FEED.into()); + let collision = recovered.store.create(input(&url, true)).await.unwrap(); + recovered.cycle().await.unwrap(); + let collision = recovered + .store + .jobs() + .await + .unwrap() + .into_iter() + .find(|j| j.subscription_id == collision.id) + .unwrap(); + assert_eq!(collision.state, "failed"); + assert!(collision.last_error.unwrap().contains("save path")); + assert_eq!(remote.lock().unwrap().adds, 3); + let other = recovered + .store + .create_downloader(rust_project::domain::DownloaderInput { + name: "unmapped".into(), + kind: rust_project::domain::DownloaderKind::Qbittorrent, + connection: config("http://127.0.0.1:1").qbit.unwrap(), + }) + .await + .unwrap(); + assert!(recovered + .store + .activate_downloader(&other.id) + .await + .is_err()); + assert_eq!( + recovered + .store + .active_downloader_id() + .await + .unwrap() + .as_deref(), + Some(owner.as_str()) + ); + recovered.store.pool.close().await; + drop(recovered); + std::fs::remove_file(path).unwrap(); + server.stop(false).await; +} + +#[actix_web::test] +async fn file_checks_keep_download_completion_and_last_success_when_storage_is_unavailable() { + use actix_web::test; + use rust_project::media::{DestinationInput, LocalKind, MappingInput}; + let (url, remote, server) = mock().await; + let directory = std::env::temp_dir().join(format!("local-media-{}", uuid::Uuid::new_v4())); + let root = directory.join("media"); + std::fs::create_dir_all(root.join("Series")).unwrap(); + std::fs::write(root.join("Series/ep03.mkv"), b"abc").unwrap(); + let db = format!("sqlite://{}", directory.join("app.db").display()); + let settings = config(&url); + let service = Arc::new( + Service::new(Store::open(&db).await.unwrap(), &settings) + .await + .unwrap(), + ); + let owner = service.store.active_downloader_id().await.unwrap().unwrap(); + let destination = service + .store + .create_destination(DestinationInput { + name: "local".into(), + kind: LocalKind::Local, + root_path: root.to_str().unwrap().into(), + }) + .await + .unwrap(); + service + .store + .create_mapping( + &destination.id, + MappingInput { + downloader_id: owner, + download_root: "/downloads/media".into(), + }, + ) + .await + .unwrap(); + service + .store + .activate_destination(&destination.id) + .await + .unwrap(); + service.store.create(input(&url, true)).await.unwrap(); + service.cycle().await.unwrap(); + remote.lock().unwrap().torrents[0]["progress"] = json!(1.0); + service.cycle().await.unwrap(); + let job = service.store.jobs().await.unwrap().remove(0); + assert_eq!(job.state, "completed"); + let app = test::init_service( + App::new() + .app_data(web::Data::new(service.clone())) + .configure(api::configure), + ) + .await; + let report_url = format!("/api/jobs/{}/files", job.id); + let check_url = format!("{report_url}/check"); + let initial: Value = + test::call_and_read_body_json(&app, test::TestRequest::get().uri(&report_url).to_request()) + .await; + assert_eq!(initial["status"], "unchecked"); + let present: Value = + test::call_and_read_body_json(&app, test::TestRequest::post().uri(&check_url).to_request()) + .await; + assert_eq!(present["status"], "present"); + assert_eq!(present["files"].as_array().unwrap().len(), 1); // Skip priority-zero files. + assert_eq!(present["files"][0]["observed_size"], 3); + std::fs::remove_file(root.join("Series/ep03.mkv")).unwrap(); + let absent: Value = + test::call_and_read_body_json(&app, test::TestRequest::post().uri(&check_url).to_request()) + .await; + assert_eq!(absent["status"], "absent"); + std::fs::write(root.join("Series/ep03.mkv"), b"x").unwrap(); + let partial: Value = + test::call_and_read_body_json(&app, test::TestRequest::post().uri(&check_url).to_request()) + .await; + assert_eq!(partial["status"], "present"); // Presence is not integrity or download completion. + assert_eq!(partial["files"][0]["expected_size"], 3); + assert_eq!(partial["files"][0]["observed_size"], 1); + tokio::time::sleep(Duration::from_millis(1100)).await; + std::fs::rename(&root, directory.join("offline")).unwrap(); + let unavailable: Value = + test::call_and_read_body_json(&app, test::TestRequest::post().uri(&check_url).to_request()) + .await; + assert_eq!(unavailable["status"], "unavailable"); + assert_eq!(unavailable["last_success_at"], partial["last_success_at"]); + assert!( + unavailable["checked_at"].as_i64().unwrap() > partial["last_success_at"].as_i64().unwrap() + ); + assert_eq!(service.store.job(&job.id).await.unwrap().state, "completed"); + assert_eq!(remote.lock().unwrap().adds, 1); + service.store.pool.close().await; + drop(app); + drop(service); + server.stop(false).await; + let recovered = Service::new(Store::open(&db).await.unwrap(), &settings) + .await + .unwrap(); + assert_eq!( + recovered.store.file_report(&job.id).await.unwrap().status, + "unavailable" + ); + std::fs::rename(directory.join("offline"), &root).unwrap(); + let report = recovered.check_job_files(&job.id).await.unwrap(); + assert_eq!(report.status, "present"); // Saved paths remain checkable with downloader offline. + assert_eq!( + recovered.store.job(&job.id).await.unwrap().state, + "completed" + ); + recovered.store.pool.close().await; + drop(recovered); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[actix_web::test] +async fn unsafe_downloader_paths_and_unreadable_files_are_unavailable_not_missing() { + use rust_project::media::{DestinationInput, LocalKind, MappingInput}; + let (url, remote, server) = mock().await; + let directory = + std::env::temp_dir().join(format!("media-confinement-{}", uuid::Uuid::new_v4())); + let root = directory.join("root"); + std::fs::create_dir_all(root.join("restricted")).unwrap(); + std::fs::create_dir_all(directory.join("outside")).unwrap(); + std::fs::write(directory.join("outside/secret"), b"secret").unwrap(); + std::fs::write(root.join("restricted/file.mkv"), b"abc").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(directory.join("outside"), root.join("escape")).unwrap(); + let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config(&url)) + .await + .unwrap(); + let owner = service.store.active_downloader_id().await.unwrap().unwrap(); + let destination = service + .store + .create_destination(DestinationInput { + name: "local".into(), + kind: LocalKind::Local, + root_path: root.to_str().unwrap().into(), + }) + .await + .unwrap(); + service + .store + .create_mapping( + &destination.id, + MappingInput { + downloader_id: owner, + download_root: "/downloads/media".into(), + }, + ) + .await + .unwrap(); + service + .store + .activate_destination(&destination.id) + .await + .unwrap(); + // Each subscription is a distinct task pointing to the same already-complete test torrent. + remote.lock().unwrap().torrents.push( + json!({"hash":HASH, "state":"uploading", "progress":1.0, "save_path":"/downloads/media"}), + ); + for path in [ + "../outside/secret", + "/absolute", + "C:\\outside", + "folder/../outside", + "", + ] { + remote.lock().unwrap().files = Some(vec![json!({"name":path,"size":3,"priority":1})]); + let sub = service.store.create(input(&url, true)).await.unwrap(); + service.cycle().await.unwrap(); + let job = service + .store + .jobs() + .await + .unwrap() + .into_iter() + .find(|j| j.subscription_id == sub.id) + .unwrap(); + let report = service.check_job_files(&job.id).await.unwrap(); + assert_eq!(report.status, "unavailable", "{path}"); + assert!(report.files.is_empty()); // No partial or unsafe manifest is stored. + assert_eq!(service.store.job(&job.id).await.unwrap().state, "completed"); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in ["escape/secret", "restricted/file.mkv", "restricted"] { + remote.lock().unwrap().files = Some(vec![json!({"name":path,"size":3,"priority":1})]); + let sub = service.store.create(input(&url, true)).await.unwrap(); + service.cycle().await.unwrap(); + let job = service + .store + .jobs() + .await + .unwrap() + .into_iter() + .find(|j| j.subscription_id == sub.id) + .unwrap(); + let restricted = root.join("restricted"); + let permissions = std::fs::metadata(&restricted).unwrap().permissions(); + if path == "restricted/file.mkv" { + std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o000)) + .unwrap(); + } + let permission_enforced = std::fs::read_dir(&restricted).is_err(); + let report = service.check_job_files(&job.id).await.unwrap(); + std::fs::set_permissions(&restricted, permissions).unwrap(); + // Root-owned CI processes may bypass chmod; other cases always enforce confinement. + if path != "restricted/file.mkv" || permission_enforced { + assert_eq!(report.status, "unavailable", "{path}"); + assert_eq!(report.files[0].status, "unavailable"); + } + assert_eq!(service.store.job(&job.id).await.unwrap().state, "completed"); + } + } + assert_eq!(remote.lock().unwrap().adds, 0); + service.store.pool.close().await; + drop(service); + std::fs::remove_dir_all(directory).unwrap(); + server.stop(false).await; +}