diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39f8197..ac22d8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: runs-on: [self-hosted, node-b, linux, x64] steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: 24 cache: npm @@ -61,7 +61,7 @@ jobs: needs: webui steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: 24 cache: npm diff --git a/CLAUDE.md b/CLAUDE.md index 5e6fcaf..bcd3340 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,18 @@ BitTorrent client written in Rust with a web UI. (Service Access table): `GIT_AUTH_TOKEN`/`FORGEJO_TOKEN`/`GITHUB_PAT` live in the `cicd` project, env `prod`. Never print token values. +## Coding conventions + +- **Do not reference the upstream qBittorrent project by name in code.** The + WebUI API v2 compatibility layer must not spell the brand name in Rust + identifiers, struct/field names, log messages, or response strings. Use the + neutral `qbit` abbreviation (already the established prefix, e.g. `qbit_compat`, + `QbitTorrentInfo`) or phrasings like "WebUI API v2 compat". Protocol-level + constants the layer must emit for wire compatibility (state strings such as + `stoppedDL`, the advertised `webapiVersion`, endpoint paths) are not brand + references and are fine. Doc comments may mention the upstream project where + needed to explain a compatibility decision, but keep it out of code proper. + ## Codesight Auto-generated codebase context map: `.codesight/CODESIGHT.md` — routes, schema, components, dependencies, and hot files. Regenerate with `npx codesight`. diff --git a/Cargo.lock b/Cargo.lock index e0fc8fe..34a1d17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -114,9 +114,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" @@ -129,18 +129,18 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "assert_cfg" @@ -204,9 +204,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -334,13 +334,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -377,9 +377,9 @@ dependencies = [ [[package]] name = "atoi" -version = "3.0.0" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4652e33138d8d9c774c9697a351b019deb33737812be629603434407f288a323" +checksum = "e7a8bbe9949e43a1edaa043038c68703b04774156afdfb62ba2cef5bf93d67be" dependencies = [ "num-traits", ] @@ -398,9 +398,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -409,14 +409,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -520,6 +521,12 @@ 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 = "bincode" version = "2.0.1" @@ -572,9 +579,9 @@ dependencies = [ [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -654,13 +661,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -689,9 +696,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -750,7 +757,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -885,9 +892,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -895,9 +902,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -907,23 +914,23 @@ dependencies = [ [[package]] name = "clap_complete" -version = "4.6.5" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" +checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19" dependencies = [ "clap", ] [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -1141,9 +1148,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1375,9 +1382,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "dbus" @@ -1390,6 +1397,37 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1776,13 +1814,13 @@ dependencies = [ [[package]] name = "feed-rs" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4c0591d23efd0d595099af69a31863ac1823046b1b021e3b06ba3aae7e00991" +checksum = "369995dae0733f1fe5ab0e3f345f6503a5f384179df5d8da333702031a131cf9" dependencies = [ "chrono", "mediatype", - "quick-xml 0.37.5", + "quick-xml", "regex", "serde", "serde_json", @@ -1921,9 +1959,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1936,9 +1974,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1946,15 +1984,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1974,9 +2012,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -1993,26 +2031,26 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-timer" @@ -2022,9 +2060,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -2587,9 +2625,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -2632,9 +2670,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" @@ -2999,9 +3037,9 @@ dependencies = [ [[package]] name = "itertools" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ "either", ] @@ -3035,6 +3073,59 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.21.1" @@ -3063,7 +3154,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link 0.2.1", ] @@ -3243,9 +3334,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libdbus-sys" @@ -3287,7 +3378,7 @@ dependencies = [ "libc", "network-interface", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -3309,7 +3400,7 @@ dependencies = [ "ringbuf", "rustc-hash 2.1.2", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -3381,9 +3472,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.18.0" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +checksum = "0d317b4b9eb398e6acce275758ec6125535505e7a146fb1a9b8bda2451b0ff4c" dependencies = [ "hashbrown 0.17.1", ] @@ -3446,24 +3537,24 @@ dependencies = [ [[package]] name = "mediatype" -version = "0.19.20" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33746aadcb41349ec291e7f2f0a3aa6834d1d7c58066fb4b01f68efc4c4b7631" +checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" dependencies = [ "serde", ] [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -3499,7 +3590,7 @@ dependencies = [ "metrics", "metrics-util", "quanta", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -3590,7 +3681,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -3660,7 +3751,7 @@ checksum = "4ddcb8865ad3d9950f22f42ffa0ef0aecbfbf191867b3122413602b0a360b2a6" dependencies = [ "cc", "libc", - "thiserror 2.0.18", + "thiserror 2.0.20", "winapi", ] @@ -4055,9 +4146,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" -version = "5.4.0" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" dependencies = [ "dunce", "is-wsl", @@ -4066,9 +4157,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ "bitflags 2.13.0", "cfg-if", @@ -4106,9 +4197,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -4323,7 +4414,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml 0.41.0", + "quick-xml", "serde", "time", ] @@ -4398,9 +4489,9 @@ dependencies = [ [[package]] name = "pollster" -version = "0.4.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" [[package]] name = "portable-atomic" @@ -4536,7 +4627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", - "itertools 0.14.0", + "itertools 0.13.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4572,33 +4663,15 @@ dependencies = [ "winapi", ] -[[package]] -name = "quick-xml" -version = "0.37.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" -dependencies = [ - "encoding_rs", - "memchr", -] - -[[package]] -name = "quick-xml" -version = "0.40.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "quick-xml" version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ + "encoding_rs", "memchr", + "serde", ] [[package]] @@ -4615,7 +4688,7 @@ dependencies = [ "rustc-hash 2.1.2", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -4637,7 +4710,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -4696,9 +4769,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -4800,7 +4873,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -4825,9 +4898,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -4837,9 +4910,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -4887,7 +4960,7 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", @@ -4937,12 +5010,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ "hashbrown 0.16.1", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "rtbit" -version = "0.1.0-beta.3" +version = "0.1.0-rc.1" dependencies = [ "anyhow", "bytes", @@ -5303,9 +5376,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -5325,22 +5398,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -5369,9 +5442,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -5434,9 +5507,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64 0.22.1", "bs58", @@ -5444,6 +5517,7 @@ dependencies = [ "hex 0.4.3", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -5454,9 +5528,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", @@ -5656,9 +5730,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -5782,7 +5856,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -5821,7 +5895,7 @@ dependencies = [ "sqlx-core", "sqlx-postgres", "syn 2.0.117", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "url", ] @@ -5847,7 +5921,7 @@ dependencies = [ "sha1", "sha2 0.11.0", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -5874,14 +5948,14 @@ dependencies = [ "log", "md-5", "memchr", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "whoami", ] @@ -5905,7 +5979,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "url", ] @@ -5982,7 +6056,7 @@ dependencies = [ "axum", "axum-extra", "backon", - "base64 0.22.1", + "base64 0.23.1", "bincode", "bitvec", "byteorder", @@ -5998,7 +6072,7 @@ dependencies = [ "home", "http", "intervaltree", - "itertools 0.14.0", + "itertools 0.15.0", "librqbit-dualstack-sockets", "librqbit-utp", "lru", @@ -6009,7 +6083,7 @@ dependencies = [ "notify", "parking_lot", "pollster", - "rand 0.10.1", + "rand 0.10.2", "regex", "reqwest", "rlimit", @@ -6034,13 +6108,14 @@ dependencies = [ "swarmforge-upnp", "swarmforge-upnp-serve", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-socks", "tokio-stream", "tokio-test", "tokio-util", - "tower-http", + "tower", + "tower-http 0.7.0", "tracing", "tracing-subscriber", "url", @@ -6059,13 +6134,13 @@ version = "0.1.0" dependencies = [ "anyhow", "arrayvec", - "atoi 3.0.0", + "atoi 3.1.0", "bytes", "serde", "serde_derive", "swarmforge-buffers", "swarmforge-clone-to-owned", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -6096,10 +6171,10 @@ dependencies = [ "directories", "encoding_rs", "hex 0.4.3", - "itertools 0.14.0", + "itertools 0.15.0", "memchr", "parking_lot", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_derive", "serde_json", @@ -6107,7 +6182,7 @@ dependencies = [ "swarmforge-buffers", "swarmforge-clone-to-owned", "swarmforge-sha1-wrapper", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -6129,7 +6204,7 @@ dependencies = [ "leaky-bucket", "librqbit-dualstack-sockets", "parking_lot", - "rand 0.10.1", + "rand 0.10.2", "serde", "serde_derive", "serde_json", @@ -6138,7 +6213,7 @@ dependencies = [ "swarmforge-clone-to-owned", "swarmforge-core", "swarmforge-sha1-wrapper", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", @@ -6151,13 +6226,13 @@ name = "swarmforge-lsd" version = "0.1.0" dependencies = [ "anyhow", - "atoi 3.0.0", + "atoi 3.1.0", "bstr", "futures", "httparse", "librqbit-dualstack-sockets", "parking_lot", - "rand 0.10.1", + "rand 0.10.2", "swarmforge-core", "swarmforge-sha1-wrapper", "tokio", @@ -6175,14 +6250,14 @@ dependencies = [ "byteorder", "bytes", "criterion", - "itertools 0.14.0", + "itertools 0.15.0", "serde", "serde_derive", "swarmforge-bencode", "swarmforge-buffers", "swarmforge-clone-to-owned", "swarmforge-core", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] @@ -6205,10 +6280,10 @@ dependencies = [ "backon", "byteorder", "futures", - "itertools 0.14.0", + "itertools 0.15.0", "librqbit-dualstack-sockets", "parking_lot", - "rand 0.10.1", + "rand 0.10.2", "reqwest", "serde", "serde_derive", @@ -6233,7 +6308,7 @@ dependencies = [ "httparse", "librqbit-dualstack-sockets", "network-interface", - "quick-xml 0.40.1", + "quick-xml", "reqwest", "serde", "serde_derive", @@ -6259,8 +6334,8 @@ dependencies = [ "mime_guess", "network-interface", "parking_lot", - "quick-xml 0.40.1", - "rand 0.10.1", + "quick-xml", + "rand 0.10.2", "reqwest", "serde", "serde_derive", @@ -6268,7 +6343,7 @@ dependencies = [ "swarmforge-upnp", "tokio", "tokio-util", - "tower-http", + "tower-http 0.7.0", "tracing", "tracing-subscriber", "url", @@ -6307,6 +6382,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -6445,7 +6531,7 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tray-icon", "url", @@ -6496,7 +6582,7 @@ dependencies = [ "sha2 0.10.9", "syn 2.0.117", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", "uuid", @@ -6547,7 +6633,7 @@ dependencies = [ "serde_repr", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "time", "url", ] @@ -6569,7 +6655,7 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", ] @@ -6591,7 +6677,7 @@ dependencies = [ "serde", "serde_json", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webview2-com", @@ -6654,7 +6740,7 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.18", + "thiserror 2.0.20", "toml 1.1.2+spec-1.1.0", "url", "urlpattern", @@ -6679,7 +6765,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.61.3", "windows-version", ] @@ -6717,11 +6803,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -6737,13 +6823,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.4", ] [[package]] @@ -6823,9 +6909,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -6884,9 +6970,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -6907,13 +6993,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -7112,10 +7199,26 @@ dependencies = [ "tower", "tower-layer", "tower-service", - "tracing", "url", ] +[[package]] +name = "tower-http" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "http", + "http-body", + "percent-encoding", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -7221,7 +7324,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows-sys 0.61.2", ] @@ -7463,9 +7566,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.3" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -7784,7 +7887,7 @@ version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.61.3", "windows-core 0.61.2", ] @@ -8513,7 +8616,7 @@ dependencies = [ "sha2 0.10.9", "soup3", "tao-macros", - "thiserror 2.0.18", + "thiserror 2.0.20", "url", "webkit2gtk", "webkit2gtk-sys", diff --git a/Cargo.toml b/Cargo.toml index e2ef8c2..8adf44c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -73,7 +73,7 @@ aws-lc-rs = "1.17" axum = "0.8" axum-extra = "0.12" backon = "1.6" -base64 = "0.22" +base64 = "0.23" bincode = "2" bitvec = "1" bstr = "1" @@ -102,7 +102,7 @@ httparse = "1" humantime = "2" indexmap = "2" intervaltree = "0.2" -itertools = "0.14" +itertools = "0.15" leaky-bucket = "1.1" libc = "0.2" librqbit-dualstack-sockets = "0.7" @@ -117,8 +117,8 @@ nix = "0.31" notify = "8" openssl = "0.10" parking_lot = "0.12" -pollster = "0.4" -quick-xml = "0.40" +pollster = "1.0" +quick-xml = "0.41" rand = "0.10" regex = "1" reqwest = { version = "0.13", default-features = false } @@ -143,7 +143,7 @@ tokio-socks = "0.5" tokio-stream = "0.1" tokio-test = "0.4" tokio-util = "0.7" -tower-http = "0.6" +tower-http = "0.7" tracing = "0.1" tracing-subscriber = { version = "0.3", default-features = false } url = { version = "2", default-features = false } diff --git a/RELEASE_NOTES_v0.1.0-rc.1.md b/RELEASE_NOTES_v0.1.0-rc.1.md new file mode 100644 index 0000000..e157779 --- /dev/null +++ b/RELEASE_NOTES_v0.1.0-rc.1.md @@ -0,0 +1,74 @@ +# rustTorrent v0.1.0-rc.1 + +## Summary + +rustTorrent 0.1.0-rc.1 is the first release candidate for 0.1.0. It greatly +expands qBittorrent WebUI API v2 compatibility, folds in the security fixes and +dependency refresh accumulated since `0.1.0-beta.3`, and adopts the +`dev → main → tag` promotion pipeline for releases. + +As a release candidate this is feature-oriented toward the 0.1.0 line and is +intended for validation. Back up the persistent session directory before +upgrading and avoid using it for data that cannot be downloaded again. + +## qBittorrent WebUI API v2 compatibility + +- Expanded WebUI API v2 parity from 19 to 64 endpoints, substantially widening + the surface that existing qBittorrent clients and automation can drive + unmodified. +- Added file, folder, and display renames, with a storage guard that refuses a + rename that would clobber an existing destination. +- Added whole-torrent relocation via `setLocation` / `setSavePath`. +- Added the torrent-creator task API. +- Added `setShareLimits` with ratio and seeding-time limits stored and reported + back through the compatible API. + +## Security and dependency maintenance + +- Replaced the unmaintained `parse_duration` crate with `humantime`. +- Bumped `quinn-proto` to a patched release and cleared the associated + Dependabot advisory. +- Refreshed web UI dependencies and benchmarking-harness dependencies to + patched versions. +- Adopted Dependabot version updates targeting the `dev` integration branch and + merged the outstanding cargo, npm, and GitHub Actions bumps. + +## Release pipeline + +- Introduced the `dev → main → cut-release (v* tag) → prod` promotion flow. +- `dev` publishes the preview image `…:dev`; `main` publishes `…:staging`; a + `v*` tag publishes the multi-arch prod image (`…:`, `…:latest`, + `…:beta`) plus the binaries, `.deb`, and checksums. +- Every build additionally publishes an immutable `sha-` image tag. + +## Upgrade notes + +- No intentional destructive session or RSS database migration is included. +- Keep `/home/rtbit/db` and `/home/rtbit/cache` persistent and back them up + before upgrading. +- For qBittorrent migrations, use read-only storage for the first verification + pass and confirm the payload root expected by each imported torrent. +- `latest` and `beta` move to this release after publication. Pin + `v0.1.0-rc.1` when reproducibility matters. + +## Validation + +- `cargo fmt --all -- --check` +- `cargo check --workspace --exclude rtbit-desktop --no-default-features --features default-tls` +- `cargo test --workspace --exclude rtbit-desktop --no-default-features --features default-tls` +- `cargo clippy --workspace --exclude rtbit-desktop --no-default-features --features default-tls -- -D warnings` +- Web UI unit tests, lint, production build, and Playwright browser tests +- Website structural validation +- GitHub release binary build and multi-architecture container publication + +## Downloads + +- Linux x86_64: `rtbit-v0.1.0-rc.1-linux-x86_64` +- Windows x86_64: `rtbit-v0.1.0-rc.1-windows-x86_64.exe` +- Debian/Ubuntu amd64: `rtbit-v0.1.0-rc.1-amd64.deb` +- Checksums: `SHA256SUMS-v0.1.0-rc.1.txt` +- Docker: `ghcr.io/thedancingdeveloper-org/rusttorrent:v0.1.0-rc.1` +- Source: automatic `.zip` and `.tar.gz` archives on the GitHub release tag + +Release files are attached to the canonical GitHub release and are also +published at `https://dl.rusttorrent.dev/v0.1.0-rc.1/`. diff --git a/benchv2/Cargo.lock b/benchv2/Cargo.lock index a021081..d5cbfa7 100644 --- a/benchv2/Cargo.lock +++ b/benchv2/Cargo.lock @@ -72,9 +72,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "atomic-waker" @@ -106,7 +106,7 @@ dependencies = [ "futures-util", "memmap2", "percent-encoding", - "rand 0.8.6", + "rand", "reqwest", "serde", "serde_json", @@ -125,11 +125,11 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "block-buffer" -version = "0.10.4" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -221,9 +221,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -235,9 +235,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -245,9 +245,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -257,14 +257,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -279,6 +279,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "cookie" version = "0.18.1" @@ -316,9 +322,9 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -331,19 +337,18 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", ] [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ "cfg-if", "crossbeam-utils", @@ -365,11 +370,12 @@ dependencies = [ [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", + "const-oid", "crypto-common", ] @@ -381,7 +387,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -441,19 +447,19 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -464,15 +470,15 @@ checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", @@ -481,16 +487,6 @@ dependencies = [ "slab", ] -[[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 = "getrandom" version = "0.2.17" @@ -593,6 +589,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -934,9 +939,9 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -959,9 +964,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -1123,7 +1128,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.3", + "rand", "ring", "rustc-hash", "rustls", @@ -1164,35 +1169,14 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[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", + "rand_chacha", + "rand_core", ] [[package]] @@ -1202,16 +1186,7 @@ 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", + "rand_core", ] [[package]] @@ -1249,7 +1224,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1410,9 +1385,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1420,29 +1395,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1459,7 +1434,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1495,9 +1470,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", "cpufeatures", @@ -1580,6 +1555,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -1597,7 +1583,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1626,7 +1612,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1637,7 +1623,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1707,9 +1693,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1724,13 +1710,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -1745,13 +1731,14 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -1820,7 +1807,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1870,9 +1857,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" @@ -1998,7 +1985,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -2083,7 +2070,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2094,7 +2081,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2308,7 +2295,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2329,7 +2316,7 @@ checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2349,7 +2336,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -2389,7 +2376,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/benchv2/Cargo.toml b/benchv2/Cargo.toml index 65f765a..3306d10 100644 --- a/benchv2/Cargo.toml +++ b/benchv2/Cargo.toml @@ -8,8 +8,8 @@ tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart", "cookies"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -sha1 = "0.10" -rand = "0.8" +sha1 = "0.11" +rand = "0.9" clap = { version = "4", features = ["derive", "env"] } chrono = { version = "0.4", features = ["serde"] } tracing = "0.1" diff --git a/crates/librtbit-core/Cargo.toml b/crates/librtbit-core/Cargo.toml index 5b050cf..ea8d1de 100644 --- a/crates/librtbit-core/Cargo.toml +++ b/crates/librtbit-core/Cargo.toml @@ -31,7 +31,7 @@ buffers.workspace = true bencode.workspace = true clone_to_owned.workspace = true sha1w = { workspace = true, optional = true } -itertools = "0.14" +itertools = "0.15" directories = "6" tokio-util = "0.7" data-encoding = "2" diff --git a/crates/librtbit-peer-protocol/Cargo.toml b/crates/librtbit-peer-protocol/Cargo.toml index 41e00c5..fd610b4 100644 --- a/crates/librtbit-peer-protocol/Cargo.toml +++ b/crates/librtbit-peer-protocol/Cargo.toml @@ -23,7 +23,7 @@ librtbit-core.workspace = true bitvec = "1" anyhow = "1" bytes = "1" -itertools = "0.14" +itertools = "0.15" thiserror = "2" tracing = "0.1" diff --git a/crates/librtbit-tracker-comms/Cargo.toml b/crates/librtbit-tracker-comms/Cargo.toml index b99e35f..e2345fd 100644 --- a/crates/librtbit-tracker-comms/Cargo.toml +++ b/crates/librtbit-tracker-comms/Cargo.toml @@ -32,7 +32,7 @@ parking_lot = "0.12" tokio-util = "0.7" librqbit-dualstack-sockets = "0.7" backon = "1.6" -itertools = "0.14" +itertools = "0.15" serde_with = "3" [lints.clippy] diff --git a/crates/librtbit-upnp-serve/Cargo.toml b/crates/librtbit-upnp-serve/Cargo.toml index e07fd0c..6457642 100644 --- a/crates/librtbit-upnp-serve/Cargo.toml +++ b/crates/librtbit-upnp-serve/Cargo.toml @@ -31,7 +31,7 @@ url = { version = "2", default-features = false } parking_lot = "0.12" tokio-util = "0.7" reqwest = { version = "0.13", default-features = false } -quick-xml = { version = "0.40", features = ["serialize"] } +quick-xml = { version = "0.41", features = ["serialize"] } network-interface = "2" futures = "0.3" librqbit-dualstack-sockets = "0.7" @@ -39,7 +39,7 @@ rand = "0.10" [dev-dependencies] tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt"] } -tower-http = { version = "0.6", features = ["trace"] } +tower-http = { version = "0.7", features = ["trace"] } [[example]] name = "upnp-stub-server" diff --git a/crates/librtbit-upnp/Cargo.toml b/crates/librtbit-upnp/Cargo.toml index ba49f1a..1360bf4 100644 --- a/crates/librtbit-upnp/Cargo.toml +++ b/crates/librtbit-upnp/Cargo.toml @@ -24,7 +24,7 @@ url = { version = "2", default-features = false } network-interface = "2" httparse = "1" bstr = "1" -quick-xml = { version = "0.40", features = ["serialize"] } +quick-xml = { version = "0.41", features = ["serialize"] } librqbit-dualstack-sockets = "0.7" socket2 = "0.6" diff --git a/crates/librtbit/Cargo.toml b/crates/librtbit/Cargo.toml index d4ad095..5e5801d 100644 --- a/crates/librtbit/Cargo.toml +++ b/crates/librtbit/Cargo.toml @@ -80,14 +80,14 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread", "fs", "io-util governor = "0.10" console-subscriber = { version = "0.5", optional = true } axum = { version = "0.8", optional = true, features = ["multipart"] } -tower-http = { version = "0.6", features = ["cors", "trace"], optional = true } +tower-http = { version = "0.7", features = ["cors", "trace"], optional = true } tokio-stream = "0.1" serde = { version = "1", features = ["rc"] } serde_derive = "1" serde_json = "1" serde_urlencoded = "0.7" anyhow = "1" -itertools = "0.14" +itertools = "0.15" http = "1" regex = "1" reqwest = { version = "0.13", default-features = false, features = ["json", "query", "socks", "stream"] } @@ -107,7 +107,7 @@ url = { version = "2", default-features = false, features = ["serde"] } hex = "0.4" backon = "1.6" dashmap = "6" -base64 = "0.22" +base64 = "0.23" serde_with = "3" tokio-util = { version = "0.7", features = ["io"] } metrics-exporter-prometheus = { version = "0.18", default-features = false, optional = true } @@ -142,10 +142,11 @@ windows = { version = "0.62", features = ["Win32", "Win32_System", "Win32_System anyhow = "1" [dev-dependencies] +tower = { version = "0.5", features = ["util"] } tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] } tokio-test = "0.4" tempfile = "3" -pollster = "0.4" +pollster = "1.0" [[example]] name = "simulate_traffic" diff --git a/crates/librtbit/src/api.rs b/crates/librtbit/src/api.rs index bf00ee9..6a6aa88 100644 --- a/crates/librtbit/src/api.rs +++ b/crates/librtbit/src/api.rs @@ -243,12 +243,7 @@ impl Api { id: Some(id), info_hash: mgr.shared().info_hash.as_string(), name: mgr.name(), - output_folder: mgr - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned(), + output_folder: mgr.output_folder().to_string_lossy().into_owned(), total_pieces, // These will be filled in /details and /stats endpoints @@ -283,13 +278,7 @@ impl Api { let info_hash = handle.shared().info_hash; let only_files = handle.only_files(); let category = handle.shared().category.read().clone(); - let output_folder = handle - .shared() - .options - .output_folder - .to_string_lossy() - .into_owned() - .to_string(); + let output_folder = handle.output_folder().to_string_lossy().into_owned(); make_torrent_details( Some(handle.id()), &info_hash, diff --git a/crates/librtbit/src/http_api/handlers/mod.rs b/crates/librtbit/src/http_api/handlers/mod.rs index 6bd407e..6a9f603 100644 --- a/crates/librtbit/src/http_api/handlers/mod.rs +++ b/crates/librtbit/src/http_api/handlers/mod.rs @@ -5,6 +5,8 @@ pub(crate) mod logging; pub(crate) mod other; pub(crate) mod playlist; pub(crate) mod qbit_compat; +#[cfg(test)] +mod qbit_parity; pub(crate) mod rss; pub(crate) mod speed; pub(crate) mod streaming; diff --git a/crates/librtbit/src/http_api/handlers/qbit_compat.rs b/crates/librtbit/src/http_api/handlers/qbit_compat.rs index a2482a9..3a63183 100644 --- a/crates/librtbit/src/http_api/handlers/qbit_compat.rs +++ b/crates/librtbit/src/http_api/handlers/qbit_compat.rs @@ -5,9 +5,10 @@ //! pretending to be qBittorrent. use std::{ - collections::HashMap, - num::NonZeroU16, + collections::{BTreeSet, HashMap, HashSet}, + num::{NonZeroU16, NonZeroU32}, path::Path, + path::PathBuf, sync::Arc, time::{Instant, SystemTime, UNIX_EPOCH}, }; @@ -25,8 +26,11 @@ use serde::{Deserialize, Serialize}; use tracing::warn; use crate::{ - AddTorrent, AddTorrentOptions, + AddTorrent, AddTorrentOptions, CreateTorrentOptions, api::{Api, TorrentIdOrHash}, + create_torrent, + limits::LimitsConfig, + spawn_utils::BlockingSpawner, torrent_state::stats::TorrentStatsState, }; @@ -71,6 +75,212 @@ impl QbitSessions { } } +/// In-memory tag store for the compat layer. rtbit has no native tag concept, +/// so tags live here (a global set plus a per-torrent, info-hash-keyed set) and +/// are not persisted across restarts. +#[derive(Default)] +struct QbitTags { + all: RwLock>, + per_torrent: RwLock>>, +} + +impl QbitTags { + fn all_tags(&self) -> Vec { + self.all.read().iter().cloned().collect() + } + + fn create(&self, tags: &[String]) { + let mut all = self.all.write(); + all.extend(tags.iter().cloned()); + } + + fn delete(&self, tags: &[String]) { + let mut all = self.all.write(); + let mut per = self.per_torrent.write(); + for tag in tags { + all.remove(tag); + } + for set in per.values_mut() { + for tag in tags { + set.remove(tag); + } + } + } + + fn add_to(&self, hashes: &[String], tags: &[String]) { + if tags.is_empty() { + return; + } + self.create(tags); + let mut per = self.per_torrent.write(); + for hash in hashes { + per.entry(hash.clone()) + .or_default() + .extend(tags.iter().cloned()); + } + } + + fn remove_from(&self, hashes: &[String], tags: &[String]) { + let mut per = self.per_torrent.write(); + for hash in hashes { + if let Some(set) = per.get_mut(hash) { + // An empty tag list clears every tag, matching qBittorrent. + if tags.is_empty() { + set.clear(); + } else { + for tag in tags { + set.remove(tag); + } + } + } + } + } + + fn set(&self, hashes: &[String], tags: &[String]) { + self.create(tags); + let mut per = self.per_torrent.write(); + for hash in hashes { + per.insert(hash.clone(), tags.iter().cloned().collect()); + } + } + + fn tags_for(&self, hash: &str) -> String { + self.per_torrent + .read() + .get(hash) + .map(|set| set.iter().cloned().collect::>().join(", ")) + .unwrap_or_default() + } + + fn has_tag(&self, hash: &str, tag: &str) -> bool { + self.per_torrent + .read() + .get(hash) + .is_some_and(|set| set.contains(tag)) + } +} + +// --------------------------------------------------------------------------- +// Torrent-creator task store (bridges the native create_torrent) +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, Serialize)] +enum CreatorStatus { + Running, + Finished, + Failed, +} + +#[derive(Clone)] +struct CreatorTask { + status: CreatorStatus, + source_path: String, + format: String, + error: Option, + torrent_bytes: Option, + info_hash: Option, +} + +/// In-memory registry of torrent-creation tasks. rtbit creates torrents +/// synchronously via `create_torrent`; each qBittorrent "task" runs that on a +/// spawned future and records the result here. Not persisted across restarts. +#[derive(Default)] +struct QbitTorrentCreator { + tasks: RwLock>, +} + +impl QbitTorrentCreator { + fn new_task(&self, source_path: String, format: String) -> String { + let id: String = (0..16) + .map(|_| format!("{:02x}", rand::random::())) + .collect(); + self.tasks.write().insert( + id.clone(), + CreatorTask { + status: CreatorStatus::Running, + source_path, + format, + error: None, + torrent_bytes: None, + info_hash: None, + }, + ); + id + } + + fn finish(&self, id: &str, torrent_bytes: Option, info_hash: String) { + if let Some(task) = self.tasks.write().get_mut(id) { + task.status = CreatorStatus::Finished; + task.torrent_bytes = torrent_bytes; + task.info_hash = Some(info_hash); + } + } + + fn fail(&self, id: &str, error: String) { + if let Some(task) = self.tasks.write().get_mut(id) { + task.status = CreatorStatus::Failed; + task.error = Some(error); + } + } + + fn get(&self, id: &str) -> Option { + self.tasks.read().get(id).cloned() + } + + fn all(&self) -> Vec<(String, CreatorTask)> { + self.tasks + .read() + .iter() + .map(|(id, task)| (id.clone(), task.clone())) + .collect() + } + + fn remove(&self, id: &str) -> bool { + self.tasks.write().remove(id).is_some() + } +} + +/// In-memory per-torrent share limits (max ratio + max seeding minutes). rtbit +/// does not auto-enforce these; they are stored so the values round-trip and are +/// surfaced in `torrents/info` for clients that enforce removal themselves +/// (e.g. Sonarr/Radarr). Not persisted across restarts. +#[derive(Default)] +struct QbitShareLimits { + limits: RwLock>, +} + +impl QbitShareLimits { + fn set(&self, hashes: &[String], ratio_limit: f64, seeding_time_limit: i64) { + let mut limits = self.limits.write(); + for hash in hashes { + limits.insert(hash.clone(), (ratio_limit, seeding_time_limit)); + } + } + + fn get(&self, hash: &str) -> Option<(f64, i64)> { + self.limits.read().get(hash).copied() + } +} + +/// Overlay stored share limits onto a torrent info view (reporting only). +fn apply_share_limits(info: &mut QbitTorrentInfo, store: &QbitShareLimits) { + if let Some((ratio_limit, seeding_time_limit)) = store.get(&info.hash) { + info.ratio_limit = ratio_limit; + info.max_ratio = ratio_limit; + let minutes = i32::try_from(seeding_time_limit).unwrap_or(-1); + info.seeding_time_limit = minutes; + info.max_seeding_time = minutes; + } +} + +fn parse_tags(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(str::to_owned) + .collect() +} + #[derive(Clone, Serialize)] struct QbitCategory { name: String, @@ -82,6 +292,9 @@ struct QbitCategory { pub(crate) struct QbitState { api_state: ApiState, sessions: QbitSessions, + tags: QbitTags, + creator: QbitTorrentCreator, + share_limits: QbitShareLimits, } // --------------------------------------------------------------------------- @@ -111,7 +324,7 @@ struct QbitTorrentInfo { infohash_v2: String, last_activity: u64, magnet_uri: String, - max_ratio: i32, + max_ratio: f64, max_seeding_time: i32, name: String, num_complete: u32, @@ -121,7 +334,7 @@ struct QbitTorrentInfo { priority: u32, progress: f64, ratio: f64, - ratio_limit: i32, + ratio_limit: f64, save_path: String, seeding_time: u64, seeding_time_limit: i32, @@ -191,12 +404,19 @@ struct QbitFileInfo { } fn qbit_save_path(handle: &crate::torrent_state::ManagedTorrentHandle) -> String { - handle - .shared() + let shared = handle.shared(); + // After a relocation the override diverges from the add-time output_folder; + // report the current root. Otherwise keep the qbit-savepath (output_folder_root) + // behaviour used for multi-file name prefixing. + let current_root = shared.output_folder_override.read(); + if *current_root != shared.options.output_folder { + return current_root.to_string_lossy().into_owned(); + } + shared .options .output_folder_root .as_ref() - .unwrap_or(&handle.shared().options.output_folder) + .unwrap_or(&shared.options.output_folder) .to_string_lossy() .into_owned() } @@ -343,6 +563,10 @@ async fn h_app_webapi_version() -> &'static str { "2.11.3" } +async fn h_app_default_save_path(State(state): State>) -> String { + state.api_state.api.api_output_folder() +} + async fn h_app_build_info() -> impl IntoResponse { axum::Json(QbitBuildInfo { qt: "N/A", @@ -417,18 +641,124 @@ async fn h_app_set_preferences( async fn h_transfer_info(State(state): State>) -> impl IntoResponse { let session_stats = state.api_state.api.api_session_stats(); + let config = state.api_state.api.session().ratelimits.get_config(); axum::Json(QbitTransferInfo { dl_info_speed: session_stats.download_speed.as_bytes(), dl_info_data: session_stats.counters.fetched_bytes, up_info_speed: session_stats.upload_speed.as_bytes(), up_info_data: session_stats.counters.uploaded_bytes, - dl_rate_limit: 0, - up_rate_limit: 0, + dl_rate_limit: bps_to_u64(config.download_bps), + up_rate_limit: bps_to_u64(config.upload_bps), dht_nodes: 0, connection_status: "connected", }) } +fn bps_to_u64(bps: Option) -> u64 { + bps.map_or(0, |v| u64::from(v.get())) +} + +/// Parse a qBittorrent byte-rate limit (0 means unlimited). +fn limit_to_bps(limit: u64) -> Option { + NonZeroU32::new(u32::try_from(limit).unwrap_or(u32::MAX)) +} + +#[derive(Deserialize, Default)] +struct LimitForm { + #[serde(default)] + limit: u64, +} + +#[derive(Deserialize, Default)] +struct ModeForm { + #[serde(default)] + mode: u8, +} + +async fn h_transfer_download_limit(State(state): State>) -> impl IntoResponse { + let config = state.api_state.api.session().ratelimits.get_config(); + axum::Json(bps_to_u64(config.download_bps)) +} + +async fn h_transfer_upload_limit(State(state): State>) -> impl IntoResponse { + let config = state.api_state.api.session().ratelimits.get_config(); + axum::Json(bps_to_u64(config.upload_bps)) +} + +async fn h_transfer_set_download_limit( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: LimitForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let session = state.api_state.api.session(); + let upload = session.ratelimits.get_config().upload_bps; + session.set_normal_rate_limits(limit_to_bps(form.limit), upload); + "Ok." +} + +async fn h_transfer_set_upload_limit( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: LimitForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let session = state.api_state.api.session(); + let download = session.ratelimits.get_config().download_bps; + session.set_normal_rate_limits(download, limit_to_bps(form.limit)); + "Ok." +} + +/// qBittorrent alternative-speed mode maps onto our alt-speed toggle: `1` when +/// alternative limits are active, `0` otherwise. +async fn h_transfer_speed_limits_mode(State(state): State>) -> &'static str { + if state.api_state.api.session().alt_speed_enabled() { + "1" + } else { + "0" + } +} + +async fn h_transfer_toggle_speed_limits_mode(State(state): State>) -> &'static str { + let session = state.api_state.api.session(); + session.set_alt_speed_enabled(!session.alt_speed_enabled()); + "Ok." +} + +async fn h_transfer_set_speed_limits_mode( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: ModeForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + state + .api_state + .api + .session() + .set_alt_speed_enabled(form.mode != 0); + "Ok." +} + +/// `transfer/pauseSession` — qBittorrent pauses the whole session; we have no +/// global pause, so we pause every torrent. +async fn h_transfer_pause_session(State(state): State>) -> &'static str { + let api = &state.api_state.api; + for idx in resolve_hashes(api, "all") { + if let Err(error) = api.api_torrent_action_pause(idx).await { + warn!(%error, "qbit compat: error pausing session torrent"); + } + } + "Ok." +} + +/// `transfer/resumeSession` — resume every torrent (see pauseSession). +async fn h_transfer_resume_session(State(state): State>) -> &'static str { + let api = &state.api_state.api; + for idx in resolve_hashes(api, "all") { + if let Err(error) = api.api_torrent_action_start(idx).await { + warn!(%error, "qbit compat: error resuming session torrent"); + } + } + "Ok." +} + // --------------------------------------------------------------------------- // Torrent management endpoints // --------------------------------------------------------------------------- @@ -441,13 +771,17 @@ fn now_unix() -> u64 { } /// Map rtbit torrent state to qBittorrent state string. +/// +/// We advertise WebAPI 2.11.3, so we emit the post-2.11 `stoppedDL`/`stoppedUP` +/// names (renamed from `pausedDL`/`pausedUP` in 2.11); clients that switch on +/// the advertised version expect these. fn map_state(state: TorrentStatsState, finished: bool) -> &'static str { match (state, finished) { (TorrentStatsState::Initializing, _) => "metaDL", (TorrentStatsState::Live, false) => "downloading", (TorrentStatsState::Live, true) => "uploading", - (TorrentStatsState::Paused, false) => "pausedDL", - (TorrentStatsState::Paused, true) => "pausedUP", + (TorrentStatsState::Paused, false) => "stoppedDL", + (TorrentStatsState::Paused, true) => "stoppedUP", (TorrentStatsState::Error, _) => "error", } } @@ -456,6 +790,7 @@ fn map_state(state: TorrentStatsState, finished: bool) -> &'static str { struct TorrentsInfoQuery { filter: Option, category: Option, + tag: Option, hashes: Option, sort: Option, reverse: Option, @@ -474,10 +809,12 @@ fn matches_filter( "downloading" => qbit_state == "downloading" || qbit_state == "metaDL", "seeding" => qbit_state == "uploading", "completed" => stats.finished, - "paused" => qbit_state == "pausedDL" || qbit_state == "pausedUP", + // `paused` was renamed to `stopped` in 2.11; accept both spellings. + "paused" | "stopped" => qbit_state == "stoppedDL" || qbit_state == "stoppedUP", "active" => qbit_state == "downloading" || qbit_state == "uploading", "inactive" => qbit_state != "downloading" && qbit_state != "uploading", - "resumed" => qbit_state != "pausedDL" && qbit_state != "pausedUP", + // `resumed` was renamed to `running` in 2.11; accept both spellings. + "resumed" | "running" => qbit_state != "stoppedDL" && qbit_state != "stoppedUP", "stalled" | "stalled_uploading" | "stalled_downloading" => { matches!(stats.state, TorrentStatsState::Live) && stats @@ -491,6 +828,164 @@ fn matches_filter( } } +/// Build the qBittorrent `torrents/info` view of a single torrent. Shared by +/// `torrents/info` and `sync/maindata`. +fn build_torrent_info( + handle: &crate::torrent_state::ManagedTorrentHandle, + stats: &crate::torrent_state::stats::TorrentStats, + now: u64, +) -> QbitTorrentInfo { + let info_hash = handle.shared().info_hash.as_string(); + let name = handle + .name() + .unwrap_or_else(|| format!("torrent_{}", handle.id())); + let output_folder = qbit_save_path(handle); + let content_path = qbit_content_path(handle, &name); + let qbit_state = map_state(stats.state, stats.finished); + let category = handle.shared().category.read().clone().unwrap_or_default(); + + let dl_speed = stats + .live + .as_ref() + .map(|l| l.download_speed.as_bytes()) + .unwrap_or(0); + let up_speed = stats + .live + .as_ref() + .map(|l| l.upload_speed.as_bytes()) + .unwrap_or(0); + + let progress = if stats.total_bytes > 0 { + stats.progress_bytes as f64 / stats.total_bytes as f64 + } else { + 0.0 + }; + + let eta = stats + .total_bytes + .saturating_sub(stats.progress_bytes) + .checked_div(dl_speed) + .map(|seconds| i64::try_from(seconds.min(8_640_000)).unwrap_or(8_640_000)) + .unwrap_or(8_640_000); + + let num_seeds = stats + .live + .as_ref() + .map(|l| l.snapshot.connected_seeders) + .unwrap_or(0); + let num_leechs = stats + .live + .as_ref() + .map(|l| l.snapshot.connected_leechers) + .unwrap_or(0); + let added_on = handle.shared().added_on; + let completion_on = handle + .shared() + .completion_on + .load(std::sync::atomic::Ordering::Relaxed); + let ratio = if stats.progress_bytes == 0 { + 0.0 + } else { + stats.uploaded_bytes as f64 / stats.progress_bytes as f64 + }; + let time_active = now.saturating_sub(added_on); + let seeding_time = completion_on + .checked_sub(added_on) + .map_or(0, |_| now.saturating_sub(completion_on)); + + let tracker = handle + .shared() + .trackers + .read() + .iter() + .next() + .map(|u| u.to_string()) + .unwrap_or_default(); + + let trackers_count = handle.shared().trackers.read().len(); + + QbitTorrentInfo { + added_on, + amount_left: stats.total_bytes.saturating_sub(stats.progress_bytes), + auto_tmm: false, + availability: -1, + category, + completed: stats.progress_bytes, + completion_on: if completion_on > 0 { + i64::try_from(completion_on).unwrap_or(i64::MAX) + } else { + -1 + }, + content_path, + dl_limit: -1, + dlspeed: dl_speed, + download_path: String::new(), + downloaded: stats.progress_bytes, + downloaded_session: 0, + eta, + f_l_piece_prio: false, + force_start: false, + hash: info_hash.clone(), + infohash_v1: info_hash, + infohash_v2: String::new(), + last_activity: if completion_on > 0 { + completion_on + } else { + added_on + }, + magnet_uri: String::new(), + max_ratio: -1.0, + max_seeding_time: -1, + name, + num_complete: num_seeds, + num_incomplete: num_leechs, + num_leechs, + num_seeds, + priority: 0, + progress, + ratio, + ratio_limit: -1.0, + save_path: output_folder, + seeding_time, + seeding_time_limit: -1, + seen_complete: if completion_on > 0 { + i64::try_from(completion_on).unwrap_or(i64::MAX) + } else { + -1 + }, + seq_dl: false, + size: stats.total_bytes, + state: qbit_state.to_string(), + super_seeding: false, + tags: String::new(), + time_active, + total_size: stats.total_bytes, + tracker, + trackers_count, + up_limit: -1, + uploaded: stats.uploaded_bytes, + uploaded_session: stats.uploaded_bytes, + upspeed: up_speed, + } +} + +/// Apply qBittorrent `offset`/`limit` pagination. An offset at or past the end +/// yields an empty list (not the whole list); a `limit` of 0 means "no limit". +fn apply_offset_limit(mut items: Vec, offset: usize, limit: Option) -> Vec { + if offset >= items.len() { + return Vec::new(); + } + if offset > 0 { + items = items.split_off(offset); + } + if let Some(limit) = limit + && limit > 0 + { + items.truncate(limit); + } + items +} + async fn h_torrents_info( State(state): State>, Query(query): Query, @@ -504,162 +999,43 @@ async fn h_torrents_info( .map(|h| h.split('|').map(|s| s.to_lowercase()).collect()); let mut torrents: Vec = api.session().with_torrents(|iter| { - iter.filter_map(|(id, handle)| { - let info_hash = handle.shared().info_hash.as_string(); + iter.filter_map(|(_id, handle)| { + let stats = handle.stats(); + let mut info = build_torrent_info(handle, &stats, now); + info.tags = state.tags.tags_for(&info.hash); + apply_share_limits(&mut info, &state.share_limits); - // Filter by hash if specified + // Filter by hash if specified. if let Some(ref hashes) = hash_filter - && !hashes.contains(&info_hash) + && !hashes.contains(&info.hash) { return None; } - let stats = handle.stats(); - let name = handle.name().unwrap_or_else(|| format!("torrent_{id}")); - let output_folder = qbit_save_path(handle); - let content_path = qbit_content_path(handle, &name); - - let qbit_state = map_state(stats.state, stats.finished); - let category = handle.shared().category.read().clone().unwrap_or_default(); - // qBittorrent treats an empty category and "uncategorized" as the // uncategorized bucket; otherwise category matching is exact. if let Some(ref requested) = query.category - && !matches_category(requested, &category) + && !matches_category(requested, &info.category) { return None; } - // Apply filter - if let Some(ref filter) = query.filter - && !matches_filter(filter, qbit_state, &stats) + // A non-empty tag query filters to torrents carrying that tag. + if let Some(ref tag) = query.tag + && !tag.is_empty() + && !state.tags.has_tag(&info.hash, tag) { return None; } - let dl_speed = stats - .live - .as_ref() - .map(|l| l.download_speed.as_bytes()) - .unwrap_or(0); - let up_speed = stats - .live - .as_ref() - .map(|l| l.upload_speed.as_bytes()) - .unwrap_or(0); - - let progress = if stats.total_bytes > 0 { - stats.progress_bytes as f64 / stats.total_bytes as f64 - } else { - 0.0 - }; - - let eta = stats - .total_bytes - .saturating_sub(stats.progress_bytes) - .checked_div(dl_speed) - .map(|seconds| i64::try_from(seconds.min(8_640_000)).unwrap_or(8_640_000)) - .unwrap_or(8_640_000); - - let num_seeds = stats - .live - .as_ref() - .map(|l| l.snapshot.connected_seeders) - .unwrap_or(0); - let num_leechs = stats - .live - .as_ref() - .map(|l| l.snapshot.connected_leechers) - .unwrap_or(0); - let added_on = handle.shared().added_on; - let completion_on = handle - .shared() - .completion_on - .load(std::sync::atomic::Ordering::Relaxed); - let ratio = if stats.progress_bytes == 0 { - 0.0 - } else { - stats.uploaded_bytes as f64 / stats.progress_bytes as f64 - }; - let time_active = now.saturating_sub(added_on); - let seeding_time = completion_on - .checked_sub(added_on) - .map_or(0, |_| now.saturating_sub(completion_on)); - - let tracker = handle - .shared() - .trackers - .read() - .iter() - .next() - .map(|u| u.to_string()) - .unwrap_or_default(); + // Apply the state filter. + if let Some(ref filter) = query.filter + && !matches_filter(filter, &info.state, &stats) + { + return None; + } - let trackers_count = handle.shared().trackers.read().len(); - - Some(QbitTorrentInfo { - added_on, - amount_left: stats.total_bytes.saturating_sub(stats.progress_bytes), - auto_tmm: false, - availability: -1, - category, - completed: stats.progress_bytes, - completion_on: if completion_on > 0 { - i64::try_from(completion_on).unwrap_or(i64::MAX) - } else { - -1 - }, - content_path, - dl_limit: -1, - dlspeed: dl_speed, - download_path: String::new(), - downloaded: stats.progress_bytes, - downloaded_session: 0, - eta, - f_l_piece_prio: false, - force_start: false, - hash: info_hash.clone(), - infohash_v1: info_hash, - infohash_v2: String::new(), - last_activity: if completion_on > 0 { - completion_on - } else { - added_on - }, - magnet_uri: String::new(), - max_ratio: -1, - max_seeding_time: -1, - name, - num_complete: num_seeds, - num_incomplete: num_leechs, - num_leechs, - num_seeds, - priority: 0, - progress, - ratio, - ratio_limit: -1, - save_path: output_folder, - seeding_time, - seeding_time_limit: -1, - seen_complete: if completion_on > 0 { - i64::try_from(completion_on).unwrap_or(i64::MAX) - } else { - -1 - }, - seq_dl: false, - size: stats.total_bytes, - state: qbit_state.to_string(), - super_seeding: false, - tags: String::new(), - time_active, - total_size: stats.total_bytes, - tracker, - trackers_count, - up_limit: -1, - uploaded: stats.uploaded_bytes, - uploaded_session: stats.uploaded_bytes, - upspeed: up_speed, - }) + Some(info) }) .collect() }); @@ -693,15 +1069,7 @@ async fn h_torrents_info( }); } - // Offset and limit - let offset = query.offset.unwrap_or(0); - if offset > 0 && offset < torrents.len() { - torrents = torrents.split_off(offset); - } - if let Some(limit) = query.limit { - torrents.truncate(limit); - } - + let torrents = apply_offset_limit(torrents, query.offset.unwrap_or(0), query.limit); axum::Json(torrents) } @@ -875,6 +1243,19 @@ async fn h_torrents_files( let is_multi_file = handle .with_metadata(|metadata| metadata.info.info().files.is_some()) .unwrap_or(false); + // Source per-file names from file_infos (which reflects renames) when its + // count lines up with the details file list; otherwise fall back to the + // metadata names to avoid any index skew (e.g. padding files). + let rel_names: Vec = handle + .with_metadata(|metadata| { + metadata + .file_infos + .iter() + .map(|fi| fi.relative_filename.to_string_lossy().into_owned()) + .collect::>() + }) + .unwrap_or_default(); + let use_rel_names = rel_names.len() == details_files.len(); let files: Vec = details_files .iter() .enumerate() @@ -885,7 +1266,12 @@ async fn h_torrents_files( } else { 1.0 }; - let name = qbit_file_name(details_name.as_deref(), &f.name, is_multi_file, qbit_root); + let base_name = if use_rel_names { + rel_names[i].as_str() + } else { + f.name.as_str() + }; + let name = qbit_file_name(details_name.as_deref(), base_name, is_multi_file, qbit_root); QbitFileInfo { index: i, name, @@ -902,23 +1288,196 @@ async fn h_torrents_files( axum::Json(files).into_response() } -// --------------------------------------------------------------------------- -// Torrent actions (add, pause, resume, delete) -// --------------------------------------------------------------------------- +#[derive(Serialize)] +struct QbitTrackerInfo { + url: String, + /// qBittorrent tracker status: 0 disabled, 1 not contacted, 2 working, + /// 3 updating, 4 not working. + status: u8, + tier: i32, + num_peers: i64, + num_seeds: i64, + num_leeches: i64, + num_downloaded: i64, + msg: String, +} -async fn h_torrents_add( +/// Map our tracker announce state to qBittorrent's integer status code. +fn qbit_tracker_status(state: tracker_comms::TrackerAnnounceState) -> u8 { + use tracker_comms::TrackerAnnounceState::*; + match state { + Disabled => 0, + NotContacted => 1, + Working => 2, + Updating => 3, + Error => 4, + } +} + +async fn h_torrents_trackers( State(state): State>, - mut multipart: Multipart, + Query(query): Query, ) -> impl IntoResponse { - let mut urls: Vec = Vec::new(); - let mut torrent_bytes: Vec = Vec::new(); - let mut savepath: Option = None; - let mut category: Option = None; - let mut paused = false; + let api = &state.api_state.api; + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let response = match api.api_tracker_status(idx) { + Ok(r) => r, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; - while let Ok(Some(field)) = multipart.next_field().await { - let field_name = field.name().unwrap_or("").to_string(); - match field_name.as_str() { + let trackers: Vec = response + .trackers + .into_iter() + .map(|t| QbitTrackerInfo { + status: qbit_tracker_status(t.state), + tier: 0, + num_peers: t.peers_returned.map_or(-1, i64::from), + num_seeds: t.seeders.map_or(-1, i64::from), + num_leeches: t.leechers.map_or(-1, i64::from), + num_downloaded: -1, + msg: t.last_error.unwrap_or_default(), + url: t.url, + }) + .collect(); + axum::Json(trackers).into_response() +} + +async fn h_torrents_piece_states( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let api = &state.api_state.api; + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let (bf, len) = match api.api_dump_haves(idx) { + Ok(v) => v, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + // qBittorrent: 0 not downloaded, 1 downloading (requested), 2 downloaded. + // We only distinguish have/not-have, so emit 0 or 2. + let states: Vec = bf + .iter() + .take(len as usize) + .map(|b| if *b { 2 } else { 0 }) + .collect(); + axum::Json(states).into_response() +} + +async fn h_torrents_piece_hashes( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let api = &state.api_state.api; + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let hashes: Option> = handle + .with_metadata(|m| { + let info = m.info.info(); + let total = m.info.lengths().total_pieces(); + (0..total) + .map(|p| { + info.get_hash(p).map(|h| { + h.iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + }) + }) + .collect::>>() + }) + .ok() + .flatten(); + match hashes { + Some(hashes) => axum::Json(hashes).into_response(), + // Metadata not yet available (magnet still resolving) or v2-only torrent. + None => axum::Json(Vec::::new()).into_response(), + } +} + +async fn h_torrents_count(State(state): State>) -> impl IntoResponse { + let count = state + .api_state + .api + .session() + .with_torrents(|iter| iter.count()); + axum::Json(count) +} + +/// `torrents/export` — return the raw `.torrent` file bytes for one torrent. +async fn h_torrents_export( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let api = &state.api_state.api; + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + match handle.with_metadata(|meta| meta.torrent_bytes.clone()) { + Ok(bytes) => ([("content-type", "application/x-bittorrent")], bytes).into_response(), + // Metadata not resolved yet (magnet) — no file to export. + Err(_) => (StatusCode::CONFLICT, "Metadata not available").into_response(), + } +} + +#[derive(Serialize)] +struct QbitWebSeed { + url: String, +} + +async fn h_torrents_webseeds( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let api = &state.api_state.api; + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let seeds: Vec = handle + .shared() + .web_seed_urls + .iter() + .map(|url| QbitWebSeed { url: url.clone() }) + .collect(); + axum::Json(seeds).into_response() +} + +// --------------------------------------------------------------------------- +// Torrent actions (add, pause, resume, delete) +// --------------------------------------------------------------------------- + +async fn h_torrents_add( + State(state): State>, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut urls: Vec = Vec::new(); + let mut torrent_bytes: Vec = Vec::new(); + let mut savepath: Option = None; + let mut category: Option = None; + let mut paused = false; + + while let Ok(Some(field)) = multipart.next_field().await { + let field_name = field.name().unwrap_or("").to_string(); + match field_name.as_str() { "urls" => { if let Ok(text) = field.text().await { for line in text.lines() { @@ -953,9 +1512,12 @@ async fn h_torrents_add( savepath = Some(text); } } - "paused" => { - if let Ok(text) = field.text().await { - paused = text.eq_ignore_ascii_case("true"); + // `paused` was renamed to `stopped` in WebAPI 2.11; accept both. + "paused" | "stopped" => { + if let Ok(text) = field.text().await + && text.eq_ignore_ascii_case("true") + { + paused = true; } } _ => { @@ -1063,6 +1625,17 @@ async fn h_torrents_resume(State(state): State>, body: Bytes) -> "Ok." } +/// `torrents/stop` — the WebAPI 2.11+ name for `torrents/pause`. We advertise +/// 2.11.3, so modern clients (qbittorrent-api, newer *arr) call this. +async fn h_torrents_stop(state: State>, body: Bytes) -> &'static str { + h_torrents_pause(state, body).await +} + +/// `torrents/start` — the WebAPI 2.11+ name for `torrents/resume`. +async fn h_torrents_start(state: State>, body: Bytes) -> &'static str { + h_torrents_resume(state, body).await +} + async fn h_torrents_recheck(State(state): State>, body: Bytes) -> &'static str { let form: HashesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); let api = &state.api_state.api; @@ -1074,6 +1647,145 @@ async fn h_torrents_recheck(State(state): State>, body: Bytes) -> "Ok." } +async fn h_torrents_reannounce(State(state): State>, body: Bytes) -> &'static str { + let form: HashesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + for idx in resolve_hashes(api, &form.hashes) { + if let Ok(handle) = api.mgr_handle(idx) { + handle.reannounce(); + } + } + "Ok." +} + +/// Relative paths of every file in the torrent, indexed by file id. +fn torrent_file_paths(handle: &crate::torrent_state::ManagedTorrentHandle) -> Vec { + handle + .with_metadata(|metadata| { + metadata + .file_infos + .iter() + .map(|fi| fi.relative_filename.clone()) + .collect::>() + }) + .unwrap_or_default() +} + +/// Map a rename engine error onto qBittorrent's 409 Conflict (used for +/// "torrent must be stopped", path collisions, invalid paths, etc.). +fn rename_conflict(error: anyhow::Error) -> axum::response::Response { + (StatusCode::CONFLICT, format!("{error:#}")).into_response() +} + +#[derive(Deserialize, Default)] +struct RenamePathForm { + #[serde(default)] + hash: String, + #[serde(default, alias = "oldPath")] + old_path: String, + #[serde(default, alias = "newPath")] + new_path: String, +} + +async fn h_torrents_rename_file( + State(state): State>, + body: Bytes, +) -> axum::response::Response { + let form: RenamePathForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required").into_response(); + } + if form.old_path.is_empty() || form.new_path.is_empty() { + return (StatusCode::BAD_REQUEST, "oldPath and newPath are required").into_response(); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let paths = torrent_file_paths(&handle); + let old = PathBuf::from(&form.old_path); + let file_id = match paths.iter().position(|p| *p == old) { + Some(id) => id, + None => return (StatusCode::CONFLICT, "oldPath does not exist").into_response(), + }; + match handle.rename_files(&[(file_id, PathBuf::from(&form.new_path))]) { + Ok(()) => (StatusCode::OK, "Ok.").into_response(), + Err(error) => rename_conflict(error), + } +} + +async fn h_torrents_rename_folder( + State(state): State>, + body: Bytes, +) -> axum::response::Response { + let form: RenamePathForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required").into_response(); + } + if form.old_path.is_empty() || form.new_path.is_empty() { + return (StatusCode::BAD_REQUEST, "oldPath and newPath are required").into_response(); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + // Rename every file whose path is under the old folder prefix. + let old_prefix = PathBuf::from(&form.old_path); + let new_prefix = PathBuf::from(&form.new_path); + let mut renames: Vec<(usize, PathBuf)> = Vec::new(); + for (id, path) in torrent_file_paths(&handle).into_iter().enumerate() { + if let Ok(rest) = path.strip_prefix(&old_prefix) { + renames.push((id, new_prefix.join(rest))); + } + } + if renames.is_empty() { + return (StatusCode::CONFLICT, "no files under oldPath").into_response(); + } + match handle.rename_files(&renames) { + Ok(()) => (StatusCode::OK, "Ok.").into_response(), + Err(error) => rename_conflict(error), + } +} + +#[derive(Deserialize, Default)] +struct RenameForm { + #[serde(default)] + hash: String, + #[serde(default)] + name: String, +} + +async fn h_torrents_rename(State(state): State>, body: Bytes) -> impl IntoResponse { + let form: RenameForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required"); + } + if form.name.trim().is_empty() { + return (StatusCode::CONFLICT, "name must not be empty"); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + handle.set_display_name(Some(form.name)); + (StatusCode::OK, "Ok.") +} + #[derive(Deserialize, Default)] struct DeleteForm { #[serde(default)] @@ -1125,6 +1837,456 @@ async fn h_torrents_set_category(State(state): State>, body: Byte "Ok." } +#[derive(Deserialize, Default)] +struct AddTrackersForm { + #[serde(default)] + hash: String, + #[serde(default)] + urls: String, +} + +async fn h_torrents_add_trackers( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: AddTrackersForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required"); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + let trackers: Vec = form + .urls + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + if trackers.is_empty() { + return (StatusCode::OK, "Ok."); + } + match api.api_torrent_action_add_trackers(idx, trackers).await { + Ok(_) => (StatusCode::OK, "Ok."), + Err(error) => { + warn!(%error, "qbit compat: error adding trackers"); + (StatusCode::NOT_FOUND, "Not found") + } + } +} + +#[derive(Deserialize, Default)] +struct RemoveTrackersForm { + #[serde(default)] + hash: String, + /// Pipe-separated tracker URLs. + #[serde(default)] + urls: String, +} + +async fn h_torrents_remove_trackers( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: RemoveTrackersForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required").into_response(); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let urls: Vec = form + .urls + .split('|') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + if let Err(error) = api.session().remove_trackers(&handle, &urls).await { + warn!(%error, "qbit compat: error removing trackers"); + } + (StatusCode::OK, "Ok.").into_response() +} + +#[derive(Deserialize, Default)] +struct EditTrackerForm { + #[serde(default)] + hash: String, + #[serde(default, alias = "origUrl")] + orig_url: String, + #[serde(default, alias = "newUrl")] + new_url: String, +} + +async fn h_torrents_edit_tracker( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: EditTrackerForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required").into_response(); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found").into_response(), + }; + // Validate newUrl *before* removing origUrl so a bad newUrl can't leave the + // torrent with neither tracker. + let valid_new = url::Url::parse(&form.new_url) + .ok() + .is_some_and(|u| matches!(u.scheme(), "http" | "https" | "udp")); + if !valid_new { + return (StatusCode::BAD_REQUEST, "invalid newUrl").into_response(); + } + let removed = api + .session() + .remove_trackers(&handle, std::slice::from_ref(&form.orig_url)) + .await + .unwrap_or(0); + if removed == 0 { + return (StatusCode::CONFLICT, "origUrl not found").into_response(); + } + match api + .api_torrent_action_add_trackers(idx, vec![form.new_url]) + .await + { + Ok(_) => (StatusCode::OK, "Ok.").into_response(), + Err(error) => { + warn!(%error, "qbit compat: error editing tracker"); + (StatusCode::CONFLICT, "Failed").into_response() + } + } +} + +#[derive(Deserialize, Default)] +struct AddPeersForm { + #[serde(default)] + hashes: String, + #[serde(default)] + peers: String, +} + +async fn h_torrents_add_peers(State(state): State>, body: Bytes) -> &'static str { + let form: AddPeersForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + let peers: Vec = form + .peers + .split('|') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + for idx in resolve_hashes(api, &form.hashes) { + let Ok(handle) = api.mgr_handle(idx) else { + continue; + }; + let Some(live) = handle.live() else { + continue; + }; + for addr in &peers { + let _ = live.add_peer_if_not_seen(*addr); + } + } + "Ok." +} + +#[derive(Deserialize, Default)] +struct FilePrioForm { + #[serde(default)] + hash: String, + /// Pipe-separated file indices. + #[serde(default)] + id: String, + #[serde(default)] + priority: u8, +} + +async fn h_torrents_file_prio( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: FilePrioForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + if form.hash.is_empty() { + return (StatusCode::BAD_REQUEST, "hash is required"); + } + let idx = match TorrentIdOrHash::parse(&form.hash) { + Ok(idx) => idx, + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + let handle = match api.mgr_handle(idx) { + Ok(h) => h, + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + let num_files = match api.api_torrent_details(idx) { + Ok(details) => details.files.map(|f| f.len()).unwrap_or(0), + Err(_) => return (StatusCode::NOT_FOUND, "Not found"), + }; + + // qBittorrent priority 0 means "do not download"; anything else downloads. + // We only model an include/exclude selection, so map onto only_files. + let mut included: HashSet = match handle.only_files() { + Some(files) => files.into_iter().collect(), + None => (0..num_files).collect(), + }; + let download = form.priority != 0; + for id in form + .id + .split('|') + .filter_map(|s| s.trim().parse::().ok()) + { + if id >= num_files { + return (StatusCode::CONFLICT, "Invalid file id"); + } + if download { + included.insert(id); + } else { + included.remove(&id); + } + } + match api + .api_torrent_action_update_only_files(idx, &included) + .await + { + Ok(_) => (StatusCode::OK, "Ok."), + Err(error) => { + warn!(%error, "qbit compat: error setting file priority"); + (StatusCode::CONFLICT, "Failed") + } + } +} + +// --------------------------------------------------------------------------- +// Per-torrent speed limits +// --------------------------------------------------------------------------- + +#[derive(Deserialize, Default)] +struct HashesQuery { + #[serde(default)] + hashes: String, +} + +#[derive(Deserialize, Default)] +struct SetTorrentLimitForm { + #[serde(default)] + hashes: String, + /// Bytes/s; <= 0 means unlimited. + #[serde(default)] + limit: i64, +} + +fn torrent_limit_map( + api: &Api, + hashes: &str, + pick: impl Fn(LimitsConfig) -> Option, +) -> HashMap { + let mut map = HashMap::new(); + for idx in resolve_hashes(api, hashes) { + if let Ok(handle) = api.mgr_handle(idx) { + let hash = handle.shared().info_hash.as_string(); + map.insert(hash, bps_to_u64(pick(handle.rate_limits()))); + } + } + map +} + +fn parse_torrent_limit(limit: i64) -> Option { + if limit <= 0 { + None + } else { + limit_to_bps(limit as u64) + } +} + +async fn h_torrents_download_limit( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let map = torrent_limit_map(&state.api_state.api, &query.hashes, |c| c.download_bps); + axum::Json(map) +} + +async fn h_torrents_upload_limit( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let map = torrent_limit_map(&state.api_state.api, &query.hashes, |c| c.upload_bps); + axum::Json(map) +} + +async fn h_torrents_set_download_limit( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: SetTorrentLimitForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + let bps = parse_torrent_limit(form.limit); + for idx in resolve_hashes(api, &form.hashes) { + if let Ok(handle) = api.mgr_handle(idx) { + handle.set_download_limit(bps); + } + } + "Ok." +} + +async fn h_torrents_set_upload_limit( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: SetTorrentLimitForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + let bps = parse_torrent_limit(form.limit); + for idx in resolve_hashes(api, &form.hashes) { + if let Ok(handle) = api.mgr_handle(idx) { + handle.set_upload_limit(bps); + } + } + "Ok." +} + +fn default_ratio_limit() -> f64 { + -1.0 +} + +fn default_seeding_time_limit() -> i64 { + -1 +} + +#[derive(Deserialize)] +struct SetShareLimitsForm { + #[serde(default)] + hashes: String, + #[serde(default = "default_ratio_limit", alias = "ratioLimit")] + ratio_limit: f64, + #[serde(default = "default_seeding_time_limit", alias = "seedingTimeLimit")] + seeding_time_limit: i64, +} + +/// `torrents/setShareLimits` — store per-torrent ratio / seeding-time limits so +/// they round-trip and appear in `torrents/info`. rtbit does not auto-enforce +/// them (clients like Sonarr/Radarr apply their own removal policy). +async fn h_torrents_set_share_limits( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: SetShareLimitsForm = match serde_urlencoded::from_bytes(&body) { + Ok(form) => form, + Err(_) => return "Ok.", + }; + let hashes = resolve_info_hashes(&state.api_state.api, &form.hashes); + state + .share_limits + .set(&hashes, form.ratio_limit, form.seeding_time_limit); + "Ok." +} + +// --------------------------------------------------------------------------- +// Tag endpoints (backed by the in-memory QbitTags store) +// --------------------------------------------------------------------------- + +/// Resolve hash(es) to canonical (lowercase hex) info-hash strings, used as the +/// key space for the tag store. Skips torrents that cannot be resolved. +fn resolve_info_hashes(api: &Api, hashes_str: &str) -> Vec { + resolve_hashes(api, hashes_str) + .into_iter() + .filter_map(|idx| { + api.mgr_handle(idx) + .ok() + .map(|handle| handle.shared().info_hash.as_string()) + }) + .collect() +} + +#[derive(Deserialize, Default)] +struct TagsForm { + #[serde(default)] + hashes: String, + #[serde(default)] + tags: String, +} + +async fn h_torrents_tags(State(state): State>) -> impl IntoResponse { + axum::Json(state.tags.all_tags()) +} + +async fn h_torrents_create_tags(State(state): State>, body: Bytes) -> &'static str { + let form: TagsForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + state.tags.create(&parse_tags(&form.tags)); + "Ok." +} + +async fn h_torrents_delete_tags(State(state): State>, body: Bytes) -> &'static str { + let form: TagsForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + state.tags.delete(&parse_tags(&form.tags)); + "Ok." +} + +async fn h_torrents_add_tags(State(state): State>, body: Bytes) -> &'static str { + let form: TagsForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let hashes = resolve_info_hashes(&state.api_state.api, &form.hashes); + state.tags.add_to(&hashes, &parse_tags(&form.tags)); + "Ok." +} + +async fn h_torrents_remove_tags(State(state): State>, body: Bytes) -> &'static str { + let form: TagsForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let hashes = resolve_info_hashes(&state.api_state.api, &form.hashes); + state.tags.remove_from(&hashes, &parse_tags(&form.tags)); + "Ok." +} + +async fn h_torrents_set_tags(State(state): State>, body: Bytes) -> &'static str { + let form: TagsForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let hashes = resolve_info_hashes(&state.api_state.api, &form.hashes); + state.tags.set(&hashes, &parse_tags(&form.tags)); + "Ok." +} + +#[derive(Deserialize, Default)] +struct SetLocationForm { + // setLocation uses `hashes`/`location`; setSavePath uses `id`/`path`. + #[serde(default, alias = "id")] + hashes: String, + #[serde(default, alias = "path")] + location: String, +} + +async fn h_torrents_set_location( + State(state): State>, + body: Bytes, +) -> axum::response::Response { + let form: SetLocationForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let api = &state.api_state.api; + let location = form.location.trim(); + if location.is_empty() { + return (StatusCode::BAD_REQUEST, "location is required").into_response(); + } + let new_root = PathBuf::from(location); + let mut last_error = None; + for idx in resolve_hashes(api, &form.hashes) { + if let Ok(handle) = api.mgr_handle(idx) + && let Err(error) = handle.set_location(new_root.clone()) + { + last_error = Some(error); + } + } + match last_error { + Some(error) => rename_conflict(error), + None => (StatusCode::OK, "Ok.").into_response(), + } +} + // --------------------------------------------------------------------------- // Category endpoints // --------------------------------------------------------------------------- @@ -1143,67 +2305,335 @@ async fn h_categories(State(state): State>) -> impl IntoResponse (name.clone(), QbitCategory { name, save_path }) }) .collect(); - axum::Json(serde_json::to_value(&map).unwrap_or_default()) + axum::Json(serde_json::to_value(&map).unwrap_or_default()) +} + +#[derive(Deserialize, Default)] +struct CreateCategoryForm { + #[serde(default)] + category: String, + #[serde(default, alias = "savePath")] + save_path: String, +} + +async fn h_create_category(State(state): State>, body: Bytes) -> impl IntoResponse { + let form: CreateCategoryForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + if form.category.is_empty() { + return (StatusCode::BAD_REQUEST, "Category name required").into_response(); + } + let save_path = (!form.save_path.is_empty()).then(|| form.save_path.into()); + match state + .api_state + .api + .api_create_or_edit_category(form.category, save_path) + .await + { + Ok(_) => (StatusCode::OK, "Ok.").into_response(), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + } +} + +async fn h_edit_category(State(state): State>, body: Bytes) -> impl IntoResponse { + let form: CreateCategoryForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + if form.category.is_empty() { + return (StatusCode::BAD_REQUEST, "Category name required").into_response(); + } + let save_path = (!form.save_path.is_empty()).then(|| form.save_path.into()); + match state + .api_state + .api + .api_create_or_edit_category(form.category, save_path) + .await + { + Ok(_) => (StatusCode::OK, "Ok.").into_response(), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + } +} + +#[derive(Deserialize, Default)] +struct RemoveCategoriesForm { + #[serde(default)] + categories: String, +} + +async fn h_remove_categories(State(state): State>, body: Bytes) -> &'static str { + let form: RemoveCategoriesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + for name in form.categories.split('\n') { + let name = name.trim(); + if !name.is_empty() + && let Err(error) = state.api_state.api.api_remove_category(name).await + { + warn!(%error, %name, "qbit compat: error removing category"); + } + } + "Ok." +} + +// --------------------------------------------------------------------------- +// Sync (main polling endpoint) +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +struct QbitServerState { + connection_status: &'static str, + dht_nodes: u64, + dl_info_data: u64, + dl_info_speed: u64, + dl_rate_limit: u64, + up_info_data: u64, + up_info_speed: u64, + up_rate_limit: u64, + queueing: bool, + use_alt_speed_limits: bool, + refresh_interval: u64, + free_space_on_disk: u64, + global_ratio: String, +} + +#[derive(Serialize)] +struct QbitMainData { + rid: u64, + full_update: bool, + torrents: HashMap, + categories: HashMap, + tags: Vec, + server_state: QbitServerState, +} + +#[derive(Deserialize, Default)] +struct SyncQuery { + #[serde(default)] + rid: u64, +} + +/// `sync/maindata` — the primary polling endpoint for WebUI frontends and many +/// integrations. We do not track per-client deltas, so every response is a +/// `full_update` snapshot (a valid, if chattier, mode of the protocol); the +/// `rid` is echoed back incremented so clients keep polling. +async fn h_sync_maindata( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let api = &state.api_state.api; + let now = now_unix(); + + let torrents: HashMap = api.session().with_torrents(|iter| { + iter.map(|(_id, handle)| { + let stats = handle.stats(); + let mut info = build_torrent_info(handle, &stats, now); + info.tags = state.tags.tags_for(&info.hash); + apply_share_limits(&mut info, &state.share_limits); + (info.hash.clone(), info) + }) + .collect() + }); + + let categories: HashMap = api + .api_list_categories() + .into_iter() + .map(|(name, category)| { + let save_path = category + .save_path + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_default(); + (name.clone(), QbitCategory { name, save_path }) + }) + .collect(); + + let session_stats = api.api_session_stats(); + let config = api.session().ratelimits.get_config(); + let downloaded = session_stats.counters.fetched_bytes; + let uploaded = session_stats.counters.uploaded_bytes; + let global_ratio = if downloaded == 0 { + "0.00".to_string() + } else { + format!("{:.2}", uploaded as f64 / downloaded as f64) + }; + + let server_state = QbitServerState { + connection_status: "connected", + dht_nodes: 0, + dl_info_data: downloaded, + dl_info_speed: session_stats.download_speed.as_bytes(), + dl_rate_limit: bps_to_u64(config.download_bps), + up_info_data: uploaded, + up_info_speed: session_stats.upload_speed.as_bytes(), + up_rate_limit: bps_to_u64(config.upload_bps), + queueing: false, + use_alt_speed_limits: api.session().alt_speed_enabled(), + refresh_interval: 1500, + free_space_on_disk: 0, + global_ratio, + }; + + axum::Json(QbitMainData { + rid: query.rid.saturating_add(1), + full_update: true, + torrents, + categories, + tags: state.tags.all_tags(), + server_state, + }) } +// --------------------------------------------------------------------------- +// Torrent creator endpoints +// --------------------------------------------------------------------------- + #[derive(Deserialize, Default)] -struct CreateCategoryForm { +struct AddTaskForm { + #[serde(default, alias = "sourcePath")] + source_path: String, #[serde(default)] - category: String, - #[serde(default, alias = "savePath")] - save_path: String, + format: String, + #[serde(default)] + trackers: String, + #[serde(default, alias = "pieceSize")] + piece_size: u32, + #[serde(default, alias = "torrentName", alias = "name")] + name: String, } -async fn h_create_category(State(state): State>, body: Bytes) -> impl IntoResponse { - let form: CreateCategoryForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - if form.category.is_empty() { - return (StatusCode::BAD_REQUEST, "Category name required").into_response(); +async fn h_torrentcreator_add_task( + State(state): State>, + body: Bytes, +) -> axum::response::Response { + if !state.api_state.opts.allow_create { + return ( + StatusCode::FORBIDDEN, + "creating torrents is not enabled on this server", + ) + .into_response(); } - let save_path = (!form.save_path.is_empty()).then(|| form.save_path.into()); - match state - .api_state - .api - .api_create_or_edit_category(form.category, save_path) - .await - { - Ok(_) => (StatusCode::OK, "Ok.").into_response(), - Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + let form: AddTaskForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + if form.source_path.trim().is_empty() { + return (StatusCode::BAD_REQUEST, "sourcePath is required").into_response(); } + + let format = if form.format.is_empty() { + "v1".to_string() + } else { + form.format.clone() + }; + let task_id = state.creator.new_task(form.source_path.clone(), format); + + // Run the (synchronous) creation off the request path and record the result. + let trackers: Vec = form + .trackers + .lines() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(); + let name = (!form.name.trim().is_empty()).then(|| form.name.clone()); + let piece_length = (form.piece_size > 0).then_some(form.piece_size); + let source = form.source_path.clone(); + let state = state.clone(); + let spawned_id = task_id.clone(); + tokio::spawn(async move { + let spawner = BlockingSpawner::new(1); + let options = CreateTorrentOptions { + name: name.as_deref(), + trackers, + piece_length, + }; + match create_torrent(std::path::Path::new(&source), options, &spawner).await { + Ok(result) => { + let bytes = result.as_bytes().ok(); + let info_hash = result.info_hash().as_string(); + state.creator.finish(&spawned_id, bytes, info_hash); + } + Err(error) => state.creator.fail(&spawned_id, format!("{error:#}")), + } + }); + + axum::Json(serde_json::json!({ "taskID": task_id })).into_response() } -async fn h_edit_category(State(state): State>, body: Bytes) -> impl IntoResponse { - let form: CreateCategoryForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - if form.category.is_empty() { - return (StatusCode::BAD_REQUEST, "Category name required").into_response(); - } - let save_path = (!form.save_path.is_empty()).then(|| form.save_path.into()); - match state - .api_state - .api - .api_create_or_edit_category(form.category, save_path) - .await - { - Ok(_) => (StatusCode::OK, "Ok.").into_response(), - Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct CreatorTaskStatus { + task_id: String, + source_path: String, + format: String, + status: CreatorStatus, + error_message: String, + progress: f64, +} + +fn creator_task_status(id: String, task: CreatorTask) -> CreatorTaskStatus { + CreatorTaskStatus { + progress: match task.status { + CreatorStatus::Finished => 1.0, + _ => 0.0, + }, + error_message: task.error.unwrap_or_default(), + task_id: id, + source_path: task.source_path, + format: task.format, + status: task.status, } } #[derive(Deserialize, Default)] -struct RemoveCategoriesForm { - #[serde(default)] - categories: String, +struct OptionalTaskIdQuery { + #[serde(default, alias = "taskID")] + task_id: String, } -async fn h_remove_categories(State(state): State>, body: Bytes) -> &'static str { - let form: RemoveCategoriesForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); - for name in form.categories.split('\n') { - let name = name.trim(); - if !name.is_empty() - && let Err(error) = state.api_state.api.api_remove_category(name).await - { - warn!(%error, %name, "qbit compat: error removing category"); - } +async fn h_torrentcreator_status( + State(state): State>, + Query(query): Query, +) -> axum::response::Response { + if !query.task_id.is_empty() { + return match state.creator.get(&query.task_id) { + Some(task) => { + axum::Json(vec![creator_task_status(query.task_id, task)]).into_response() + } + None => (StatusCode::NOT_FOUND, "task not found").into_response(), + }; + } + let all: Vec = state + .creator + .all() + .into_iter() + .map(|(id, task)| creator_task_status(id, task)) + .collect(); + axum::Json(all).into_response() +} + +#[derive(Deserialize)] +struct RequiredTaskIdQuery { + #[serde(alias = "taskID")] + task_id: String, +} + +async fn h_torrentcreator_torrent_file( + State(state): State>, + Query(query): Query, +) -> axum::response::Response { + match state.creator.get(&query.task_id) { + Some(task) if matches!(task.status, CreatorStatus::Finished) => match task.torrent_bytes { + Some(bytes) => ([("content-type", "application/x-bittorrent")], bytes).into_response(), + None => (StatusCode::INTERNAL_SERVER_ERROR, "no torrent bytes").into_response(), + }, + Some(_) => (StatusCode::CONFLICT, "task has not finished").into_response(), + None => (StatusCode::NOT_FOUND, "task not found").into_response(), } +} + +#[derive(Deserialize, Default)] +struct DeleteTaskForm { + #[serde(default, alias = "taskID")] + task_id: String, +} + +async fn h_torrentcreator_delete_task( + State(state): State>, + body: Bytes, +) -> &'static str { + let form: DeleteTaskForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + state.creator.remove(&form.task_id); "Ok." } @@ -1232,6 +2662,9 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { let qbit_state = Arc::new(QbitState { api_state: api_state.clone(), sessions: QbitSessions::new(), + tags: QbitTags::default(), + creator: QbitTorrentCreator::default(), + share_limits: QbitShareLimits::default(), }); // Auth endpoints (no auth required to reach these) @@ -1244,32 +2677,92 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { .route("/version", get(h_app_version)) .route("/webapiVersion", get(h_app_webapi_version)) .route("/buildInfo", get(h_app_build_info)) + .route("/defaultSavePath", get(h_app_default_save_path)) .route("/preferences", get(h_app_preferences)) .route("/setPreferences", post(h_app_set_preferences)); // Torrent endpoints let torrents_router = Router::new() .route("/info", get(h_torrents_info)) + .route("/count", get(h_torrents_count)) .route("/properties", get(h_torrents_properties)) .route("/files", get(h_torrents_files)) + .route("/trackers", get(h_torrents_trackers)) + .route("/webseeds", get(h_torrents_webseeds)) + .route("/export", get(h_torrents_export)) + .route("/pieceStates", get(h_torrents_piece_states)) + .route("/pieceHashes", get(h_torrents_piece_hashes)) .route("/add", post(h_torrents_add)) .route("/pause", post(h_torrents_pause)) .route("/resume", post(h_torrents_resume)) + .route("/stop", post(h_torrents_stop)) + .route("/start", post(h_torrents_start)) .route("/recheck", post(h_torrents_recheck)) + .route("/reannounce", post(h_torrents_reannounce)) + .route("/rename", post(h_torrents_rename)) + .route("/renameFile", post(h_torrents_rename_file)) + .route("/renameFolder", post(h_torrents_rename_folder)) .route("/delete", post(h_torrents_delete)) + .route("/addTrackers", post(h_torrents_add_trackers)) + .route("/removeTrackers", post(h_torrents_remove_trackers)) + .route("/editTracker", post(h_torrents_edit_tracker)) + .route("/addPeers", post(h_torrents_add_peers)) + .route("/filePrio", post(h_torrents_file_prio)) + .route("/downloadLimit", get(h_torrents_download_limit)) + .route("/uploadLimit", get(h_torrents_upload_limit)) + .route("/setDownloadLimit", post(h_torrents_set_download_limit)) + .route("/setUploadLimit", post(h_torrents_set_upload_limit)) + .route("/setShareLimits", post(h_torrents_set_share_limits)) + .route("/setLocation", post(h_torrents_set_location)) + .route("/setSavePath", post(h_torrents_set_location)) .route("/setCategory", post(h_torrents_set_category)) + .route("/tags", get(h_torrents_tags)) + .route("/createTags", post(h_torrents_create_tags)) + .route("/deleteTags", post(h_torrents_delete_tags)) + .route("/addTags", post(h_torrents_add_tags)) + .route("/removeTags", post(h_torrents_remove_tags)) + .route("/setTags", post(h_torrents_set_tags)) .route("/categories", get(h_categories)) .route("/createCategory", post(h_create_category)) .route("/editCategory", post(h_edit_category)) .route("/removeCategories", post(h_remove_categories)); - // Transfer info - let transfer_router = Router::new().route("/info", get(h_transfer_info)); + // Transfer info + session speed limits + let transfer_router = Router::new() + .route("/info", get(h_transfer_info)) + .route("/downloadLimit", get(h_transfer_download_limit)) + .route("/uploadLimit", get(h_transfer_upload_limit)) + .route("/setDownloadLimit", post(h_transfer_set_download_limit)) + .route("/setUploadLimit", post(h_transfer_set_upload_limit)) + .route("/speedLimitsMode", get(h_transfer_speed_limits_mode)) + .route( + "/toggleSpeedLimitsMode", + post(h_transfer_toggle_speed_limits_mode), + ) + .route( + "/setSpeedLimitsMode", + post(h_transfer_set_speed_limits_mode), + ) + .route("/pauseSession", post(h_transfer_pause_session)) + .route("/resumeSession", post(h_transfer_resume_session)); + + // Sync (main polling endpoint). Must stay nested inside protected_router so + // it runs behind the SID-cookie auth layer. + let sync_router = Router::new().route("/maindata", get(h_sync_maindata)); + + // Torrent creator task API (also behind the auth layer). + let torrentcreator_router = Router::new() + .route("/addTask", post(h_torrentcreator_add_task)) + .route("/status", get(h_torrentcreator_status)) + .route("/torrentFile", get(h_torrentcreator_torrent_file)) + .route("/deleteTask", post(h_torrentcreator_delete_task)); let protected_router = Router::new() .nest("/app", app_router) .nest("/torrents", torrents_router) .nest("/transfer", transfer_router) + .nest("/sync", sync_router) + .nest("/torrentcreator", torrentcreator_router) .route_layer({ let qbit_state_for_layer = qbit_state.clone(); axum::middleware::from_fn( @@ -1326,7 +2819,8 @@ mod tests { }; use super::{ - QbitSessions, QbitState, h_app_preferences, h_app_set_preferences, h_torrents_recheck, + CreatorStatus, QbitSessions, QbitShareLimits, QbitState, QbitTags, QbitTorrentCreator, + h_app_preferences, h_app_set_preferences, h_torrentcreator_add_task, h_torrents_recheck, matches_category, qbit_file_name, }; @@ -1358,6 +2852,7 @@ mod tests { api, Some(HttpApiOptions { web_ui_port: Some(3031), + allow_create: true, ..Default::default() }), )); @@ -1365,6 +2860,9 @@ mod tests { Arc::new(QbitState { api_state, sessions: QbitSessions::new(), + tags: QbitTags::default(), + creator: QbitTorrentCreator::default(), + share_limits: QbitShareLimits::default(), }), session, output, @@ -1426,7 +2924,7 @@ mod tests { } #[tokio::test] - async fn recheck_endpoint_accepts_qbittorrent_hash_form() { + async fn recheck_endpoint_accepts_qbit_hash_form() { let (state, session, output) = qbit_state().await; std::fs::write(output.path().join("payload.bin"), vec![0x71; 32 * 1024]).unwrap(); let torrent = create_torrent( @@ -1471,8 +2969,189 @@ mod tests { assert!(matches!(handle.stats().state, TorrentStatsState::Paused)); } + #[tokio::test] + async fn per_torrent_rate_limits_round_trip_on_handle() { + use std::num::NonZeroU32; + + let (_state, session, output) = qbit_state().await; + std::fs::write(output.path().join("payload.bin"), vec![0x71; 32 * 1024]).unwrap(); + let torrent = create_torrent( + output.path(), + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + let handle = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder: Some(output.path().to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await + .unwrap() + .into_handle() + .unwrap(); + handle.wait_until_initialized().await.unwrap(); + + // Defaults to unlimited. + assert_eq!(handle.rate_limits().download_bps, None); + assert_eq!(handle.rate_limits().upload_bps, None); + + // Each direction is set independently and persists on the override + // (this torrent is paused, so there is no live limiter to update). + handle.set_download_limit(NonZeroU32::new(4096)); + handle.set_upload_limit(NonZeroU32::new(8192)); + assert_eq!(handle.rate_limits().download_bps, NonZeroU32::new(4096)); + assert_eq!(handle.rate_limits().upload_bps, NonZeroU32::new(8192)); + + // Setting one leaves the other untouched. + handle.set_download_limit(NonZeroU32::new(1024)); + assert_eq!(handle.rate_limits().download_bps, NonZeroU32::new(1024)); + assert_eq!(handle.rate_limits().upload_bps, NonZeroU32::new(8192)); + + // None clears the limit. + handle.set_download_limit(None); + assert_eq!(handle.rate_limits().download_bps, None); + } + + #[tokio::test] + async fn rename_file_moves_on_disk_and_updates_metadata_when_paused() { + let (_state, session, output) = qbit_state().await; + std::fs::write(output.path().join("payload.bin"), vec![0x71; 32 * 1024]).unwrap(); + let torrent = create_torrent( + output.path(), + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + let handle = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder: Some(output.path().to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await + .unwrap() + .into_handle() + .unwrap(); + handle.wait_until_initialized().await.unwrap(); + + // Discover file 0's real on-disk location from the metadata. + let old_rel = handle + .with_metadata(|m| m.file_infos[0].relative_filename.clone()) + .unwrap(); + let old_abs = output.path().join(&old_rel); + assert!(old_abs.exists(), "expected file at {old_abs:?}"); + + // A path escaping the root is rejected before anything moves. + assert!( + handle + .rename_files(&[(0, std::path::PathBuf::from("../escape.bin"))]) + .is_err() + ); + assert!(old_abs.exists(), "rejected rename must not move anything"); + + // A valid rename moves the file on disk and updates file_infos. + let new_rel = std::path::PathBuf::from("renamed_dir/renamed.bin"); + handle.rename_files(&[(0, new_rel.clone())]).unwrap(); + assert_eq!( + handle + .with_metadata(|m| m.file_infos[0].relative_filename.clone()) + .unwrap(), + new_rel + ); + assert!(!old_abs.exists(), "old path should be gone"); + assert!( + output.path().join(&new_rel).exists(), + "new path should exist" + ); + + // The display-name override is independent of file renames. + assert!(handle.name().is_some()); + handle.set_display_name(Some("Custom Name".to_string())); + assert_eq!(handle.name().as_deref(), Some("Custom Name")); + handle.set_display_name(Some(" ".to_string())); + assert_ne!( + handle.name().as_deref(), + Some(" "), + "blank name clears override" + ); + } + + #[tokio::test] + async fn set_location_relocates_torrent_files_when_paused() { + let (_state, session, output) = qbit_state().await; + std::fs::write(output.path().join("payload.bin"), vec![0x71; 32 * 1024]).unwrap(); + let torrent = create_torrent( + output.path(), + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + let handle = session + .add_torrent( + AddTorrent::from_bytes(torrent), + Some(AddTorrentOptions { + paused: true, + overwrite: true, + output_folder: Some(output.path().to_string_lossy().into_owned()), + ..Default::default() + }), + ) + .await + .unwrap() + .into_handle() + .unwrap(); + handle.wait_until_initialized().await.unwrap(); + + let rel = handle + .with_metadata(|m| m.file_infos[0].relative_filename.clone()) + .unwrap(); + let old_abs = output.path().join(&rel); + assert!(old_abs.exists(), "expected file at {old_abs:?}"); + + // Relocate to a sibling directory on the same filesystem. + let new_root = output.path().join("relocated"); + handle.set_location(new_root.clone()).unwrap(); + + assert!(!old_abs.exists(), "old location should be gone"); + assert!( + new_root.join(&rel).exists(), + "file should now live under the new root" + ); + } + #[test] - fn category_filter_supports_qbittorrent_special_values() { + fn category_filter_supports_qbit_special_values() { assert!(matches_category("all", "Linux ISOs")); assert!(matches_category("all", "")); assert!(matches_category("uncategorized", "")); @@ -1543,4 +3222,188 @@ mod tests { let result = i64::try_from(now).unwrap_or(i64::MAX); assert_eq!(result, 1_700_000_000i64); } + + #[test] + fn parse_tags_trims_splits_and_drops_empties() { + assert_eq!( + super::parse_tags("tv, , radarr ,,sonarr"), + vec!["tv".to_string(), "radarr".to_string(), "sonarr".to_string()] + ); + assert!(super::parse_tags("").is_empty()); + assert!(super::parse_tags(" , ,").is_empty()); + } + + #[test] + fn tag_store_add_remove_delete_and_set() { + let tags = QbitTags::default(); + let a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(); + let b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(); + + // createTags adds to the global set without touching any torrent. + tags.create(&["tv".to_string(), "hd".to_string()]); + assert_eq!(tags.all_tags(), vec!["hd".to_string(), "tv".to_string()]); + assert_eq!(tags.tags_for(&a), ""); + + // addTags associates tags with torrents (and registers new ones). + tags.add_to( + &[a.clone(), b.clone()], + &["tv".to_string(), "new".to_string()], + ); + assert!(tags.has_tag(&a, "tv")); + assert!(tags.has_tag(&b, "new")); + assert!(tags.all_tags().contains(&"new".to_string())); + + // removeTags with a specific tag only drops it from the given torrent. + tags.remove_from(&[a.clone()], &["tv".to_string()]); + assert!(!tags.has_tag(&a, "tv")); + assert!(tags.has_tag(&a, "new")); + assert!(tags.has_tag(&b, "tv")); + + // setTags replaces the whole tag set of a torrent. + tags.set(&[b.clone()], &["only".to_string()]); + assert_eq!(tags.tags_for(&b), "only"); + + // deleteTags removes a tag globally and from every torrent. + tags.delete(&["new".to_string()]); + assert!(!tags.has_tag(&a, "new")); + assert!(!tags.all_tags().contains(&"new".to_string())); + + // removeTags with an empty list clears all tags on the torrent. + tags.add_to(&[a.clone()], &["x".to_string(), "y".to_string()]); + tags.remove_from(&[a.clone()], &[]); + assert_eq!(tags.tags_for(&a), ""); + } + + #[test] + fn offset_limit_pagination_matches_qbittorrent_semantics() { + use super::apply_offset_limit; + let v = || vec![0, 1, 2, 3, 4]; + // Normal window. + assert_eq!(apply_offset_limit(v(), 1, Some(2)), vec![1, 2]); + // Offset past the end -> empty (not the whole list). + assert_eq!(apply_offset_limit(v(), 5, None), Vec::::new()); + assert_eq!(apply_offset_limit(v(), 99, Some(3)), Vec::::new()); + // Offset exactly at the end -> empty. + assert_eq!(apply_offset_limit(v(), 5, None), Vec::::new()); + // limit == 0 means no limit. + assert_eq!(apply_offset_limit(v(), 0, Some(0)), vec![0, 1, 2, 3, 4]); + // No offset, no limit. + assert_eq!(apply_offset_limit(v(), 0, None), vec![0, 1, 2, 3, 4]); + } + + #[test] + fn share_limits_store_set_and_get() { + let store = super::QbitShareLimits::default(); + assert_eq!(store.get("h1"), None); + store.set(&["h1".to_string(), "h2".to_string()], 2.0, 120); + assert_eq!(store.get("h1"), Some((2.0, 120))); + assert_eq!(store.get("h2"), Some((2.0, 120))); + // Re-setting overwrites, and -1 encodes "no limit". + store.set(&["h1".to_string()], -1.0, -1); + assert_eq!(store.get("h1"), Some((-1.0, -1))); + assert_eq!(store.get("h2"), Some((2.0, 120))); + } + + #[test] + fn torrent_creator_task_store_lifecycle() { + let creator = QbitTorrentCreator::default(); + let id = creator.new_task("/data/movie".to_string(), "v1".to_string()); + let task = creator.get(&id).unwrap(); + assert!(matches!(task.status, CreatorStatus::Running)); + assert_eq!(task.source_path, "/data/movie"); + assert_eq!(creator.all().len(), 1); + + creator.finish( + &id, + Some(bytes::Bytes::from_static(b"torrent")), + "abc".to_string(), + ); + let task = creator.get(&id).unwrap(); + assert!(matches!(task.status, CreatorStatus::Finished)); + assert_eq!(task.info_hash.as_deref(), Some("abc")); + assert_eq!(task.torrent_bytes.as_deref(), Some(&b"torrent"[..])); + + let failing = creator.new_task("/data/x".to_string(), "v2".to_string()); + creator.fail(&failing, "boom".to_string()); + let task = creator.get(&failing).unwrap(); + assert!(matches!(task.status, CreatorStatus::Failed)); + assert_eq!(task.error.as_deref(), Some("boom")); + + assert!(creator.remove(&id)); + assert!(!creator.remove(&id)); + assert!(creator.get(&id).is_none()); + assert_eq!(creator.all().len(), 1); + } + + #[tokio::test] + async fn torrentcreator_add_task_produces_a_torrent_file() { + let (state, _session, output) = qbit_state().await; + std::fs::write(output.path().join("data.bin"), vec![7u8; 4096]).unwrap(); + + let body = Bytes::from(format!("sourcePath={}", output.path().to_string_lossy())); + let response = h_torrentcreator_add_task(axum::extract::State(state.clone()), body) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::OK); + + // The task is registered synchronously; poll it to completion. + let all = state.creator.all(); + assert_eq!(all.len(), 1); + let (id, _) = all.into_iter().next().unwrap(); + for _ in 0..200 { + if !matches!( + state.creator.get(&id).unwrap().status, + CreatorStatus::Running + ) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let task = state.creator.get(&id).unwrap(); + assert!( + matches!(task.status, CreatorStatus::Finished), + "creation did not finish: {:?}", + task.error + ); + assert!( + task.torrent_bytes.is_some(), + "expected generated torrent bytes" + ); + assert!(task.info_hash.is_some()); + } + + #[test] + fn tracker_status_maps_to_qbit_codes() { + use super::qbit_tracker_status; + use tracker_comms::TrackerAnnounceState::*; + assert_eq!(qbit_tracker_status(Disabled), 0); + assert_eq!(qbit_tracker_status(NotContacted), 1); + assert_eq!(qbit_tracker_status(Working), 2); + assert_eq!(qbit_tracker_status(Updating), 3); + assert_eq!(qbit_tracker_status(Error), 4); + } + + #[test] + fn rate_limit_conversions_round_trip_and_treat_zero_as_unlimited() { + use std::num::NonZeroU32; + assert_eq!(super::limit_to_bps(0), None); + assert_eq!(super::bps_to_u64(None), 0); + assert_eq!(super::limit_to_bps(1024), NonZeroU32::new(1024)); + assert_eq!(super::bps_to_u64(NonZeroU32::new(1024)), 1024); + // Values beyond u32 saturate rather than overflow. + assert_eq!(super::limit_to_bps(u64::MAX), NonZeroU32::new(u32::MAX)); + } + + #[test] + fn per_torrent_limit_treats_non_positive_as_unlimited() { + use std::num::NonZeroU32; + assert_eq!(super::parse_torrent_limit(0), None); + assert_eq!(super::parse_torrent_limit(-1), None); + assert_eq!(super::parse_torrent_limit(-999), None); + assert_eq!(super::parse_torrent_limit(2048), NonZeroU32::new(2048)); + assert_eq!( + super::parse_torrent_limit(i64::MAX), + NonZeroU32::new(u32::MAX) + ); + } } diff --git a/crates/librtbit/src/http_api/handlers/qbit_parity.rs b/crates/librtbit/src/http_api/handlers/qbit_parity.rs new file mode 100644 index 0000000..dde92ff --- /dev/null +++ b/crates/librtbit/src/http_api/handlers/qbit_parity.rs @@ -0,0 +1,230 @@ +//! qBittorrent WebUI API parity checker. +//! +//! `qbit_parity_spec.json` is a machine-readable inventory of the upstream +//! qBittorrent WebUI API v2 surface (extracted from the qBittorrent sources), +//! annotated with the parity status of each endpoint in our compat layer +//! (`qbit_compat.rs`). These tests keep the spec and the actual router from +//! drifting apart: +//! +//! - every endpoint marked `full` or `partial` must be routed; +//! - every endpoint marked `missing` or `out_of_scope` must return 404; +//! - every route literal in `qbit_compat.rs` must be tracked in the spec. +//! +//! When implementing a new compat endpoint, flip its `status` in the spec +//! (and drop a note about any semantic gaps). To refresh the upstream +//! inventory against a newer qBittorrent checkout, run +//! `scripts/refresh-qbit-parity-spec.py `. + +use std::{collections::HashSet, net::Ipv4Addr, sync::Arc}; + +use http::StatusCode; +use serde::Deserialize; +use tower::ServiceExt; + +use crate::{ + Api, ListenerMode, Session, SessionOptions, + http_api::{HttpApi, HttpApiOptions}, + listen::ListenerOptions, +}; + +const SPEC_JSON: &str = include_str!("qbit_parity_spec.json"); +const COMPAT_SOURCE: &str = include_str!("qbit_compat.rs"); + +#[derive(Deserialize)] +struct Spec { + upstream: UpstreamInfo, + endpoints: Vec, +} + +#[derive(Deserialize)] +struct UpstreamInfo { + webapi_version: String, +} + +#[derive(Deserialize)] +struct Endpoint { + /// `controller/action`, e.g. `torrents/info`. + endpoint: String, + method: String, + status: Status, + /// False for legacy endpoints we serve that upstream has since removed. + #[serde(default = "default_true")] + upstream: bool, +} + +fn default_true() -> bool { + true +} + +#[derive(Deserialize, Clone, Copy, PartialEq, Eq, Debug)] +#[serde(rename_all = "snake_case")] +enum Status { + /// Routed, semantics match qBittorrent closely enough for real clients. + Full, + /// Routed, but with stubbed fields or semantic gaps (see notes). + Partial, + /// Not routed; candidate for future parity work. + Missing, + /// Not routed by explicit decision. + OutOfScope, +} + +impl Status { + fn is_routed(self) -> bool { + matches!(self, Status::Full | Status::Partial) + } +} + +fn parse_spec() -> Spec { + serde_json::from_str(SPEC_JSON).expect("qbit_parity_spec.json must be valid JSON") +} + +async fn make_router() -> (axum::Router, Arc, tempfile::TempDir) { + let output = tempfile::TempDir::with_prefix("qbit_parity").unwrap(); + let session = Session::new_with_opts( + output.path().to_owned(), + SessionOptions { + disable_dht: true, + disable_local_service_discovery: true, + listen: Some(ListenerOptions { + mode: ListenerMode::TcpOnly, + listen_addr: (Ipv4Addr::LOCALHOST, 0).into(), + ..Default::default() + }), + ..Default::default() + }, + ) + .await + .unwrap(); + let api = Api::new( + session.clone(), + None, + #[cfg(feature = "tracing-subscriber-utils")] + None, + ); + let api_state = Arc::new(HttpApi::new(api, Some(HttpApiOptions::default()))); + ( + super::qbit_compat::make_qbit_router(api_state), + session, + output, + ) +} + +#[test] +fn spec_is_well_formed() { + let spec = parse_spec(); + assert!(!spec.upstream.webapi_version.is_empty()); + let mut seen = HashSet::new(); + for ep in &spec.endpoints { + assert!( + seen.insert(ep.endpoint.as_str()), + "duplicate spec entry: {}", + ep.endpoint + ); + assert!( + matches!(ep.method.as_str(), "GET" | "POST"), + "{}: unexpected method {}", + ep.endpoint, + ep.method + ); + assert!( + ep.endpoint.split('/').count() == 2, + "{}: endpoint must be controller/action", + ep.endpoint + ); + if !ep.upstream { + assert!( + ep.status.is_routed(), + "{}: non-upstream (legacy) entries only make sense if we route them", + ep.endpoint + ); + } + } +} + +/// Probes every spec endpoint against the real compat router and fails on any +/// mismatch between the declared parity status and what is actually routed. +#[tokio::test] +async fn spec_matches_router() { + let spec = parse_spec(); + let (router, _session, _output) = make_router().await; + + let mut violations = Vec::new(); + for ep in &spec.endpoints { + let request = http::Request::builder() + .method(ep.method.as_str()) + .uri(format!("/{}", ep.endpoint)) + .header("content-type", "application/x-www-form-urlencoded") + .body(axum::body::Body::empty()) + .unwrap(); + let status = router.clone().oneshot(request).await.unwrap().status(); + // Anything but 404/405 (including 400 for probes lacking required + // params) proves the route exists with the expected method. + let routed = status != StatusCode::NOT_FOUND && status != StatusCode::METHOD_NOT_ALLOWED; + if routed != ep.status.is_routed() { + violations.push(format!( + "{} {} is marked {:?} in qbit_parity_spec.json but the router returned {status}", + ep.method, ep.endpoint, ep.status + )); + } + } + + assert!( + violations.is_empty(), + "qbit compat router and qbit_parity_spec.json disagree; \ + update the spec status (or the router) for:\n{}", + violations.join("\n") + ); +} + +/// Every `.route("...")` literal in qbit_compat.rs must correspond to a spec +/// entry marked as routed, so new compat routes can't land untracked. +#[test] +fn every_compat_route_is_tracked_in_spec() { + let spec = parse_spec(); + let routed_actions: HashSet<&str> = spec + .endpoints + .iter() + .filter(|ep| ep.status.is_routed()) + .filter_map(|ep| ep.endpoint.split_once('/').map(|(_, action)| action)) + .collect(); + + let mut untracked = Vec::new(); + for (idx, _) in COMPAT_SOURCE.match_indices(".route(\"/") { + let start = idx + ".route(\"/".len(); + let action = COMPAT_SOURCE[start..] + .split('"') + .next() + .expect("unterminated route literal"); + if !routed_actions.contains(action) { + untracked.push(action); + } + } + + assert!( + untracked.is_empty(), + "routes in qbit_compat.rs with no full/partial entry in qbit_parity_spec.json: {untracked:?}" + ); +} + +/// Not an assertion — prints the parity scoreboard (visible with +/// `cargo test -p swarmforge qbit_parity -- --nocapture`). +#[test] +fn parity_summary() { + let spec = parse_spec(); + let upstream: Vec<&Endpoint> = spec.endpoints.iter().filter(|ep| ep.upstream).collect(); + let count = |status: Status| upstream.iter().filter(|ep| ep.status == status).count(); + let (full, partial, missing, oos) = ( + count(Status::Full), + count(Status::Partial), + count(Status::Missing), + count(Status::OutOfScope), + ); + let implemented = full + partial; + eprintln!( + "qBittorrent WebUI API v{} parity: {implemented}/{} endpoints routed \ + ({full} full, {partial} partial, {missing} missing, {oos} out of scope)", + spec.upstream.webapi_version, + upstream.len(), + ); +} diff --git a/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json b/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json new file mode 100644 index 0000000..5fd05ad --- /dev/null +++ b/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json @@ -0,0 +1,758 @@ +{ + "upstream": { + "webapi_version": "2.16.2", + "source_commit": "fe4506e8c6af67cd49720b8254d7b97fe5504a69", + "extracted_on": "2026-08-30" + }, + "endpoints": [ + { + "endpoint": "app/buildInfo", + "method": "GET", + "status": "partial", + "notes": "Placeholder values for qt/libtorrent/boost/openssl." + }, + { + "endpoint": "app/cookies", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/defaultSavePath", + "method": "GET", + "status": "full" + }, + { + "endpoint": "app/deleteAPIKey", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/getDirectoryContent", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/getFreeSpaceAtPath", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "app/networkInterfaceAddressList", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "app/networkInterfaceList", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "app/preferences", + "method": "GET", + "status": "partial", + "notes": "Small subset of preference fields." + }, + { + "endpoint": "app/processInfo", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/rotateAPIKey", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/sendTestEmail", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/setCookies", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent-internal app feature (2026-08-30 descoping decision)." + }, + { + "endpoint": "app/setPreferences", + "method": "POST", + "status": "partial", + "notes": "Only announce_port is settable; other fields rejected." + }, + { + "endpoint": "app/shutdown", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "app/version", + "method": "GET", + "status": "partial", + "notes": "Returns a hardcoded version string, not a real qBittorrent version." + }, + { + "endpoint": "app/webapiVersion", + "method": "GET", + "status": "full" + }, + { + "endpoint": "auth/login", + "method": "POST", + "status": "full" + }, + { + "endpoint": "auth/logout", + "method": "POST", + "status": "full" + }, + { + "endpoint": "clientdata/load", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: WebUI client-state KV store; not needed for targeted clients." + }, + { + "endpoint": "clientdata/store", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: WebUI client-state KV store; not needed for targeted clients." + }, + { + "endpoint": "log/main", + "method": "GET", + "status": "missing", + "notes": "No persistent log ring buffer; native API streams logs instead." + }, + { + "endpoint": "log/peers", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "rss/addFeed", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/addFolder", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/cloneRule", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/items", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/markAsRead", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/matchingArticles", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/moveItem", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/refreshItem", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/removeItem", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/removeRule", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/renameRule", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/rules", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/setFeedRefreshInterval", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/setFeedURL", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "rss/setRule", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent RSS model (folders, multi-field rules) not bridged; native /rss API + Indexarr are the RSS story." + }, + { + "endpoint": "search/delete", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/downloadTorrent", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/enablePlugin", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/installPlugin", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/plugins", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/results", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/start", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/status", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/stop", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/uninstallPlugin", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "search/updatePlugins", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: qBittorrent Python search-plugin system; Indexarr covers discovery natively." + }, + { + "endpoint": "sync/maindata", + "method": "GET", + "status": "partial", + "notes": "Always returns a full_update snapshot (no per-client rid deltas); free_space_on_disk and dht_nodes stubbed." + }, + { + "endpoint": "sync/torrentPeers", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "torrentcreator/addTask", + "method": "POST", + "status": "partial", + "notes": "Bridges native create_torrent (sourcePath/trackers/pieceSize/name); other options ignored. Requires server create to be enabled. Tasks not persisted across restarts." + }, + { + "endpoint": "torrentcreator/deleteTask", + "method": "POST", + "status": "full", + "notes": "Removes a task from the in-memory store." + }, + { + "endpoint": "torrentcreator/status", + "method": "GET", + "status": "full", + "notes": "In-memory task status (Running/Finished/Failed)." + }, + { + "endpoint": "torrentcreator/torrentFile", + "method": "GET", + "status": "full", + "notes": "Returns the generated .torrent bytes for a finished task." + }, + { + "endpoint": "torrents/SSLParameters", + "method": "GET", + "status": "out_of_scope", + "notes": "Out of scope: per-torrent SSL certificates not planned." + }, + { + "endpoint": "torrents/add", + "method": "POST", + "status": "partial", + "notes": "Supports urls/torrents/savepath/category/paused; other form fields ignored." + }, + { + "endpoint": "torrents/addPeers", + "method": "POST", + "status": "partial", + "notes": "Adds peers to live torrents; silently skips torrents that are not yet live." + }, + { + "endpoint": "torrents/addTags", + "method": "POST", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/addTrackers", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/addWebSeeds", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/bottomPrio", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/categories", + "method": "GET", + "status": "full" + }, + { + "endpoint": "torrents/count", + "method": "GET", + "status": "full" + }, + { + "endpoint": "torrents/createCategory", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/createTags", + "method": "POST", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/decreasePrio", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/delete", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/deleteTags", + "method": "POST", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/downloadFile", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "torrents/downloadLimit", + "method": "GET", + "status": "partial", + "notes": "Per-torrent limit persisted on the torrent and enforced while live (resets are avoided across pause/unpause)." + }, + { + "endpoint": "torrents/editCategory", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/editTracker", + "method": "POST", + "status": "partial", + "notes": "Replaces origUrl with newUrl (remove + add); validates newUrl before removing so a bad newUrl is non-destructive." + }, + { + "endpoint": "torrents/editWebSeed", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/export", + "method": "GET", + "status": "partial", + "notes": "Returns raw .torrent bytes; 409 while a magnet is still resolving." + }, + { + "endpoint": "torrents/fetchMetadata", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/filePrio", + "method": "POST", + "status": "partial", + "notes": "Maps priority 0 to file exclusion and any non-zero priority to inclusion (no per-file priority tiers)." + }, + { + "endpoint": "torrents/files", + "method": "GET", + "status": "partial", + "notes": "piece_range is stubbed as [0, 0]." + }, + { + "endpoint": "torrents/increasePrio", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/info", + "method": "GET", + "status": "partial", + "notes": "Core fields real; several fields stubbed (tags, limits, magnet_uri, priority)." + }, + { + "endpoint": "torrents/parseMetadata", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/pause", + "method": "POST", + "status": "full", + "upstream": false, + "notes": "Legacy pre-2.11 name kept for old clients; upstream replaced it with torrents/stop." + }, + { + "endpoint": "torrents/pieceAvailability", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "torrents/pieceHashes", + "method": "GET", + "status": "partial", + "notes": "v1 piece hashes from metadata; empty array while a magnet is still resolving or for v2-only torrents." + }, + { + "endpoint": "torrents/pieceStates", + "method": "GET", + "status": "partial", + "notes": "Emits 0 (not downloaded) / 2 (downloaded) from the have bitfield; the in-flight state 1 is not distinguished." + }, + { + "endpoint": "torrents/properties", + "method": "GET", + "status": "partial", + "notes": "Some fields stubbed (creation_date, comment, wasted, reannounce)." + }, + { + "endpoint": "torrents/reannounce", + "method": "POST", + "status": "partial", + "notes": "Signals a fresh tracker announce / peer re-discovery on live torrents; a no-op for paused or finished-idle torrents." + }, + { + "endpoint": "torrents/recheck", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/removeCategories", + "method": "POST", + "status": "full" + }, + { + "endpoint": "torrents/removeTags", + "method": "POST", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/removeTrackers", + "method": "POST", + "status": "partial", + "notes": "Removes URLs from the tracker set (persisted); an in-flight announce loop for a removed tracker stops at the next re-discovery." + }, + { + "endpoint": "torrents/removeWebSeeds", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/rename", + "method": "POST", + "status": "full", + "notes": "Sets the torrent's display name (name override); surfaced in torrents/info and sync/maindata. Not persisted across restarts." + }, + { + "endpoint": "torrents/renameFile", + "method": "POST", + "status": "partial", + "notes": "Moves the file on disk and updates metadata; requires the torrent stopped (409 if running). Not persisted across restarts." + }, + { + "endpoint": "torrents/renameFolder", + "method": "POST", + "status": "partial", + "notes": "Moves every file under the folder prefix; requires the torrent stopped (409 if running). Not persisted across restarts." + }, + { + "endpoint": "torrents/resume", + "method": "POST", + "status": "full", + "upstream": false, + "notes": "Legacy pre-2.11 name kept for old clients; upstream replaced it with torrents/start." + }, + { + "endpoint": "torrents/saveMetadata", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "torrents/setAutoManagement", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/setCategory", + "method": "POST", + "status": "partial", + "notes": "Does not return 409 for unknown categories like qBittorrent does." + }, + { + "endpoint": "torrents/setComment", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/setDownloadLimit", + "method": "POST", + "status": "partial", + "notes": "Per-torrent limit persisted on the torrent and enforced while live (resets are avoided across pause/unpause)." + }, + { + "endpoint": "torrents/setDownloadPath", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/setForceStart", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/setLocation", + "method": "POST", + "status": "partial", + "notes": "Moves all files to a new root; stopped-only (409 if running) and same-filesystem only (cross-device rejected). Not persisted across restarts." + }, + { + "endpoint": "torrents/setSSLParameters", + "method": "POST", + "status": "out_of_scope", + "notes": "Out of scope: per-torrent SSL certificates not planned." + }, + { + "endpoint": "torrents/setSavePath", + "method": "POST", + "status": "partial", + "notes": "Alias of setLocation (id/path form): stopped-only, same-filesystem move. Not persisted across restarts." + }, + { + "endpoint": "torrents/setShareLimits", + "method": "POST", + "status": "partial", + "notes": "Stores ratio/seeding-time limits and reports them in torrents/info; not auto-enforced (clients apply their own removal). Not persisted across restarts." + }, + { + "endpoint": "torrents/setSuperSeeding", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/setTags", + "method": "POST", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/setUploadLimit", + "method": "POST", + "status": "partial", + "notes": "Per-torrent limit persisted on the torrent and enforced while live (resets are avoided across pause/unpause)." + }, + { + "endpoint": "torrents/start", + "method": "POST", + "status": "full", + "notes": "WebAPI 2.11+ name for torrents/resume; shares the resume handler." + }, + { + "endpoint": "torrents/stop", + "method": "POST", + "status": "full", + "notes": "WebAPI 2.11+ name for torrents/pause; shares the pause handler." + }, + { + "endpoint": "torrents/tags", + "method": "GET", + "status": "partial", + "notes": "In-memory tag store (not persisted across restarts); rtbit has no native tags." + }, + { + "endpoint": "torrents/toggleFirstLastPiecePrio", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/toggleSequentialDownload", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/topPrio", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "torrents/trackers", + "method": "GET", + "status": "partial", + "notes": "Real per-tracker announce state; tier always 0 and num_downloaded always -1 (not tracked)." + }, + { + "endpoint": "torrents/uploadLimit", + "method": "GET", + "status": "partial", + "notes": "Per-torrent limit persisted on the torrent and enforced while live (resets are avoided across pause/unpause)." + }, + { + "endpoint": "torrents/webseeds", + "method": "GET", + "status": "full" + }, + { + "endpoint": "transfer/banPeers", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "transfer/downloadLimit", + "method": "GET", + "status": "full" + }, + { + "endpoint": "transfer/getSpeedLimits", + "method": "GET", + "status": "missing" + }, + { + "endpoint": "transfer/info", + "method": "GET", + "status": "partial", + "notes": "Real session rate limits; dht_nodes stubbed and connection_status always connected." + }, + { + "endpoint": "transfer/pauseSession", + "method": "POST", + "status": "partial", + "notes": "Pauses every torrent (no global session pause)." + }, + { + "endpoint": "transfer/resumeSession", + "method": "POST", + "status": "partial", + "notes": "Resumes every torrent (no global session pause)." + }, + { + "endpoint": "transfer/setDownloadLimit", + "method": "POST", + "status": "full" + }, + { + "endpoint": "transfer/setSpeedLimits", + "method": "POST", + "status": "missing" + }, + { + "endpoint": "transfer/setSpeedLimitsMode", + "method": "POST", + "status": "full", + "notes": "Sets the alt-speed mode (native /speed/alt)." + }, + { + "endpoint": "transfer/setUploadLimit", + "method": "POST", + "status": "full" + }, + { + "endpoint": "transfer/speedLimitsMode", + "method": "GET", + "status": "full", + "notes": "Reflects the alt-speed toggle (native /speed/alt)." + }, + { + "endpoint": "transfer/toggleSpeedLimitsMode", + "method": "POST", + "status": "full", + "notes": "Toggles the alt-speed mode (native /speed/alt)." + }, + { + "endpoint": "transfer/uploadLimit", + "method": "GET", + "status": "full" + } + ] +} diff --git a/crates/librtbit/src/session/mod.rs b/crates/librtbit/src/session/mod.rs index 9cf751b..61c6d35 100644 --- a/crates/librtbit/src/session/mod.rs +++ b/crates/librtbit/src/session/mod.rs @@ -952,9 +952,8 @@ impl Session { peer_connect_timeout: peer_opts.connect_timeout, peer_read_write_timeout: peer_opts.read_write_timeout, allow_overwrite: opts.overwrite, - output_folder, + output_folder: output_folder.clone(), output_folder_root, - ratelimits: opts.ratelimits, initial_peers: opts.initial_peers.clone().unwrap_or_default(), peer_limit: opts.peer_limit.or(self.peer_limit), #[cfg(feature = "disable-upload")] @@ -965,6 +964,9 @@ impl Session { magnet_name: name, web_seed_urls, category: RwLock::new(opts.category.clone()), + ratelimit_override: RwLock::new(opts.ratelimits), + name_override: RwLock::new(None), + output_folder_override: RwLock::new(output_folder), tracker_status, added_on: opts.added_on.unwrap_or_else(|| { std::time::SystemTime::now() @@ -1103,7 +1105,7 @@ impl Session { (Ok(storage), true) => { debug!("will delete files"); remove_files_and_dirs(&metadata.file_infos, &storage); - if removed.shared().options.output_folder != *self.output_folder.read() + if removed.output_folder() != *self.output_folder.read() && let Err(e) = storage.remove_directory_if_empty(Path::new("")) { warn!( @@ -1212,6 +1214,38 @@ impl Session { Ok(()) } + /// Remove tracker URLs from a torrent's configured set. Returns the number + /// of trackers actually removed. Any in-flight announce loop for a removed + /// tracker continues until the next re-discovery; this only changes the set + /// used for future announces (and persists the change). + pub async fn remove_trackers( + self: &Arc, + handle: &ManagedTorrentHandle, + trackers: &[String], + ) -> anyhow::Result { + // Match on parsed URL where possible so callers can pass either the + // exact stored string or an equivalent spelling; fall back to string. + let removed = { + let mut configured = handle.shared().trackers.write(); + let mut removed = 0; + for tracker in trackers { + let matched = url::Url::parse(tracker) + .ok() + .map(|url| configured.remove(&url)) + .unwrap_or(false); + if matched { + removed += 1; + } + } + removed + }; + + if removed > 0 { + self.try_update_persistence_metadata(handle).await; + } + Ok(removed) + } + pub async fn update_only_files( self: &Arc, handle: &ManagedTorrentHandle, @@ -1325,7 +1359,9 @@ impl Session { handle: &ManagedTorrentHandle, target_folder: PathBuf, ) -> anyhow::Result<()> { - let current_output = handle.shared().options.output_folder.clone(); + // Use the torrent's current root so a relocated torrent moves from + // where its files actually are, not the stale add-time folder. + let current_output = handle.output_folder(); // Don't move if already in the target folder if current_output == target_folder { diff --git a/crates/librtbit/src/storage/filesystem/fs.rs b/crates/librtbit/src/storage/filesystem/fs.rs index 601e493..7f151e3 100644 --- a/crates/librtbit/src/storage/filesystem/fs.rs +++ b/crates/librtbit/src/storage/filesystem/fs.rs @@ -5,6 +5,7 @@ use std::{ }; use anyhow::Context; +use parking_lot::RwLock; use tracing::warn; use crate::{ @@ -38,7 +39,7 @@ impl StorageFactory for FilesystemStorageFactory { _metadata: &TorrentMetadata, ) -> anyhow::Result { Ok(FilesystemStorage { - output_folder: shared.options.output_folder.clone(), + output_folder: RwLock::new(shared.output_folder_override.read().clone()), opened_files: Default::default(), read_only: self.read_only, }) @@ -50,7 +51,9 @@ impl StorageFactory for FilesystemStorageFactory { } pub struct FilesystemStorage { - pub(super) output_folder: PathBuf, + /// Storage root. Mutable so a whole-torrent relocation (`move_root`) can + /// re-anchor the storage; only cold paths (init/remove/rename/move) read it. + pub(super) output_folder: RwLock, pub(super) opened_files: Vec, read_only: bool, } @@ -70,7 +73,7 @@ impl FilesystemStorage { .iter() .map(|f| f.take_clone()) .collect::>>()?, - output_folder: self.output_folder.clone(), + output_folder: RwLock::new(self.output_folder.read().clone()), read_only: self.read_only, }) } @@ -110,7 +113,43 @@ impl TorrentStorage for FilesystemStorage { fn remove_file(&self, _file_id: usize, filename: &Path) -> anyhow::Result<()> { self.require_writable()?; - Ok(std::fs::remove_file(self.output_folder.join(filename))?) + Ok(std::fs::remove_file( + self.output_folder.read().join(filename), + )?) + } + + fn rename_file(&self, file_id: usize, new_relative: &Path) -> anyhow::Result<()> { + self.require_writable()?; + let of = self.opened_files.get(file_id).context("no such file")?; + let new_full = self.output_folder.read().join(new_relative); + of.rename_to(&new_full) + } + + fn move_root(&self, new_root: &Path) -> anyhow::Result<()> { + self.require_writable()?; + let old_root = self.output_folder.read().clone(); + if old_root == new_root { + return Ok(()); + } + // Move each file to the same relative location under the new root. On + // any failure (including a cross-filesystem EXDEV rename), roll the + // already-moved files back before returning. + for (moved, of) in self.opened_files.iter().enumerate() { + if let Err(error) = of.rebase(&old_root, new_root) { + for prev in self.opened_files[..moved].iter().rev() { + if let Err(rollback_error) = prev.rebase(new_root, &old_root) { + warn!( + %rollback_error, + "error rolling back a file after a failed relocation; \ + storage may be left inconsistent for this torrent" + ); + } + } + return Err(error).context("error relocating torrent; rolled back"); + } + } + *self.output_folder.write() = new_root.to_owned(); + Ok(()) } fn ensure_file_length(&self, file_id: usize, len: u64) -> anyhow::Result<()> { @@ -136,14 +175,14 @@ impl TorrentStorage for FilesystemStorage { .iter() .map(|f| f.take_clone()) .collect::>>()?, - output_folder: self.output_folder.clone(), + output_folder: RwLock::new(self.output_folder.read().clone()), read_only: self.read_only, })) } fn remove_directory_if_empty(&self, path: &Path) -> anyhow::Result<()> { self.require_writable()?; - let path = self.output_folder.join(path); + let path = self.output_folder.read().join(path); if !path.is_dir() { anyhow::bail!("cannot remove dir: {path:?} is not a directory") } @@ -165,8 +204,9 @@ impl TorrentStorage for FilesystemStorage { metadata: &TorrentMetadata, ) -> anyhow::Result<()> { let mut files = Vec::::new(); + let output_folder = self.output_folder.read().clone(); for file_details in metadata.file_infos.iter() { - let mut full_path = self.output_folder.clone(); + let mut full_path = output_folder.clone(); let relative_path = &file_details.relative_filename; full_path.push(relative_path); @@ -242,7 +282,7 @@ mod tests { opened_files.push(OpenedFile::new(path, f)); } let storage = FilesystemStorage { - output_folder: dir.path().to_owned(), + output_folder: RwLock::new(dir.path().to_owned()), opened_files, read_only: false, }; @@ -388,6 +428,122 @@ mod tests { assert!(file1_path.exists()); } + #[test] + fn test_storage_rename_file_moves_and_keeps_handle_usable() { + let (storage, dir) = make_test_storage(1); + storage.ensure_file_length(0, 64).unwrap(); + storage.pwrite_all(0, 0, b"payload").unwrap(); + + // Rename into a fresh subdirectory (must be created). + storage + .rename_file(0, Path::new("sub/renamed.dat")) + .unwrap(); + + // Old path is gone, new path exists with the data. + assert!(!dir.path().join("file_0.dat").exists()); + let new_path = dir.path().join("sub/renamed.dat"); + assert!(new_path.exists()); + assert_eq!(&std::fs::read(&new_path).unwrap()[..7], b"payload"); + + // The cached handle still reads/writes at the new location. + let mut buf = vec![0u8; 7]; + storage.pread_exact(0, 0, &mut buf).unwrap(); + assert_eq!(&buf, b"payload"); + storage.pwrite_all(0, 0, b"updated").unwrap(); + assert_eq!(&std::fs::read(&new_path).unwrap()[..7], b"updated"); + } + + #[test] + fn test_storage_rename_file_refuses_to_clobber_existing_destination() { + let (storage, dir) = make_test_storage(1); + storage.ensure_file_length(0, 8).unwrap(); + storage.pwrite_all(0, 0, b"torrent!").unwrap(); + + // An unrelated file already sits at the target path. + let victim = dir.path().join("unrelated.dat"); + std::fs::write(&victim, b"precious").unwrap(); + + // The rename must be refused, and neither file may be touched. + assert!(storage.rename_file(0, Path::new("unrelated.dat")).is_err()); + assert_eq!(std::fs::read(&victim).unwrap(), b"precious"); + assert!(dir.path().join("file_0.dat").exists()); + // The torrent file's handle is still usable at its original location. + let mut buf = vec![0u8; 8]; + storage.pread_exact(0, 0, &mut buf).unwrap(); + assert_eq!(&buf, b"torrent!"); + } + + #[test] + fn test_storage_move_root_relocates_all_files_and_keeps_handles() { + let (storage, dir) = make_test_storage(2); + storage.ensure_file_length(0, 16).unwrap(); + storage.ensure_file_length(1, 16).unwrap(); + storage.pwrite_all(0, 0, b"aaaa").unwrap(); + storage.pwrite_all(1, 0, b"bbbb").unwrap(); + + let new_root = dir.path().join("moved"); + storage.move_root(&new_root).unwrap(); + + assert!(!dir.path().join("file_0.dat").exists()); + assert!(new_root.join("file_0.dat").exists()); + assert!(new_root.join("file_1.dat").exists()); + + // Handles still read at the new location, and a write lands there. + let mut buf = vec![0u8; 4]; + storage.pread_exact(0, 0, &mut buf).unwrap(); + assert_eq!(&buf, b"aaaa"); + storage.pwrite_all(1, 0, b"cccc").unwrap(); + assert_eq!( + &std::fs::read(new_root.join("file_1.dat")).unwrap()[..4], + b"cccc" + ); + + // The anchor moved: path-based ops now resolve under the new root. + storage.remove_file(0, Path::new("file_0.dat")).unwrap(); + assert!(!new_root.join("file_0.dat").exists()); + } + + #[test] + fn test_storage_move_root_rolls_back_on_partial_failure() { + let (storage, dir) = make_test_storage(2); + storage.ensure_file_length(0, 8).unwrap(); + storage.ensure_file_length(1, 8).unwrap(); + storage.pwrite_all(0, 0, b"keepme").unwrap(); + + let new_root = dir.path().join("dest"); + std::fs::create_dir_all(&new_root).unwrap(); + // Block the second file's destination so the relocation fails midway. + std::fs::write(new_root.join("file_1.dat"), b"blocker").unwrap(); + + assert!(storage.move_root(&new_root).is_err()); + + // File 0 was rolled back to the original root and is still usable. + assert!(dir.path().join("file_0.dat").exists()); + assert!(!new_root.join("file_0.dat").exists()); + let mut buf = vec![0u8; 6]; + storage.pread_exact(0, 0, &mut buf).unwrap(); + assert_eq!(&buf, b"keepme"); + // The unrelated blocker file is untouched. + assert_eq!( + std::fs::read(new_root.join("file_1.dat")).unwrap(), + b"blocker" + ); + } + + #[test] + fn test_storage_rename_file_rejected_when_read_only() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("payload.dat"); + std::fs::write(&path, b"x").unwrap(); + let file = OpenOptions::new().read(true).open(&path).unwrap(); + let storage = FilesystemStorage { + output_folder: RwLock::new(dir.path().to_owned()), + opened_files: vec![OpenedFile::new(path, file)], + read_only: true, + }; + assert!(storage.rename_file(0, Path::new("other.dat")).is_err()); + } + #[test] fn test_storage_take() { let (storage, _dir) = make_test_storage(1); @@ -454,7 +610,7 @@ mod tests { std::fs::write(&path, original).unwrap(); let file = OpenOptions::new().read(true).open(&path).unwrap(); let storage = FilesystemStorage { - output_folder: dir.path().to_owned(), + output_folder: RwLock::new(dir.path().to_owned()), opened_files: vec![OpenedFile::new(path.clone(), file)], read_only: true, }; diff --git a/crates/librtbit/src/storage/filesystem/mmap.rs b/crates/librtbit/src/storage/filesystem/mmap.rs index 4a77151..ffb75d3 100644 --- a/crates/librtbit/src/storage/filesystem/mmap.rs +++ b/crates/librtbit/src/storage/filesystem/mmap.rs @@ -78,6 +78,18 @@ impl TorrentStorage for MmapFilesystemStorage { self.fs.remove_file(file_id, filename) } + fn rename_file(&self, file_id: usize, new_relative: &Path) -> anyhow::Result<()> { + // The mmap stays valid across a rename on Unix (it is bound to the + // inode, not the name); the fs layer moves the file and reopens its fd. + self.fs.rename_file(file_id, new_relative) + } + + fn move_root(&self, new_root: &Path) -> anyhow::Result<()> { + // Same inode-based reasoning as rename_file: the mappings survive the + // move on Unix; the fs layer relocates the files and reopens the fds. + self.fs.move_root(new_root) + } + fn remove_directory_if_empty(&self, path: &Path) -> anyhow::Result<()> { self.fs.remove_directory_if_empty(path) } diff --git a/crates/librtbit/src/storage/filesystem/opened_file.rs b/crates/librtbit/src/storage/filesystem/opened_file.rs index 3bd49e3..526b7af 100644 --- a/crates/librtbit/src/storage/filesystem/opened_file.rs +++ b/crates/librtbit/src/storage/filesystem/opened_file.rs @@ -125,6 +125,42 @@ impl DerefMut for OpenedFileLocked { } } +/// Move the file behind an already-write-locked handle to `new_full` and +/// re-point the handle at it. Never clobbers (refuses an existing destination), +/// creates missing parent directories, and fails on a cross-filesystem move +/// rather than copying. A dummy/not-present file just records the new path. +fn rename_locked(g: &mut OpenedFileLocked, new_full: &std::path::Path) -> anyhow::Result<()> { + use std::fs::OpenOptions; + if g.fd.is_none() { + g.path = new_full.to_owned(); + return Ok(()); + } + if new_full == g.path { + return Ok(()); + } + if new_full.try_exists().unwrap_or(true) { + anyhow::bail!("cannot move to {new_full:?}: destination already exists"); + } + if let Some(parent) = new_full.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("error creating parent directory for {new_full:?}"))?; + } + std::fs::rename(&g.path, new_full) + .with_context(|| format!("error renaming {:?} -> {new_full:?}", g.path))?; + let f = OpenOptions::new() + .read(true) + .write(true) + .open(new_full) + .with_context(|| format!("error reopening {new_full:?} after rename"))?; + g.fd = Some(f); + #[cfg(windows)] + { + g.tried_marking_sparse = false; + } + g.path = new_full.to_owned(); + Ok(()) +} + #[derive(Debug)] pub(crate) struct OpenedFile { file: RwLock, @@ -155,6 +191,32 @@ impl OpenedFile { }) } + /// Move this file on disk to `new_full` and re-point the cached handle at + /// the new location. A dummy (padding / not-present) file just records the + /// new path. Takes the write lock, so it serialises against in-flight I/O. + pub fn rename_to(&self, new_full: &std::path::Path) -> anyhow::Result<()> { + let mut g = self.file.write(); + rename_locked(&mut g, new_full) + } + + /// Move this file from under `old_root` to the same relative location under + /// `new_root` (whole-torrent relocation). Fails on a cross-filesystem move + /// (the underlying rename returns an error) rather than copying. + pub fn rebase( + &self, + old_root: &std::path::Path, + new_root: &std::path::Path, + ) -> anyhow::Result<()> { + let mut g = self.file.write(); + let relative = g + .path + .strip_prefix(old_root) + .with_context(|| format!("file {:?} is not under root {old_root:?}", g.path))? + .to_owned(); + let new_full = new_root.join(&relative); + rename_locked(&mut g, &new_full) + } + pub fn lock_read(&self) -> crate::Result> { RwLockReadGuard::try_map(self.file.read(), |f| f.as_ref()) .ok() diff --git a/crates/librtbit/src/storage/middleware/slow.rs b/crates/librtbit/src/storage/middleware/slow.rs index e8c8571..539df92 100644 --- a/crates/librtbit/src/storage/middleware/slow.rs +++ b/crates/librtbit/src/storage/middleware/slow.rs @@ -109,6 +109,14 @@ impl TorrentStorage for SlowStorage { self.underlying.remove_file(file_id, filename) } + fn rename_file(&self, file_id: usize, new_relative: &std::path::Path) -> anyhow::Result<()> { + self.underlying.rename_file(file_id, new_relative) + } + + fn move_root(&self, new_root: &std::path::Path) -> anyhow::Result<()> { + self.underlying.move_root(new_root) + } + fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { self.underlying.ensure_file_length(file_id, length) } diff --git a/crates/librtbit/src/storage/middleware/timing.rs b/crates/librtbit/src/storage/middleware/timing.rs index e71e490..5c5dd99 100644 --- a/crates/librtbit/src/storage/middleware/timing.rs +++ b/crates/librtbit/src/storage/middleware/timing.rs @@ -94,6 +94,14 @@ impl TorrentStorage for TimingStorage { self.underlying.remove_file(file_id, filename) } + fn rename_file(&self, file_id: usize, new_relative: &std::path::Path) -> anyhow::Result<()> { + self.underlying.rename_file(file_id, new_relative) + } + + fn move_root(&self, new_root: &std::path::Path) -> anyhow::Result<()> { + self.underlying.move_root(new_root) + } + fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { self.underlying.ensure_file_length(file_id, length) } diff --git a/crates/librtbit/src/storage/middleware/write_through_cache.rs b/crates/librtbit/src/storage/middleware/write_through_cache.rs index 11cf401..c6993bb 100644 --- a/crates/librtbit/src/storage/middleware/write_through_cache.rs +++ b/crates/librtbit/src/storage/middleware/write_through_cache.rs @@ -107,6 +107,14 @@ impl TorrentStorage for WriteThroughCacheStorage { self.underlying.remove_file(file_id, filename) } + fn rename_file(&self, file_id: usize, new_relative: &std::path::Path) -> anyhow::Result<()> { + self.underlying.rename_file(file_id, new_relative) + } + + fn move_root(&self, new_root: &std::path::Path) -> anyhow::Result<()> { + self.underlying.move_root(new_root) + } + fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { self.underlying.ensure_file_length(file_id, length) } diff --git a/crates/librtbit/src/storage/mod.rs b/crates/librtbit/src/storage/mod.rs index 30e3745..8e5ca28 100644 --- a/crates/librtbit/src/storage/mod.rs +++ b/crates/librtbit/src/storage/mod.rs @@ -154,6 +154,22 @@ pub trait TorrentStorage: Send + Sync { /// Remove a file from the storage. If not supported, or it doesn't matter, just return Ok(()) fn remove_file(&self, file_id: usize, filename: &Path) -> anyhow::Result<()>; + /// Move the file identified by `file_id` to `new_relative` (a path relative + /// to the storage root), keeping any cached handle valid. Backends that + /// cannot rename should return an error (the default). Callers are + /// responsible for keeping torrent metadata (`FileInfo.relative_filename`) + /// in sync. + fn rename_file(&self, _file_id: usize, _new_relative: &Path) -> anyhow::Result<()> { + anyhow::bail!("this storage backend does not support renaming files") + } + + /// Relocate every file to the same relative path under a new storage root, + /// keeping cached handles valid. Backends that cannot relocate should return + /// an error (the default). Callers keep the torrent's root anchor in sync. + fn move_root(&self, _new_root: &Path) -> anyhow::Result<()> { + anyhow::bail!("this storage backend does not support relocation") + } + fn remove_directory_if_empty(&self, path: &Path) -> anyhow::Result<()>; /// E.g. for filesystem backend ensure that the file has a certain length, and grow/shrink as needed. @@ -183,6 +199,14 @@ impl TorrentStorage for Box { (**self).remove_file(file_id, filename) } + fn rename_file(&self, file_id: usize, new_relative: &Path) -> anyhow::Result<()> { + (**self).rename_file(file_id, new_relative) + } + + fn move_root(&self, new_root: &Path) -> anyhow::Result<()> { + (**self).move_root(new_root) + } + fn ensure_file_length(&self, file_id: usize, length: u64) -> anyhow::Result<()> { (**self).ensure_file_length(file_id, length) } diff --git a/crates/librtbit/src/torrent_state/live/mod.rs b/crates/librtbit/src/torrent_state/live/mod.rs index 5c4618f..7207231 100644 --- a/crates/librtbit/src/torrent_state/live/mod.rs +++ b/crates/librtbit/src/torrent_state/live/mod.rs @@ -270,7 +270,9 @@ impl TorrentStateLive { tokio::sync::mpsc::UnboundedSender, ChunkInfo, )>(); - let ratelimits = Limits::new(paused.shared.options.ratelimits); + // Read the runtime override (seeded from options.ratelimits) so a + // per-torrent limit set while paused takes effect on this live state. + let ratelimits = Limits::new(*paused.shared.ratelimit_override.read()); let state = Arc::new(TorrentStateLive { shared: paused.shared.clone(), diff --git a/crates/librtbit/src/torrent_state/mod.rs b/crates/librtbit/src/torrent_state/mod.rs index 5c8c74e..09853d4 100644 --- a/crates/librtbit/src/torrent_state/mod.rs +++ b/crates/librtbit/src/torrent_state/mod.rs @@ -115,7 +115,6 @@ pub(crate) struct ManagedTorrentOptions { pub allow_overwrite: bool, pub output_folder: PathBuf, pub output_folder_root: Option, - pub ratelimits: LimitsConfig, pub initial_peers: Vec, pub peer_limit: Option, #[cfg(feature = "disable-upload")] @@ -172,6 +171,71 @@ impl TorrentMetadata { pub fn lengths(&self) -> &Lengths { self.info.lengths() } + + /// Return a copy of this metadata with the `relative_filename` of the given + /// files replaced. `renames` is a list of `(file_id, new_relative_path)`. + /// Only paths change — offsets, lengths and piece ranges are preserved, so + /// the chunk tracker stays valid. + pub(crate) fn with_renamed_files(&self, renames: &[(usize, std::path::PathBuf)]) -> Self { + let mut file_infos = self.file_infos.clone(); + for (file_id, new_path) in renames { + if let Some(fi) = file_infos.get_mut(*file_id) { + fi.relative_filename = new_path.clone(); + } + } + Self { + info: self.info.clone(), + torrent_bytes: self.torrent_bytes.clone(), + info_bytes: self.info_bytes.clone(), + file_infos, + } + } +} + +/// Validate a batch of file renames against the current file set: each id must +/// exist, each new path must be relative and free of `.`/`..`/root components, +/// and the resulting set of paths must stay unique (no collisions). +fn validate_renames( + file_infos: &crate::type_aliases::FileInfos, + renames: &[(usize, std::path::PathBuf)], +) -> anyhow::Result<()> { + use std::path::Component; + + if renames.is_empty() { + bail!("no files to rename"); + } + + let mut paths: Vec = file_infos + .iter() + .map(|f| f.relative_filename.clone()) + .collect(); + + for (file_id, new_relative) in renames { + if *file_id >= file_infos.len() { + bail!("no such file id {file_id}"); + } + if new_relative.as_os_str().is_empty() { + bail!("new path for file {file_id} is empty"); + } + if new_relative.is_absolute() { + bail!("new path {new_relative:?} must be relative to the torrent root"); + } + for component in new_relative.components() { + if !matches!(component, Component::Normal(_)) { + bail!("new path {new_relative:?} must not contain '.', '..', or a root"); + } + } + paths[*file_id] = new_relative.clone(); + } + + let mut seen = std::collections::HashSet::with_capacity(paths.len()); + for path in &paths { + if !seen.insert(path) { + bail!("rename would create two files at the same path: {path:?}"); + } + } + + Ok(()) } /// Common information about torrent shared among all possible states. @@ -200,6 +264,22 @@ pub struct ManagedTorrentShared { /// Category assigned to this torrent. pub category: RwLock>, + /// Per-torrent speed limits, mutable at runtime. Seeded from the add-time + /// options; read when (re)constructing the live limiter so a limit set + /// while paused (or before a pause/unpause) survives. `None` bps = no limit. + pub(crate) ratelimit_override: RwLock, + + /// Display-name override set via the compat rename endpoint; takes + /// precedence over the metadata/magnet name. `None` = use the torrent's + /// own name. Not persisted across restarts. + pub(crate) name_override: RwLock>, + + /// Current storage root, mutable at runtime so a whole-torrent relocation + /// (`set_location`) can re-anchor storage. Seeded from + /// `options.output_folder`; read by the storage factory's `create`. Not + /// persisted across restarts. + pub(crate) output_folder_override: RwLock, + /// Live per-tracker announce status (seeds/peers per tracker etc). pub tracker_status: Arc, @@ -250,6 +330,9 @@ impl ManagedTorrent { } pub fn name(&self) -> Option { + if let Some(name) = self.shared.name_override.read().clone() { + return Some(name); + } if let Some(m) = &*self.metadata.load() { return m .info @@ -260,6 +343,105 @@ impl ManagedTorrent { self.shared.magnet_name.clone() } + /// The torrent's current storage root. Reflects a `set_location` + /// relocation, unlike the add-time `options.output_folder`. + pub fn output_folder(&self) -> std::path::PathBuf { + self.shared.output_folder_override.read().clone() + } + + /// Set (or clear, with `None`) the display-name override for this torrent. + /// An empty/whitespace name clears the override. + pub fn set_display_name(&self, name: Option) { + let name = name.filter(|n| !n.trim().is_empty()); + *self.shared.name_override.write() = name; + } + + /// Rename one or more files within the torrent. Only supported while the + /// torrent is stopped (paused); moves the files on disk, keeps the storage + /// handles valid, and updates the torrent metadata. All-or-nothing: a + /// mid-move failure rolls back the renames already applied. + /// + /// `renames` is a list of `(file_id, new_relative_path)`. A rename whose + /// destination already exists on disk is refused (never clobbers), which + /// also makes a colliding/cyclic batch fail safely. Not persisted across + /// restarts. + pub fn rename_files(&self, renames: &[(usize, std::path::PathBuf)]) -> anyhow::Result<()> { + let mut g = self.locked.write(); + let paused = match &mut g.state { + ManagedTorrentState::Paused(paused) => paused, + ManagedTorrentState::Live(_) => { + bail!("torrent must be stopped before renaming files") + } + _ => bail!("torrent is not in a renamable state (must be stopped)"), + }; + + validate_renames(&paused.metadata.file_infos, renames)?; + + // Capture old relative paths (for rollback and directory pruning) + // before we start mutating the storage. + let old_paths: Vec<(usize, std::path::PathBuf)> = renames + .iter() + .map(|(file_id, _)| { + ( + *file_id, + paused.metadata.file_infos[*file_id] + .relative_filename + .clone(), + ) + }) + .collect(); + + for (applied, (file_id, new_relative)) in renames.iter().enumerate() { + if let Err(error) = paused.files.rename_file(*file_id, new_relative) { + // Roll back the renames already applied, in reverse order. + for (rollback_id, old_relative) in old_paths[..applied].iter().rev() { + let _ = paused.files.rename_file(*rollback_id, old_relative); + } + return Err(error).context("failed to rename file on disk; rolled back"); + } + } + + // Update the metadata (source of truth for deletion, display and any + // future storage re-init). + let new_metadata = Arc::new(paused.metadata.with_renamed_files(renames)); + paused.metadata = new_metadata.clone(); + self.metadata.store(Some(new_metadata)); + + // Best-effort prune of source directories left empty by the move. + for (_, old_relative) in &old_paths { + if let Some(parent) = old_relative.parent() + && !parent.as_os_str().is_empty() + { + let _ = paused.files.remove_directory_if_empty(parent); + } + } + + Ok(()) + } + + /// Relocate the torrent's files to a new root directory. Only supported + /// while the torrent is stopped (paused): moves every file to the same + /// relative path under `new_root`, keeps the storage handles valid, and + /// re-anchors the storage root. Same-filesystem only — a cross-device move + /// is refused (and rolled back) rather than copied. Not persisted across + /// restarts. + pub fn set_location(&self, new_root: std::path::PathBuf) -> anyhow::Result<()> { + let mut g = self.locked.write(); + let paused = match &mut g.state { + ManagedTorrentState::Paused(paused) => paused, + ManagedTorrentState::Live(_) => { + bail!("torrent must be stopped before changing its location") + } + _ => bail!("torrent is not in a relocatable state (must be stopped)"), + }; + + std::fs::create_dir_all(&new_root) + .with_context(|| format!("error creating destination directory {new_root:?}"))?; + paused.files.move_root(&new_root)?; + *self.shared.output_folder_override.write() = new_root; + Ok(()) + } + pub fn shared(&self) -> &ManagedTorrentShared { &self.shared } @@ -281,6 +463,43 @@ impl ManagedTorrent { self.locked.read().only_files.clone() } + /// Current per-torrent speed limits (the runtime override). `None` bps means + /// unlimited in that direction. + pub fn rate_limits(&self) -> LimitsConfig { + *self.shared.ratelimit_override.read() + } + + /// Set the per-torrent download limit (`None` = unlimited). Persisted on the + /// runtime override and applied to the live limiter immediately if live. + pub fn set_download_limit(&self, bps: Option) { + self.shared.ratelimit_override.write().download_bps = bps; + if let Some(live) = self.live() { + live.ratelimits.set_download_bps(bps); + } + } + + /// Set the per-torrent upload limit (`None` = unlimited). See + /// [`Self::set_download_limit`]. + pub fn set_upload_limit(&self, bps: Option) { + self.shared.ratelimit_override.write().upload_bps = bps; + if let Some(live) = self.live() { + live.ratelimits.set_upload_bps(bps); + } + } + + /// Force an immediate re-announce to trackers (and a fresh peer discovery + /// from DHT/trackers). Returns false if the torrent is not live, in which + /// case there is no announce loop to signal. + pub fn reannounce(&self) -> bool { + match self.live() { + Some(live) => { + live.rediscovery_notify.notify_one(); + true + } + None => false, + } + } + pub fn with_state(&self, f: impl FnOnce(&ManagedTorrentState) -> R) -> R { f(&self.locked.read().state) } diff --git a/crates/librtbit/webui/package-lock.json b/crates/librtbit/webui/package-lock.json index a2aec3c..4c5b0a5 100644 --- a/crates/librtbit/webui/package-lock.json +++ b/crates/librtbit/webui/package-lock.json @@ -11,28 +11,28 @@ "lodash.sortby": "^4.7", "react": "^19", "react-dom": "^19", - "react-icons": "^5.6.0", - "react-virtuoso": "^4.18.7", + "react-icons": "^5.7.0", + "react-virtuoso": "^4.18.12", "zustand": "^5" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.61.1", - "@tailwindcss/vite": "^4.3.0", + "@playwright/test": "^1.62.1", + "@tailwindcss/vite": "^4.3.3", "@types/lodash.debounce": "^4.0.9", "@types/lodash.sortby": "^4.7.9", - "@types/node": "^26.1.1", + "@types/node": "^26.4.0", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6", - "autoprefixer": "^10.5.0", - "eslint": "^10.4.1", + "autoprefixer": "^10.5.4", + "eslint": "^10.9.1", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "prettier": "^3.8", + "eslint-plugin-react-refresh": "^0.5.5", + "prettier": "^3.9", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.61.0", + "typescript-eslint": "^8.68.0", "vite": "^8", "vite-plugin-svgr": "^5.2.0" } @@ -286,40 +286,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -378,9 +344,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", - "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -591,29 +557,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", "dev": true, "license": "MIT", "funding": { @@ -621,19 +568,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@popperjs/core": { @@ -705,10 +652,27 @@ "react-dom": ">=16.14.0" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", "cpu": [ "arm64" ], @@ -723,9 +687,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", "cpu": [ "arm64" ], @@ -740,9 +704,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", "cpu": [ "x64" ], @@ -757,9 +721,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", "cpu": [ "x64" ], @@ -774,9 +738,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", "cpu": [ "arm" ], @@ -791,13 +755,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -808,13 +775,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -825,13 +795,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -842,13 +815,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -859,13 +835,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -876,13 +855,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -893,9 +875,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", "cpu": [ "arm64" ], @@ -909,29 +891,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", "cpu": [ "arm64" ], @@ -946,9 +909,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", "cpu": [ "x64" ], @@ -1227,49 +1190,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -1284,9 +1247,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -1301,9 +1264,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -1318,9 +1281,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -1335,9 +1298,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -1352,13 +1315,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1369,13 +1335,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1386,13 +1355,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1403,13 +1375,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1420,9 +1395,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1438,21 +1413,87 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -1467,9 +1508,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -1484,31 +1525,20 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", - "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "tailwindcss": "4.3.0" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1558,9 +1588,9 @@ } }, "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1568,9 +1598,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1578,9 +1608,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1594,17 +1624,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", - "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", + "integrity": "sha512-WASHDpCm6qO5jj9g1a+8NiW5+GCkAyLReR56/4VruYmNgfUmqpxOfZ2Yfb8xGfJPWv5Qi6LSD8sXdces3vbp/Q==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/type-utils": "8.61.0", - "@typescript-eslint/utils": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/type-utils": "8.68.0", + "@typescript-eslint/utils": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1617,15 +1647,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.61.0", + "@typescript-eslint/parser": "^8.68.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { @@ -1633,16 +1663,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", - "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.68.0.tgz", + "integrity": "sha512-fHq2VC1kpyYfvEcbiMjOpySY4WS7voEp89yAThrHRX5sm9j2lzYppCb2umFMEed4fWcyeLjHxrz0mpjNBaBxMQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3" }, "engines": { @@ -1658,14 +1688,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", - "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.68.0.tgz", + "integrity": "sha512-5GQtWZCXFcFYux955pvoS02WLc49pXNlvIxocKjS0clvwo3in1RdlzVKyiqQH9vE5AKWFLTaUgeQkOrTS+0Qxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.61.0", - "@typescript-eslint/types": "^8.61.0", + "@typescript-eslint/tsconfig-utils": "^8.68.0", + "@typescript-eslint/types": "^8.68.0", "debug": "^4.4.3" }, "engines": { @@ -1680,14 +1710,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", - "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.68.0.tgz", + "integrity": "sha512-T5eXpcaJNg8bhjHJ8Rjp68Vq/QBteYtTKY8TZqVNPaUbuz0f6jI9t6aDkylwvalpAB9XTTFeFOjrjXAZ3YvmVA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0" + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1698,9 +1728,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", - "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.68.0.tgz", + "integrity": "sha512-F7zrGQfiJHojPwi8vhxZQC1tWtJzvL74cK/nqri2lk8YUXvYaYwl263xOJ69jDWPUk1hmcdoayFwk9lX09npVw==", "dev": true, "license": "MIT", "engines": { @@ -1715,15 +1745,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", - "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.68.0.tgz", + "integrity": "sha512-X77zqoY1EjeWGs/0JNxeaMfp5C5lIz4Tw8y66F1Ne8Faq6g424sBNYM6xBAqElfGZPLpWS+CZAp0DXyKDzWiHg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1740,9 +1770,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", - "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.68.0.tgz", + "integrity": "sha512-9RnpsGJjrAllCMefGVVsImJM24YurhC0Q1h4UbvivtvOqXmR/vEJge2OoE++z9m6hyg8T1Q8t5SNT6tHSbrxcg==", "dev": true, "license": "MIT", "engines": { @@ -1754,16 +1784,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", - "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.68.0.tgz", + "integrity": "sha512-OKKsD0tYmoNiU5PW2zehO1yO56jYOm1ShYlxon/Z0SJNidAkdVg86eg9ruRuoXf8xfnuWZGbwDsStkoXbZtIIA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.61.0", - "@typescript-eslint/tsconfig-utils": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/visitor-keys": "8.61.0", + "@typescript-eslint/project-service": "8.68.0", + "@typescript-eslint/tsconfig-utils": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/visitor-keys": "8.68.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1782,9 +1812,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", - "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -1795,16 +1825,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", - "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.68.0.tgz", + "integrity": "sha512-PB5gJMMOg0Q5P1tsgWtEAqQacJXq0qEqRHDX/YJ4FaTMLfZPpHB3gjl2EJuiZyPABxmj4ZQYiY9m1bdAJ5y7tQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.61.0", - "@typescript-eslint/types": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0" + "@typescript-eslint/scope-manager": "8.68.0", + "@typescript-eslint/types": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1819,13 +1849,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", - "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.68.0.tgz", + "integrity": "sha512-YR65gGdGvTUAWLldC3xLOvOzamdGzB4A5/N8rehEaHs3Zvoe39BhgY+u0SPch1OvrVTfLcc55wsSgK2NcnTS/A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/types": "8.68.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1837,13 +1867,13 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", - "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.0" + "@rolldown/pluginutils": "^1.0.1" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1851,6 +1881,7 @@ "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "peerDependenciesMeta": { @@ -1859,6 +1890,9 @@ }, "babel-plugin-react-compiler": { "optional": true + }, + "oxc-transform-react": { + "optional": true } } }, @@ -1922,9 +1956,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", - "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", "dev": true, "funding": [ { @@ -1942,8 +1976,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.2", - "caniuse-lite": "^1.0.30001787", + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -1969,9 +2003,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.36", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.36.tgz", - "integrity": "sha512-lVq/Df7LXlO79MVaaUHztSwWiG9oXoWHlgvNS51v8Dpd4+G4/VIy6qYePTw31nAVls33nUtnfezYeLkYAak9dg==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1995,9 +2029,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -2015,11 +2049,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -2052,9 +2086,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -2202,16 +2236,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.372", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", - "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "version": "1.5.415", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.415.tgz", + "integrity": "sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz", - "integrity": "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==", + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", "dependencies": { @@ -2269,16 +2303,19 @@ } }, "node_modules/eslint": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", - "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz", + "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.6.0", + "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -2302,7 +2339,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2345,9 +2382,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", - "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.5.tgz", + "integrity": "sha512-vG7yLURXNvCHy0FBdbZRwIu0BLPJMlUUJS2Ep7ud9w1YCLftFZtuEjyjhym0Qq9yuZ6LJUitNlu/hMk0gakXAw==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3213,9 +3250,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "dev": true, "license": "MIT", "engines": { @@ -3342,9 +3379,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -3355,35 +3392,35 @@ } }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/playwright/node_modules/fsevents": { @@ -3448,9 +3485,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { @@ -3474,9 +3511,9 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3504,21 +3541,21 @@ } }, "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.7" + "react": "^19.2.8" } }, "node_modules/react-icons": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", - "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.7.0.tgz", + "integrity": "sha512-LBLy340Rzqy6+/yVhZKT3B/QpP1BZaesGqasf09HPOBzRarcDIFH0WwXlXQfE7q7ipxK4MSiC5DIBWURCny6fw==", "license": "MIT", "peerDependencies": { "react": "*" @@ -3542,9 +3579,9 @@ } }, "node_modules/react-virtuoso": { - "version": "4.18.7", - "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.7.tgz", - "integrity": "sha512-xNF5zDGEEIMB7cKwcen/pLig0YDf6OnfFrVgKFa7sHPf9fRem0CaLshyObbBcP88jzn0enavL39EgplgdyT21g==", + "version": "4.18.12", + "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.12.tgz", + "integrity": "sha512-6c1SnRicSBfG+WnbhcyJUxzDHvvxD3vsux/EpcfVbgB7clyP1Od2r81TAGTgwbL7U2qP889RR4u+CH6WjgpW0g==", "license": "MIT", "peerDependencies": { "react": ">=16 || >=17 || >= 18 || >= 19", @@ -3562,13 +3599,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3578,21 +3615,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" } }, "node_modules/scheduler": { @@ -3663,9 +3700,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "dev": true, "license": "MIT" }, @@ -3747,16 +3784,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.61.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.0.tgz", - "integrity": "sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==", + "version": "8.68.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.68.0.tgz", + "integrity": "sha512-MHy0Y0ynqeEbx/S45+i/bBssdy3X6KNBfmJAP35GrgtNxu2TQ5K5xsFDhAnmsq1jvpdoZOPG1LGtJo0HWqYCrQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.61.0", - "@typescript-eslint/parser": "8.61.0", - "@typescript-eslint/typescript-estree": "8.61.0", - "@typescript-eslint/utils": "8.61.0" + "@typescript-eslint/eslint-plugin": "8.68.0", + "@typescript-eslint/parser": "8.68.0", + "@typescript-eslint/typescript-estree": "8.68.0", + "@typescript-eslint/utils": "8.68.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3787,9 +3824,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -3837,16 +3874,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "bin": { @@ -3863,7 +3900,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -3929,6 +3966,279 @@ "vite": ">=3.0.0" } }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", @@ -4008,9 +4318,9 @@ } }, "node_modules/zustand": { - "version": "5.0.14", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", - "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/crates/librtbit/webui/package.json b/crates/librtbit/webui/package.json index 3fae192..c2c22fb 100644 --- a/crates/librtbit/webui/package.json +++ b/crates/librtbit/webui/package.json @@ -20,28 +20,28 @@ "lodash.sortby": "^4.7", "react": "^19", "react-dom": "^19", - "react-icons": "^5.6.0", - "react-virtuoso": "^4.18.7", + "react-icons": "^5.7.0", + "react-virtuoso": "^4.18.12", "zustand": "^5" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.61.1", - "@tailwindcss/vite": "^4.3.0", + "@playwright/test": "^1.62.1", + "@tailwindcss/vite": "^4.3.3", "@types/lodash.debounce": "^4.0.9", "@types/lodash.sortby": "^4.7.9", - "@types/node": "^26.1.1", + "@types/node": "^26.4.0", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6", - "autoprefixer": "^10.5.0", - "eslint": "^10.4.1", + "autoprefixer": "^10.5.4", + "eslint": "^10.9.1", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "prettier": "^3.8", + "eslint-plugin-react-refresh": "^0.5.5", + "prettier": "^3.9", "tailwindcss": "^4.3.0", "typescript": "^6.0.3", - "typescript-eslint": "^8.61.0", + "typescript-eslint": "^8.68.0", "vite": "^8", "vite-plugin-svgr": "^5.2.0" } diff --git a/crates/rtbit/Cargo.toml b/crates/rtbit/Cargo.toml index 6b44111..6c60f4a 100644 --- a/crates/rtbit/Cargo.toml +++ b/crates/rtbit/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rtbit" -version = "0.1.0-beta.3" +version = "0.1.0-rc.1" authors = ["AusAgentSmith "] edition = "2024" description = "A bittorrent command line client and server." diff --git a/desktop/package.json b/desktop/package.json index a643925..39c4450 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,6 +6,6 @@ "tauri": "tauri" }, "devDependencies": { - "@tauri-apps/cli": "^2" + "@tauri-apps/cli": "^2.11.4" } } diff --git a/docs/QBIT-API-PARITY.md b/docs/QBIT-API-PARITY.md new file mode 100644 index 0000000..80d4607 --- /dev/null +++ b/docs/QBIT-API-PARITY.md @@ -0,0 +1,227 @@ +# qBittorrent WebUI API parity + +rustTorrent ships a qBittorrent WebUI API v2 compatibility layer +(`crates/librtbit/src/http_api/handlers/qbit_compat.rs`, mounted at +`/api/v2`) so that *arr apps and other qBittorrent integrations can talk to +it. This document describes how parity with upstream qBittorrent is measured +and enforced, and where we currently stand. + +## Current standing (upstream WebAPI 2.16.2, 2026-08-30) + +**64 of 93 in-scope endpoints routed (~69%)** — 26 full, 38 partial — plus 2 +legacy aliases (`torrents/pause`, `torrents/resume`) that upstream removed in +WebAPI 2.11. 37 of the 130 upstream endpoints were descoped on 2026-08-30 +(marked `out_of_scope`, enforced as unrouted): `search/*` (Indexarr covers +discovery), `rss/*` (native `/rss` + Indexarr are the RSS story), `clientdata/*`, +SSL parameters, and qBittorrent-internal app misc (email, cookies, API keys, +processInfo, getDirectoryContent). + +Decisions of record: advertised `webapiVersion` stays **2.11.3** and the +rename-family bug is now fixed *forward* (see below); the remaining heavy engine +items (queueing, per-torrent rate limits, move-storage, reannounce, rename) +**will be built**, not stubbed — they account for most of the 46 still-`missing` +in-scope endpoints. + +| Controller | Implemented / in scope | Notes | +|---|---|---| +| `auth` | 2 / 2 | login/logout with SID cookies | +| `app` | 6 / 10 | version info, `defaultSavePath`, minimal preferences (7 descoped) | +| `torrents` | 41 / 60 | lifecycle, categories, tags, trackers (add/remove/edit), reannounce, rename (name/file/folder), setLocation/setSavePath, pieces, file prio, per-torrent + share limits, export, webseeds | +| `transfer` | 10 / 13 | info (real session limits) + session rate limits + alt-speed mode + session pause/resume | +| `sync` | 1 / 2 | `maindata` (full-update snapshots; no per-client rid deltas) | +| `torrentcreator` | 4 / 4 | task API over the native `create_torrent` (in-memory tasks) | +| `log` | 0 / 2 | no persistent log ring buffer | +| `rss`, `search`, `clientdata` | — | descoped entirely | + +### Rename-family bug (fixed 2026-08-30) + +We advertise `webapiVersion` **2.11.3**, so modern clients (qbittorrent-api, +newer *arr releases) use the post-2.11 vocabulary. This is now served: + +- `torrents/stop` / `torrents/start` are routed (sharing the pause/resume + handlers); the pre-2.11 `torrents/pause` / `torrents/resume` remain as aliases. +- `torrents/info` emits the 2.11 state strings `stoppedDL` / `stoppedUP`. +- `matches_filter` accepts `stopped` / `running` (and still the old + `paused` / `resumed`), so `filter=stopped` no longer returns every torrent. +- `torrents/add` reads the 2.11 `stopped` form field as well as `paused`. + +### Conflict audit (2026-08-30) + +No hard route conflicts: the native API owns `/` and the compat layer owns +`/api/v2`; nothing native is mounted under `/api/*`, so all 111 missing +endpoints have free paths. Auth layering is also sound — the main +Bearer/Basic middleware is applied before the qbit router is nested, so +`/api/v2` correctly runs its own SID-cookie auth against the same credential +store. **Trap**: future qbit sub-routers (`sync`, `rss`, `log`, …) must be +nested inside `protected_router` in `make_qbit_router`, or they ship +unauthenticated. + +The version-skew conflicts (items 1–5) were the 2.11 pause→stop rename family +and the rate-limit state disagreement; all are now resolved: + +1. ~~**`torrents/stop`/`start` missing**~~ — routed (see the rename-family + section above). +2. ~~**State strings** `pausedDL`/`pausedUP`~~ — `torrents/info` now emits + `stoppedDL`/`stoppedUP`. +3. ~~**Filter values** `stopped`/`running`~~ — `matches_filter` now recognises + both the 2.11 and pre-2.11 spellings, so `filter=stopped` no longer returns + every torrent. +4. ~~**`torrents/add` `stopped` field**~~ — now read alongside `paused`. +5. ~~**Rate-limit state disagreement**~~ — `transfer/downloadLimit`/ + `uploadLimit`/`setDownloadLimit`/`setUploadLimit` now read and write the same + session limiter as the native `/torrents/limits` API, and + `transfer/speedLimitsMode`/`toggleSpeedLimitsMode`/`setSpeedLimitsMode` + reflect the native `/speed/alt` toggle. `transfer/info` now also reports the + real session `dl_rate_limit`/`up_rate_limit` (was hardcoded to 0). + +Still open: + +6. **`app/setPreferences` rejects instead of ignoring**: it uses + `deny_unknown_fields`, returning 400 for any field other than + `announce_port`. Real qBittorrent ignores unknown fields and applies the + rest, so any client that round-trips preferences (get → modify → set) + fails hard. +7. **RSS data-model gap** (affects future bridging): the native store is flat + (feeds keyed by name, rules with one feed + one regex); qBittorrent has + folder hierarchies (`Folder\Feed` paths) and rules keyed by name with + `mustContain`/`mustNotContain`/`affectedFeeds[]`. A bridge needs flat-folder + emulation and a rule-shape mapping; `rss/addFolder`/`rss/moveItem` have no + clean mapping. + +Hygiene note (adjacent): `QbitSessions` never purges expired SIDs except on +explicit logout, so the session map grows unboundedly with clients that +re-login frequently (Sonarr does). + +### Suggested parity tiers + +Tier 1 (high value) and the thin backend bridges from tier 2 are now largely +done: `torrents/stop`/`start`, `sync/maindata`, `app/defaultSavePath`, +`torrents/tags` + tag CRUD, `torrents/trackers`, `torrents/addTrackers`, +`torrents/addPeers`, `torrents/filePrio`, `torrents/pieceStates`/`pieceHashes`, +`torrents/export`, `torrents/webseeds`, `torrents/count`, the `transfer/*Limit*` ++ speed-limits-mode family, and `transfer/pauseSession`/`resumeSession`. + +Remaining work, roughly by cost: + +1. **Needs a new engine method** (the `will be built` items): queueing + (`topPrio` / `bottomPrio` / `increasePrio` / `decreasePrio`), + `setSuperSeeding`, `toggleSequentialDownload`, `setForceStart`, + `setAutoManagement`, `setDownloadPath` (incomplete-file path). And the + **auto-enforcement** half of share limits (`setShareLimits` currently stores + and reports the limits but does not auto-pause/remove at the threshold — a + periodic session task is the v2). Done since: per-torrent rate limits (`ratelimit_override` on + `ManagedTorrentShared`, enforced by the live limiter), `reannounce` (signals + the live re-discovery notify), **file/folder/display rename**, and + **`setLocation`/`setSavePath`** (whole-torrent relocation — see below). + +### Relocation (setLocation, v1, 2026-08-31) + +`torrents/setLocation` and `torrents/setSavePath` move every file to a new root: + +- New `TorrentStorage::move_root(new_root)` primitive: the filesystem backend + moves each file to the same relative path under the new root (reusing the + no-clobber rename core) and re-anchors its (now `RwLock`-wrapped) root; mmap + forwards. All-or-nothing with rollback. +- The persistent anchor is a new `output_folder_override` on + `ManagedTorrentShared` (seeded from `options.output_folder`, read by the + storage factory's `create`), rather than unfreezing the immutable `options`. +- `ManagedTorrent::set_location()` is **stopped-only** (409 when live) and + **same-filesystem only** — a cross-device `rename` (EXDEV) is refused and + rolled back, not copied. `qbit_save_path` reports the new root after a move. +- **v2**: cross-filesystem relocation (async copy+delete, à la + `move_completed_torrent`), live relocation, and persistence across restart. + +> **⚠️ Restart caveat (rename and relocation both).** These overrides are +> in-memory only. Persistence still records the add-time `output_folder` and +> derives filenames from the immutable `.torrent` info, so after a +> rename/relocation **and a restart** the torrent re-adds at its original root +> with original names while the data sits at the new location — a recheck finds +> nothing and re-downloads, orphaning the moved copy. Until v2 persistence +> lands, treat rename/relocation as effective only within the running session. + +### File rename (v1, 2026-08-31) + +`torrents/renameFile`, `renameFolder`, and `rename` (display name) are +implemented: + +- New `TorrentStorage::rename_file(file_id, new_relative)` primitive: the + filesystem backend moves the file on disk and re-points its cached open + handle at the new path (mmap forwards; other backends default to an error). +- `ManagedTorrent::rename_files()` is **stopped-only** (returns 409 when live, + which sidesteps live-handle / Windows-open-file / mmap-remap hazards): it + validates the batch (relative paths, no `.`/`..`/root, no collisions), + moves each file, then swaps in rebuilt `TorrentMetadata` with updated + `file_infos` and prunes emptied source dirs. All-or-nothing with rollback. +- `torrents/rename` sets a `name_override` on `ManagedTorrentShared`, surfaced + in `torrents/info` / `sync/maindata` via `ManagedTorrent::name()`. +- **v1 limitations**: renames are not persisted across restarts (re-derived + from the immutable `.torrent` info on load), and require the torrent stopped. + Live rename is the documented v2, gated behind the differential-test harness. +2. **Thin bridges still open**: `torrents/setComment`, web-seed mutation + (`addWebSeeds` / `editWebSeed` / `removeWebSeeds` — `web_seed_urls` is + currently immutable). (`torrentcreator/*` is now done — an in-memory task + store over the native `create_torrent`; tasks are not persisted.) +3. **Probably out of scope**: `search/*` (plugin system), + `app/sendTestEmail`, `app/processInfo`, `clientdata/*`, + `torrents/SSLParameters`, `log/*`. + +## The parity checker framework + +The source of truth is +`crates/librtbit/src/http_api/handlers/qbit_parity_spec.json`: one entry per +upstream endpoint (`controller/action`), with its HTTP method and a parity +status: + +- `full` — routed, semantics close enough for real clients +- `partial` — routed, but with stubbed fields or gaps (see `notes`) +- `missing` — not routed; candidate for future work +- `out_of_scope` — not routed by explicit decision + +Entries with `"upstream": false` are legacy endpoints we serve that upstream +has removed. + +Tests in `crates/librtbit/src/http_api/handlers/qbit_parity.rs` enforce the +spec against the real router (they run in the normal +`cargo test --workspace` CI job): + +- `spec_matches_router` — probes every spec entry against + `make_qbit_router()`; `full`/`partial` must be routed, `missing`/ + `out_of_scope` must 404. Implementing or removing an endpoint without + flipping its spec status fails CI. +- `every_compat_route_is_tracked_in_spec` — scans the route literals in + `qbit_compat.rs` so no compat route can land untracked. +- `parity_summary` — prints the scoreboard + (`cargo test -p swarmforge qbit_parity -- --nocapture`). + +### Testing scope (the parity checker is tier 0, not the whole story) + +The tests above enforce *surface* parity only — that an endpoint is routed, +not that its response is correct. Full durable testing is scoped as a ladder +(tracked as WI-25/WI-26): + +1. **Shape conformance** (WI-25, mandatory before the endpoint wave): each + `full`/`partial` spec entry gains a response schema; the parity tests probe + endpoints with a live in-process torrent and validate JSON field + sets/types. +2. **Behavioral lifecycle** (WI-25): add→stop→start→recheck→delete suites + through the compat router against a real session. +3. **Client-replay fixtures** (WI-26): recorded Sonarr / qbittorrent-api + request sequences replayed against the router. +4. **Differential harness** (WI-26, nightly): identical requests against a + real qBittorrent container and rustTorrent, responses diffed field-by-field + with an allowlist for intentionally-stubbed fields. + +Definition of done for every endpoint: route + spec status flip + schema + +lifecycle coverage. + +### Workflow + +- **Implementing an endpoint**: add the route, flip the spec entry to + `full`/`partial`, note any semantic gaps in `notes`. +- **Declaring non-goals**: set status to `out_of_scope` (kept enforced as + unrouted). +- **Tracking upstream**: re-clone qBittorrent and run + `scripts/refresh-qbit-parity-spec.py ` — it re-extracts the + endpoint inventory and POST allowlist from the sources, preserves our + statuses/notes, adds new upstream endpoints as `missing`, and flags + upstream-removed endpoints. diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 08cb125..7e6fe2a 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -55,9 +55,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "untrusted", @@ -66,9 +66,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -264,9 +264,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "directories" @@ -377,9 +377,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -392,9 +392,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -402,15 +402,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -419,38 +419,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1230,18 +1230,18 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", diff --git a/package-lock.json b/package-lock.json index 9b5e0df..08f0f74 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,20 +9,20 @@ "desktop" ], "devDependencies": { - "prettier": "^3.8" + "prettier": "^3.9" } }, "desktop": { "name": "rtbit-desktop", "version": "0.0.1", "devDependencies": { - "@tauri-apps/cli": "^2" + "@tauri-apps/cli": "^2.11.4" } }, "node_modules/@tauri-apps/cli": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz", - "integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", + "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -36,23 +36,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.11.2", - "@tauri-apps/cli-darwin-x64": "2.11.2", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", - "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", - "@tauri-apps/cli-linux-arm64-musl": "2.11.2", - "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", - "@tauri-apps/cli-linux-x64-gnu": "2.11.2", - "@tauri-apps/cli-linux-x64-musl": "2.11.2", - "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", - "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", - "@tauri-apps/cli-win32-x64-msvc": "2.11.2" + "@tauri-apps/cli-darwin-arm64": "2.11.4", + "@tauri-apps/cli-darwin-x64": "2.11.4", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", + "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", + "@tauri-apps/cli-linux-arm64-musl": "2.11.4", + "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-gnu": "2.11.4", + "@tauri-apps/cli-linux-x64-musl": "2.11.4", + "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", + "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", + "@tauri-apps/cli-win32-x64-msvc": "2.11.4" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz", - "integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", + "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", "cpu": [ "arm64" ], @@ -67,9 +67,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz", - "integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", + "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", "cpu": [ "x64" ], @@ -84,9 +84,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz", - "integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", + "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", "cpu": [ "arm" ], @@ -101,13 +101,16 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz", - "integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", + "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -118,13 +121,16 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz", - "integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", + "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -135,13 +141,16 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz", - "integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", + "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -152,13 +161,16 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz", - "integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", + "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -169,13 +181,16 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz", - "integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", + "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -186,9 +201,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz", - "integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", + "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", "cpu": [ "arm64" ], @@ -203,9 +218,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz", - "integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", + "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", "cpu": [ "ia32" ], @@ -220,9 +235,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz", - "integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", + "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", "cpu": [ "x64" ], @@ -237,9 +252,9 @@ } }, "node_modules/prettier": { - "version": "3.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", - "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 226a543..9efc040 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,6 @@ "format:check": "prettier --check 'desktop/src/**/*.{html,ts,tsx}'" }, "devDependencies": { - "prettier": "^3.8" + "prettier": "^3.9" } } diff --git a/scripts/refresh-qbit-parity-spec.py b/scripts/refresh-qbit-parity-spec.py new file mode 100755 index 0000000..945fd14 --- /dev/null +++ b/scripts/refresh-qbit-parity-spec.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Refresh crates/librtbit/src/http_api/handlers/qbit_parity_spec.json from a +qBittorrent checkout. + +Usage: + git clone --depth 1 https://github.com/qbittorrent/qBittorrent.git /tmp/qbittorrent + scripts/refresh-qbit-parity-spec.py /tmp/qbittorrent + +Re-extracts the upstream WebUI API endpoint inventory (every `Action()` +in src/webui/api/*controller.h, with methods from the POST allowlist in +webapplication.h) and merges it into the existing spec: + +- existing entries keep their status/notes; +- new upstream endpoints are added with status "missing"; +- entries that disappeared upstream are kept but flagged upstream: false if we + route them (legacy aliases), or dropped with a warning if we don't. + +The parity tests in crates/librtbit/src/http_api/handlers/qbit_parity.rs +enforce that the spec matches the actual compat router. +""" + +import datetime +import json +import re +import subprocess +import sys +from pathlib import Path + +SPEC_PATH = ( + Path(__file__).resolve().parent.parent + / "crates/librtbit/src/http_api/handlers/qbit_parity_spec.json" +) + +# controller header -> URL scope (must match registerAPIController names in +# qBittorrent's webapplication.cpp). +SCOPES = { + "appcontroller.h": "app", + "authcontroller.h": "auth", + "clientdatacontroller.h": "clientdata", + "logcontroller.h": "log", + "rsscontroller.h": "rss", + "searchcontroller.h": "search", + "synccontroller.h": "sync", + "torrentcreatorcontroller.h": "torrentcreator", + "torrentscontroller.h": "torrents", + "transfercontroller.h": "transfer", +} + + +def main() -> int: + if len(sys.argv) != 2: + print(__doc__, file=sys.stderr) + return 2 + qbit = Path(sys.argv[1]) + api_dir = qbit / "src/webui/api" + if not api_dir.is_dir(): + print(f"error: {api_dir} not found; not a qBittorrent checkout?", file=sys.stderr) + return 2 + + upstream_endpoints = [] + for header, scope in SCOPES.items(): + text = (api_dir / header).read_text() + for m in re.finditer(r"void (\w+)Action\(\)", text): + upstream_endpoints.append(f"{scope}/{m.group(1)}") + + webapp_h = (qbit / "src/webui/webapplication.h").read_text() + post_set = { + f"{m.group(1)}/{m.group(2)}" + for m in re.finditer( + r'\{\{u"(\w+)"_s, u"(\w+)"_s\}, Http::HEADER_REQUEST_METHOD_POST\}', + webapp_h, + ) + } + version_match = re.search(r"API_VERSION \{(\d+), (\d+), (\d+)\}", webapp_h) + if not version_match: + print("error: could not find API_VERSION in webapplication.h", file=sys.stderr) + return 1 + version = ".".join(version_match.groups()) + commit = subprocess.check_output( + ["git", "-C", str(qbit), "rev-parse", "HEAD"], text=True + ).strip() + + spec = json.loads(SPEC_PATH.read_text()) + existing = {row["endpoint"]: row for row in spec["endpoints"]} + upstream_set = set(upstream_endpoints) + + rows = [] + added, flagged_legacy, dropped = [], [], [] + for ep in sorted(upstream_set): + row = existing.get(ep) + if row is None: + row = {"endpoint": ep, "status": "missing"} + added.append(ep) + row["method"] = "POST" if ep in post_set else "GET" + row.pop("upstream", None) + rows.append(row) + + for ep, row in existing.items(): + if ep in upstream_set: + continue + if row["status"] in ("full", "partial"): + if row.get("upstream", True): + flagged_legacy.append(ep) + row["upstream"] = False + rows.append(row) + else: + dropped.append(ep) + + rows.sort(key=lambda r: r["endpoint"]) + spec["upstream"] = { + "webapi_version": version, + "source_commit": commit, + "extracted_on": datetime.date.today().isoformat(), + } + spec["endpoints"] = rows + SPEC_PATH.write_text(json.dumps(spec, indent=2) + "\n") + + counts = {} + for row in rows: + counts[row["status"]] = counts.get(row["status"], 0) + 1 + print(f"upstream WebAPI v{version} @ {commit[:12]}: {len(rows)} entries {counts}") + for label, items in (("added (new upstream)", added), + ("newly flagged legacy (removed upstream, still routed)", flagged_legacy), + ("dropped (removed upstream, never implemented)", dropped)): + if items: + print(f" {label}:") + for ep in items: + print(f" {ep}") + print("run `cargo test -p swarmforge qbit_parity` to validate against the router") + return 0 + + +if __name__ == "__main__": + sys.exit(main())