diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0681728..4aa0a5a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: rust: ${{ steps.classify.outputs.rust }} rust_full: ${{ steps.classify.outputs.rust_full }} rust_packages: ${{ steps.classify.outputs.rust_packages }} + rust_fluent: ${{ steps.classify.outputs.rust_fluent }} desktop: ${{ steps.classify.outputs.desktop }} webui: ${{ steps.classify.outputs.webui }} website: ${{ steps.classify.outputs.website }} @@ -46,11 +47,11 @@ jobs: run: | set -euo pipefail if [[ "$EVENT" != pull_request ]]; then - printf 'rust=true\nrust_full=true\nrust_packages=\ndesktop=true\nwebui=true\nwebsite=true\n' >>"$GITHUB_OUTPUT" + printf 'rust=true\nrust_full=true\nrust_packages=\nrust_fluent=true\ndesktop=true\nwebui=true\nwebsite=true\n' >>"$GITHUB_OUTPUT" exit 0 fi files="$(git diff --name-only "$(git merge-base "$BASE_SHA" HEAD)" HEAD)" - rust=false full=false desktop=false webui=false website=false + rust=false full=false fluent=false desktop=false webui=false website=false packages=() add_package() { local pkg @@ -80,8 +81,15 @@ jobs: # The menu description has no window toolkit, so CI can test it. # The Tauri binary stays excluded: the runner has no webkit. desktop/menu/*) rust=true desktop=true; add_package rtbit-desktop-menu ;; + # The native app consumes the daemon's API types directly. + crates/librtbit/src/http_api_types.rs) + rust=true; fluent=true ;; crates/librtbit/*) rust=true desktop=true; add_package swarmforge ;; crates/rtbit/*) rust=true; add_package rtbit ;; + # The native client and UI crates are reverse dependencies of the + # fluent app, so changes to either must test the app as well. + crates/rtbit-client/*|crates/rtbit-ui-core/*) + rust=true; fluent=true ;; website/*) website=true ;; # An unrecognised crate path (a new member, or a path we have not # mapped yet) is not safe to narrow, so it takes everything. @@ -90,11 +98,11 @@ jobs: done <<<"$files" # A Rust change we could not attribute to a package still needs the # full workspace rather than no check at all. - if [[ "$rust" == true && "$full" == false && ${#packages[@]} -eq 0 ]]; then + if [[ "$rust" == true && "$full" == false && "$fluent" == false && ${#packages[@]} -eq 0 ]]; then full=true fi - printf 'rust=%s\nrust_full=%s\nrust_packages=%s\ndesktop=%s\nwebui=%s\nwebsite=%s\n' \ - "$rust" "$full" "${packages[*]-}" "$desktop" "$webui" "$website" | tee -a "$GITHUB_OUTPUT" + printf 'rust=%s\nrust_full=%s\nrust_packages=%s\nrust_fluent=%s\ndesktop=%s\nwebui=%s\nwebsite=%s\n' \ + "$rust" "$full" "${packages[*]-}" "$fluent" "$desktop" "$webui" "$website" | tee -a "$GITHUB_OUTPUT" # Shell checks for the release scripts and repository layout. They take # seconds and touch no network, so they run on every event. @@ -181,10 +189,14 @@ jobs: - name: Test env: SCOPE_ARGS: ${{ steps.scope.outputs.args }} + RUST_FLUENT: ${{ needs.changes.outputs.rust_fluent }} CARGO_PROFILE_TEST_DEBUG: 0 run: | read -r -a args <<<"$SCOPE_ARGS" cargo nextest run "${args[@]}" --all-targets --locked --no-default-features --features default-tls + if [[ "$RUST_FLUENT" == true ]]; then + cargo nextest run -p rtbit-fluent --all-targets --locked + fi # The shipped binary is worth a release-profile compile on a PR that # touches it. The queue builds it for every entry anyway. - name: Build the release binary diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 3679a366..b5eb4cac 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -5,6 +5,8 @@ on: branches: [main, dev] tags: ['v*'] workflow_dispatch: + merge_group: + types: [checks_requested] permissions: contents: read @@ -16,6 +18,7 @@ concurrency: jobs: publish: + if: github.event_name != 'merge_group' # Privileged publication capacity is selected only for this repository and # this workflow deliberately has no pull_request trigger. runs-on: [self-hosted, linux, docker, publish] @@ -80,3 +83,61 @@ jobs: -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json' \ "https://ghcr.io/v2/thedancingdeveloper-org/rusttorrent/manifests/$tag" > manifest.json jq -e '.schemaVersion == 2' manifest.json >/dev/null + + smoke: + if: github.event_name == 'merge_group' + runs-on: [self-hosted, linux, docker, publish] + permissions: + contents: read + env: + IMAGE: rusttorrent-container-smoke:${{ github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Build the daemon image + run: docker build --tag "$IMAGE" --file Dockerfile . + - name: Start and probe the daemon + shell: bash + run: | + set -euo pipefail + volume="rusttorrent-smoke-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + container="rusttorrent-smoke-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + cleanup() { + docker rm --force "$container" >/dev/null 2>&1 || true + docker volume rm "$volume" >/dev/null 2>&1 || true + docker image rm "$IMAGE" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + docker volume create "$volume" >/dev/null + docker run --detach --name "$container" --volume "$volume:/home/rtbit/db" \ + --publish 3030:3030 "$IMAGE" >/dev/null + + deadline=$((SECONDS + 30)) + setup_token='' + while (( SECONDS < deadline )); do + if ! setup_token=$(docker exec "$container" cat /home/rtbit/db/session/setup-token 2>/dev/null); then + setup_token='' + fi + if [[ -n "$setup_token" ]] && curl --fail --silent --show-error \ + --output /dev/null http://127.0.0.1:3030/healthz; then + break + fi + sleep 1 + done + [[ -n "$setup_token" ]] + (( SECONDS < deadline )) + + curl --fail --silent --show-error \ + --header 'Content-Type: application/json' \ + --data "{\"username\":\"smoke\",\"password\":\"smoke-password\",\"setup_token\":\"$setup_token\"}" \ + http://127.0.0.1:3030/api/v2/auth/setup >/dev/null + version=$(curl --fail --silent --show-error \ + --user smoke:smoke-password http://127.0.0.1:3030/api/v2/app/webapiVersion) + [[ "$version" == '2.11.3' ]] + curl --fail --silent --show-error \ + http://127.0.0.1:3030/healthz >/dev/null + + docker stop --time 30 "$container" >/dev/null + [[ "$(docker inspect --format '{{.State.ExitCode}}' "$container")" == 0 ]] + docker run --rm --volume "$volume:/data" --entrypoint sh "$IMAGE" \ + -c 'test -s /data/session/session.json' diff --git a/.github/workflows/policy.yml b/.github/workflows/policy.yml index fb23b452..5b5638ef 100644 --- a/.github/workflows/policy.yml +++ b/.github/workflows/policy.yml @@ -32,3 +32,22 @@ jobs: - name: Verify migration policies shell: bash run: bash ci/migration-policy.sh + - name: Detect dependency policy changes + id: dependency-policy + if: github.event_name == 'pull_request' + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if git diff --quiet "$BASE_SHA" HEAD -- Cargo.lock ':(glob)**/Cargo.toml' deny.toml; then + echo "run=false" >> "$GITHUB_OUTPUT" + else + echo "run=true" >> "$GITHUB_OUTPUT" + fi + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2.87.29 + if: github.event_name != 'pull_request' || steps.dependency-policy.outputs.run == 'true' + with: + tool: cargo-deny@0.20.2 + - name: Verify Rust dependency policy + if: github.event_name != 'pull_request' || steps.dependency-policy.outputs.run == 'true' + run: cargo deny check advisories licenses bans sources diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 23da7cf9..330bc470 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -82,6 +82,15 @@ jobs: cargo build -p rtbit-fluent --release --target x86_64-pc-windows-msvc rm -rf target/desktop-stage bash packaging/scripts/build-desktop.sh windows "$tag" target/desktop-stage + - name: Smoke-test Windows fluent artifact + shell: bash + run: | + set -euo pipefail + version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/rtbit/Cargo.toml | head -1)" + tag="v$version" + expected="rtbit-fluent $version" + actual="$(target/desktop-stage/rtbit-fluent-"$tag"-windows-x86_64.exe --version)" + test "$actual" = "$expected" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-dry-run-windows-fluent-${{ github.sha }} @@ -157,6 +166,14 @@ jobs: rm -rf target/desktop-stage bash packaging/scripts/build-desktop.sh linux "$tag" target/desktop-stage bash packaging/scripts/build-desktop.sh sbom "$tag" target/desktop-stage + - name: Smoke-test Linux fluent artifact + shell: bash + run: | + set -euo pipefail + tag="${{ steps.tag.outputs.tag }}" + expected="rtbit-fluent ${tag#v}" + actual="$(target/desktop-stage/rtbit-fluent-"$tag"-linux-x86_64 --version)" + test "$actual" = "$expected" - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 continue-on-error: true with: @@ -278,3 +295,48 @@ jobs: done test "$count" -eq 2 || { echo "::error::expected 2 signed executables (daemon and fluent), verified $count"; exit 1; } (cd release-artifacts && sha256sum -c "SHA256SUMS-$tag.txt") + + report-failure: + name: Report release dry-run failure + if: >- + ${{ always() && github.event_name != 'pull_request' && + (needs.build-windows-fluent.result == 'failure' || needs.dry-run.result == 'failure') }} + needs: [build-windows-fluent, dry-run] + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Open or update the tracking issue + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const title = '[CI] Release dry run is failing'; + const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const report = [ + `The ${context.eventName} release dry run failed.`, + '', + `Run: ${runUrl}`, + `Commit: ${context.sha}`, + '', + 'This workflow does not publish release artifacts.', + ].join('\n'); + const {data: issues} = await github.rest.issues.listForRepo({ + ...context.repo, + state: 'open', + per_page: 100, + }); + const existing = issues.find(issue => !issue.pull_request && issue.title === title); + if (existing) { + await github.rest.issues.createComment({ + ...context.repo, + issue_number: existing.number, + body: report, + }); + } else { + await github.rest.issues.create({ + ...context.repo, + title, + body: report, + }); + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fa3b94f..00d4db8f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,6 +86,14 @@ jobs: rm -rf target/desktop-stage bash packaging/scripts/build-desktop.sh linux "$tag" target/desktop-stage bash packaging/scripts/build-desktop.sh sbom "$tag" target/desktop-stage + - name: Smoke-test Linux fluent artifact + shell: bash + run: | + set -euo pipefail + tag="${{ inputs.tag || github.ref_name }}" + expected="rtbit-fluent ${tag#v}" + actual="$(target/desktop-stage/rtbit-fluent-"$tag"-linux-x86_64 --version)" + test "$actual" = "$expected" - name: Stage and checksum artifacts shell: bash run: | @@ -178,6 +186,14 @@ jobs: cargo build -p rtbit-fluent --release --target x86_64-pc-windows-msvc rm -rf target/desktop-stage bash packaging/scripts/build-desktop.sh windows "$tag" target/desktop-stage + - name: Smoke-test Windows fluent artifact + shell: bash + run: | + set -euo pipefail + tag="${{ inputs.tag || github.ref_name }}" + expected="rtbit-fluent ${tag#v}" + actual="$(target/desktop-stage/rtbit-fluent-"$tag"-windows-x86_64.exe --version)" + test "$actual" = "$expected" - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: release-artifacts-windows-fluent diff --git a/Cargo.lock b/Cargo.lock index 0a358cec..b97805bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8752,6 +8752,7 @@ dependencies = [ "memmap2", "metrics-exporter-prometheus", "mime_guess", + "network-interface", "nix 0.31.3", "notify", "parking_lot", diff --git a/crates/librtbit-dht/src/dht.rs b/crates/librtbit-dht/src/dht.rs index 17f1c90c..9bf886b2 100644 --- a/crates/librtbit-dht/src/dht.rs +++ b/crates/librtbit-dht/src/dht.rs @@ -1114,38 +1114,32 @@ impl DhtWorker { v4.or(v6) } - async fn bootstrap_hostname_with_backoff(&self, addr: &str) -> crate::Result<()> { + async fn bootstrap(&self, bootstrap_addrs: &[String]) -> crate::Result<()> { let backoff = ExponentialBuilder::new() - .with_max_delay(Duration::from_secs(60)) + .with_max_delay(Duration::from_secs(60 * 60)) .with_jitter() - .with_total_delay(Some(Duration::from_secs(86400))) .without_max_times(); - (|| self.bootstrap_hostname(addr)) - .retry(backoff) - .notify(|error, retry_in| { - warn!(?retry_in, ?addr, "error in bootstrap: {error:#}"); - }) - .instrument(debug_span!("bootstrap", hostname = addr)) - .await - } - - async fn bootstrap(&self, bootstrap_addrs: &[String]) -> crate::Result<()> { - let mut futs = FuturesUnordered::new(); - - for addr in bootstrap_addrs.iter() { - futs.push(self.bootstrap_hostname_with_backoff(addr)); - } - let mut successes = 0; - while let Some(resp) = futs.next().await { - if resp.is_ok() { - successes += 1 + (|| async { + let mut futs = FuturesUnordered::new(); + for addr in bootstrap_addrs { + futs.push( + self.bootstrap_hostname(addr) + .instrument(debug_span!("bootstrap", hostname = addr.as_str())), + ); } - } - if successes == 0 { - return Err(Error::BootstrapFailed); - } - Ok(()) + while let Some(resp) = futs.next().await { + if resp.is_ok() { + return Ok(()); + } + } + Err(Error::BootstrapFailed) + }) + .retry(backoff) + .notify(|error, retry_in| { + warn!(?retry_in, "all DHT bootstrap nodes failed: {error:#}"); + }) + .await } async fn bucket_refresher(&self, is_v4: bool) -> crate::Result<()> { diff --git a/crates/librtbit-tracker-comms/src/tracker_comms.rs b/crates/librtbit-tracker-comms/src/tracker_comms.rs index 367664c5..5f3ee6a2 100644 --- a/crates/librtbit-tracker-comms/src/tracker_comms.rs +++ b/crates/librtbit-tracker-comms/src/tracker_comms.rs @@ -86,6 +86,18 @@ impl TorrentStatsProvider for () { type Sender = tokio::sync::mpsc::Sender; +const TRACKER_RETRY_BASE: Duration = Duration::from_secs(10); +const TRACKER_RETRY_CAP: Duration = Duration::from_secs(60 * 60); + +fn tracker_retry_delay(failures: u32) -> Duration { + let exponent = failures.saturating_sub(1).min(31); + let seconds = TRACKER_RETRY_BASE + .as_secs() + .saturating_mul(1u64 << exponent) + .min(TRACKER_RETRY_CAP.as_secs()); + Duration::from_secs(seconds) +} + enum SupportedTracker { Udp(Url), Http(Url), @@ -371,7 +383,7 @@ impl TrackerComms { .with_jitter() .with_factor(2.) .with_min_delay(Duration::from_secs(10)) - .with_max_delay(Duration::from_secs(600)), + .with_max_delay(Duration::from_secs(60 * 60)), ) .when(|_| self.is_active(&tracker_url, generation)) .notify(|err, retry_in| { @@ -483,6 +495,7 @@ impl TrackerComms { ); let mut sleep_interval: Option = None; + let mut announce_failures = 0u32; let mut prev_addrs: Option = None; let mut kick = self.status.as_ref().map(|s| s.subscribe()); loop { @@ -525,9 +538,13 @@ impl TrackerComms { .instrument(trace_span!("udp request", ?addr)) .await { - Ok(sleep) => sleep_interval = Some(sleep), + Ok(sleep) => { + announce_failures = 0; + sleep_interval = Some(sleep) + } Err(_) => { - sleep_interval = Some(sleep_interval.unwrap_or(Duration::from_secs(60))) + announce_failures = announce_failures.saturating_add(1); + sleep_interval = Some(tracker_retry_delay(announce_failures)); } } } @@ -538,12 +555,16 @@ impl TrackerComms { self.tracker_one_request_udp(&url, v6.into(), &client) .instrument(trace_span!("udp request", addr=?v6)) ); - sleep_interval = Some( - r4.or(r6) - .ok() - .or(sleep_interval) - .unwrap_or(Duration::from_secs(60)), - ) + sleep_interval = match r4.or(r6) { + Ok(sleep) => { + announce_failures = 0; + Some(sleep) + } + Err(_) => { + announce_failures = announce_failures.saturating_add(1); + Some(tracker_retry_delay(announce_failures)) + } + }; } } } @@ -613,3 +634,17 @@ impl TrackerComms { } } } + +#[cfg(test)] +mod retry_tests { + use super::{TRACKER_RETRY_CAP, tracker_retry_delay}; + use std::time::Duration; + + #[test] + fn udp_tracker_retry_backoff_is_exponential_and_capped() { + assert_eq!(tracker_retry_delay(1), Duration::from_secs(10)); + assert_eq!(tracker_retry_delay(2), Duration::from_secs(20)); + assert_eq!(tracker_retry_delay(3), Duration::from_secs(40)); + assert_eq!(tracker_retry_delay(20), TRACKER_RETRY_CAP); + } +} diff --git a/crates/librtbit/Cargo.toml b/crates/librtbit/Cargo.toml index ab597552..190b2d13 100644 --- a/crates/librtbit/Cargo.toml +++ b/crates/librtbit/Cargo.toml @@ -65,6 +65,7 @@ peer_binary_protocol.workspace = true sha1w.workspace = true dht.workspace = true librtbit-upnp.workspace = true +network-interface.workspace = true upnp-serve = { workspace = true, optional = true } # External deps diff --git a/crates/librtbit/src/api.rs b/crates/librtbit/src/api.rs index 3020c3ad..d6039b08 100644 --- a/crates/librtbit/src/api.rs +++ b/crates/librtbit/src/api.rs @@ -282,7 +282,7 @@ impl Api { let only_files = handle.only_files(); let category = handle.shared().category.read().clone(); let output_folder = handle.output_folder().to_string_lossy().into_owned(); - make_torrent_details( + let mut details = make_torrent_details( Some(handle.id()), &info_hash, handle.metadata.load().as_ref().map(|r| &r.info), @@ -290,7 +290,18 @@ impl Api { only_files.as_deref(), output_folder, category, - ) + )?; + if let Some(files) = details.files.as_mut() { + let priorities = handle.file_priority_levels(); + for (index, file) in files.iter_mut().enumerate() { + file.priority = + priorities + .get(index) + .copied() + .unwrap_or(if file.included { 1 } else { 0 }); + } + } + Ok(details) } pub fn api_session_stats(&self) -> SessionStatsSnapshot { @@ -463,6 +474,20 @@ impl Api { .await } + pub async fn api_torrent_file_set_priority( + &self, + idx: TorrentIdOrHash, + file_index: usize, + priority: u8, + ) -> Result { + if !matches!(priority, 0 | 1 | 6 | 7) { + return Err(anyhow::anyhow!("invalid file priority").into()); + } + let handle = self.mgr_handle(idx)?; + handle.update_file_priority(file_index, priority)?; + Ok(Default::default()) + } + pub fn api_set_rust_log(&self, new_value: String) -> Result { let tx = self .rust_log_reload_tx @@ -683,6 +708,20 @@ impl Api { })?) } + /// Per-piece count of connected peers that have the piece. Empty while the + /// metadata is unknown (magnet still resolving). + pub fn api_piece_availability(&self, idx: TorrentIdOrHash) -> Result> { + let mgr = self.mgr_handle(idx)?; + let Ok(total) = mgr.with_metadata(|m| m.info.lengths().total_pieces()) else { + return Ok(Vec::new()); + }; + let total = total as usize; + Ok(match mgr.live() { + Some(live) => live.peers.piece_availability(total), + None => vec![0; total], + }) + } + pub async fn api_stream(&self, idx: TorrentIdOrHash, file_id: usize) -> Result { let mgr = self.mgr_handle(idx)?; Ok(mgr.stream(file_id).await?) @@ -696,19 +735,21 @@ impl Api { &self, name: String, save_path: Option, + limits: crate::session::ShareLimits, ) -> Result { let mgr = self.session.categories(); if mgr.exists(&name) { - mgr.edit(&name, save_path) + mgr.edit(&name, save_path, limits) .context("error editing category")?; } else { - mgr.create(name, save_path) + mgr.create(name, save_path, limits) .context("error creating category")?; } self.session .persist_categories() .await .context("error persisting categories")?; + self.session.policy_pass().await; Ok(Default::default()) } @@ -933,10 +974,16 @@ pub struct TorrentDetailsResponseFile { pub components: Vec, pub length: u64, pub included: bool, + #[serde(default = "default_file_priority")] + pub priority: u8, #[cfg_attr(feature = "swagger", schema(value_type = Object))] pub attributes: FileDetailsAttrs, } +const fn default_file_priority() -> u8 { + 1 +} + #[derive(Default, Serialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct EmptyJsonResponse {} @@ -1003,6 +1050,7 @@ fn make_torrent_details( components, length: d.len, included, + priority: if included { 1 } else { 0 }, attributes: d.attrs(), } }) diff --git a/crates/librtbit/src/category.rs b/crates/librtbit/src/category.rs index 600f68ed..0387f332 100644 --- a/crates/librtbit/src/category.rs +++ b/crates/librtbit/src/category.rs @@ -5,11 +5,29 @@ use anyhow::bail; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; +use crate::session::ShareLimits; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TorrentCategory { pub name: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub save_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ratio_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub seeding_time_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inactive_seeding_time_limit: Option, +} + +impl TorrentCategory { + pub fn share_limits(&self) -> ShareLimits { + ShareLimits { + max_ratio: self.ratio_limit, + max_seeding_time: self.seeding_time_limit, + max_inactive_seeding_time: self.inactive_seeding_time_limit, + } + } } #[derive(Debug)] @@ -40,21 +58,43 @@ impl CategoryManager { self.categories.read().clone() } - pub fn create(&self, name: String, save_path: Option) -> anyhow::Result<()> { + pub fn create( + &self, + name: String, + save_path: Option, + limits: ShareLimits, + ) -> anyhow::Result<()> { let mut cats = self.categories.write(); if cats.contains_key(&name) { bail!("category '{name}' already exists"); } - cats.insert(name.clone(), TorrentCategory { name, save_path }); + cats.insert( + name.clone(), + TorrentCategory { + name, + save_path, + ratio_limit: limits.max_ratio, + seeding_time_limit: limits.max_seeding_time, + inactive_seeding_time_limit: limits.max_inactive_seeding_time, + }, + ); Ok(()) } - pub fn edit(&self, name: &str, save_path: Option) -> anyhow::Result<()> { + pub fn edit( + &self, + name: &str, + save_path: Option, + limits: ShareLimits, + ) -> anyhow::Result<()> { let mut cats = self.categories.write(); let cat = cats.get_mut(name); match cat { Some(cat) => { cat.save_path = save_path; + cat.ratio_limit = limits.max_ratio; + cat.seeding_time_limit = limits.max_seeding_time; + cat.inactive_seeding_time_limit = limits.max_inactive_seeding_time; Ok(()) } None => bail!("category '{name}' does not exist"), diff --git a/crates/librtbit/src/http_api/handlers/file_included_tests.rs b/crates/librtbit/src/http_api/handlers/file_included_tests.rs new file mode 100644 index 00000000..24924bfd --- /dev/null +++ b/crates/librtbit/src/http_api/handlers/file_included_tests.rs @@ -0,0 +1,136 @@ +//! Router-level tests for `POST /torrents/{id}/files/{index}/included`. + +use std::{net::Ipv4Addr, sync::Arc}; + +use http::StatusCode; +use tower::ServiceExt; + +use crate::{ + AddTorrent, AddTorrentOptions, Api, CreateTorrentOptions, ListenerMode, ManagedTorrent, + Session, SessionOptions, create_torrent, + http_api::{HttpApi, HttpApiOptions}, + listen::ListenerOptions, + spawn_utils::BlockingSpawner, +}; + +/// The API router over a fresh session holding one paused two-file torrent. +async fn router_with_two_file_torrent() -> (axum::Router, Arc, tempfile::TempDir) { + let output = tempfile::TempDir::with_prefix("file_included").unwrap(); + let payload = output.path().join("payload"); + std::fs::create_dir(&payload).unwrap(); + std::fs::write(payload.join("a.bin"), vec![0x41; 16 * 1024]).unwrap(); + std::fs::write(payload.join("b.bin"), vec![0x42; 16 * 1024]).unwrap(); + let torrent = create_torrent( + &payload, + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + + 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 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 api = Api::new( + session, + None, + #[cfg(feature = "tracing-subscriber-utils")] + None, + ); + let state = Arc::new(HttpApi::new(api, Some(HttpApiOptions::default()))); + (super::make_api_router(state), handle, output) +} + +fn post_included(id: usize, index: usize, body: &str) -> http::Request { + http::Request::builder() + .method("POST") + .uri(format!("/torrents/{id}/files/{index}/included")) + .header("content-type", "application/json") + .body(axum::body::Body::from(body.to_owned())) + .unwrap() +} + +fn selection(handle: &ManagedTorrent) -> Vec { + let mut files = handle.only_files().unwrap_or_else(|| vec![0, 1]); + files.sort_unstable(); + files +} + +#[tokio::test] +async fn excluding_then_including_a_file_updates_the_selection() { + let (router, handle, _output) = router_with_two_file_torrent().await; + let id = handle.id(); + + let response = router + .clone() + .oneshot(post_included(id, 1, r#"{"included":false}"#)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(selection(&handle), vec![0]); + + let response = router + .oneshot(post_included(id, 1, r#"{"included":true}"#)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(selection(&handle), vec![0, 1]); +} + +#[tokio::test] +async fn out_of_range_file_index_is_a_bad_request() { + let (router, handle, _output) = router_with_two_file_torrent().await; + + let response = router + .oneshot(post_included(handle.id(), 2, r#"{"included":false}"#)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(selection(&handle), vec![0, 1]); +} + +#[tokio::test] +async fn malformed_body_is_rejected() { + let (router, handle, _output) = router_with_two_file_torrent().await; + + let response = router + .oneshot(post_included(handle.id(), 0, r#"{"included":"yes"}"#)) + .await + .unwrap(); + assert!(response.status().is_client_error(), "{}", response.status()); + assert_eq!(selection(&handle), vec![0, 1]); +} diff --git a/crates/librtbit/src/http_api/handlers/mod.rs b/crates/librtbit/src/http_api/handlers/mod.rs index 54689b24..44a6e22d 100644 --- a/crates/librtbit/src/http_api/handlers/mod.rs +++ b/crates/librtbit/src/http_api/handlers/mod.rs @@ -1,5 +1,7 @@ pub(crate) mod configure; pub(crate) mod dht; +#[cfg(test)] +mod file_included_tests; pub(crate) mod healthz; pub(crate) mod indexarr; pub(crate) mod logging; @@ -155,6 +157,18 @@ pub fn make_api_router(state: ApiState) -> Router { if !state.opts.read_only { api_router = api_router .route("/torrents", post(torrents::h_torrents_post)) + .route( + "/torrents/bulk/category", + post(torrents::h_bulk_set_torrent_category), + ) + .route( + "/torrents/bulk/tags/add", + post(torrents::h_bulk_add_torrent_tags), + ) + .route( + "/torrents/bulk/tags/remove", + post(torrents::h_bulk_remove_torrent_tags), + ) .route("/torrents/seed_limits", post(torrents::h_set_seed_limits)) .route( "/torrents/limits", @@ -204,6 +218,10 @@ pub fn make_api_router(state: ApiState) -> Router { "/torrents/{id}/files/{index}/included", post(torrents::h_torrent_file_set_included), ) + .route( + "/torrents/{id}/files/{index}/priority", + post(torrents::h_torrent_file_set_priority), + ) .route("/torrents/{id}/add_peers", post(torrents::h_add_peers)) .route("/torrents/create", post(torrents::h_create_torrent)) .route( @@ -218,6 +236,11 @@ pub fn make_api_router(state: ApiState) -> Router { "/torrents/{id}/set_category", post(torrents::h_set_torrent_category), ) + .route( + "/torrents/{id}/set_location", + post(torrents::h_set_torrent_location), + ) + .route("/torrents/{id}/rename", post(torrents::h_rename_torrent)) .route( "/torrents/{id}/seed_limits", post(torrents::h_set_torrent_seed_limits), diff --git a/crates/librtbit/src/http_api/handlers/qbit/app.rs b/crates/librtbit/src/http_api/handlers/qbit/app.rs index 7cacc4bf..3d296d68 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/app.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/app.rs @@ -2,6 +2,69 @@ use super::*; +#[derive(Serialize)] +pub(super) struct NetworkInterfaceEntry { + name: String, + value: String, +} + +pub(super) async fn h_app_network_interface_list() -> axum::Json> { + axum::Json( + NetworkInterface::show() + .unwrap_or_default() + .into_iter() + .map(|interface| NetworkInterfaceEntry { + name: interface.name.clone(), + value: interface.name, + }) + .collect(), + ) +} + +#[derive(Deserialize, Default)] +pub(super) struct NetworkInterfaceQuery { + #[serde(default)] + iface: String, +} + +pub(super) async fn h_app_network_interface_address_list( + Query(query): Query, +) -> axum::response::Response { + let interfaces = match NetworkInterface::show() { + Ok(interfaces) => interfaces, + Err(error) => return qbit_error(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()), + }; + if query.iface.is_empty() { + return axum::Json(Vec::::new()).into_response(); + } + let Some(interface) = interfaces + .into_iter() + .find(|interface| interface.name == query.iface) + else { + return qbit_error(StatusCode::NOT_FOUND, "network interface not found"); + }; + axum::Json( + interface + .addr + .into_iter() + .map(|address| address.ip().to_string()) + .collect::>(), + ) + .into_response() +} + +pub(super) async fn h_app_shutdown(State(state): State>) -> &'static str { + let session = state.api_state.api.session().clone(); + let shutdown_token = state.api_state.opts.shutdown_token.clone(); + tokio::spawn(async move { + session.stop().await; + if let Some(token) = shutdown_token { + token.cancel(); + } + }); + "" +} + // --------------------------------------------------------------------------- // App info endpoints // --------------------------------------------------------------------------- @@ -22,13 +85,82 @@ pub(super) async fn h_app_default_save_path(State(state): State>) state.api_state.api.api_output_folder() } -pub(super) async fn h_app_build_info() -> impl IntoResponse { +/// Configured download roots: the session output folder plus every category +/// save path. +fn save_roots(api: &crate::Api) -> Vec { + let mut roots = vec![std::path::PathBuf::from(api.api_output_folder())]; + roots.extend( + api.api_list_categories() + .into_values() + .filter_map(|category| category.save_path), + ); + roots +} + +/// Whether `path` is a root or lies beneath one. Compared by path component, +/// and a `..` component never qualifies, so it cannot climb out of a root. +fn path_under_roots(path: &std::path::Path, roots: &[std::path::PathBuf]) -> bool { + !path + .components() + .any(|c| matches!(c, std::path::Component::ParentDir)) + && roots.iter().any(|root| path.starts_with(root)) +} + +/// Free bytes on the volume holding the default save path; 0 when the +/// platform cannot report it. +pub(super) fn free_space_on_save_volume(api: &crate::Api) -> u64 { + crate::session::scheduler::free_disk_space(std::path::Path::new(&api.api_output_folder())) + .unwrap_or(0) +} + +#[derive(Deserialize, Default)] +pub(super) struct FreeSpaceQuery { + #[serde(default)] + pub(super) path: String, +} + +/// `app/getFreeSpaceAtPath?path=` — bytes free on the volume holding `path`, +/// as plain text. Only paths at or under a configured download root (the +/// default save path or a category save path) are answered; anything else is +/// 400, so the endpoint cannot probe the rest of the filesystem. +pub(super) async fn h_app_get_free_space_at_path( + State(state): State>, + Query(query): Query, +) -> axum::response::Response { + let api = &state.api_state.api; + let path = std::path::Path::new(&query.path); + if query.path.is_empty() || !path_under_roots(path, &save_roots(api)) { + return qbit_error( + StatusCode::BAD_REQUEST, + "path must be under a configured download folder", + ); + } + match crate::session::scheduler::free_disk_space(path) { + Some(free) => (StatusCode::OK, free.to_string()).into_response(), + None => qbit_error( + StatusCode::BAD_REQUEST, + "free space is not available for this path", + ), + } +} + +const fn tls_flavour() -> &'static str { + if cfg!(feature = "rust-tls") { + "rustls" + } else if cfg!(feature = "default-tls") { + "OpenSSL (native-tls)" + } else { + "none" + } +} + +pub(super) async fn h_app_build_info() -> axum::Json { axum::Json(QbitBuildInfo { qt: "N/A", libtorrent: "N/A", - boost: "N/A", - openssl: "N/A", - bitness: 64, + boost: concat!("rtbit ", env!("CARGO_PKG_VERSION")), + openssl: tls_flavour(), + bitness: usize::BITS, }) } @@ -39,6 +171,8 @@ pub(super) async fn h_app_preferences( let session = state.api_state.api.session(); let (download, upload) = session.normal_rate_limits(); let alt = session.alt_speed_status().config; + let seed = session.seed_limits(); + let net = session.pending_network().unwrap_or_default(); axum::Json(QbitPreferences { save_path, @@ -50,9 +184,14 @@ pub(super) async fn h_app_preferences( max_uploads_per_torrent: -1, dl_limit: pref_limit_to_qbit(download.map(|v| u64::from(v.get()))), up_limit: pref_limit_to_qbit(upload.map(|v| u64::from(v.get()))), - dht: true, + // A saved value applies at the next start; report it so a client that + // just set it reads it back. + dht: net + .dht_enabled + .unwrap_or_else(|| session.get_dht().is_some()), + // Peer exchange is part of the peer protocol and has no switch. pex: true, - lsd: true, + lsd: net.lsd_enabled.unwrap_or_else(|| session.lsd.is_some()), encryption: 0, queueing_enabled: session.queueing_enabled(), max_active_downloads: limit_to_qbit(session.max_active_downloads()), @@ -66,17 +205,65 @@ pub(super) async fn h_app_preferences( alt_dl_limit: pref_limit_to_qbit(alt.download_rate), alt_up_limit: pref_limit_to_qbit(alt.upload_rate), scheduler_enabled: false, - max_ratio_enabled: false, - max_ratio: 2.0, + max_ratio_enabled: seed.max_ratio.is_some(), + max_ratio: seed.max_ratio.unwrap_or(2.0), + // Reaching a limit always pauses the torrent. max_ratio_act: 0, - max_seeding_time_enabled: false, - max_seeding_time: 1440, - max_inactive_seeding_time_enabled: false, - max_inactive_seeding_time: 1440, + max_seeding_time_enabled: seed.max_seeding_time.is_some(), + max_seeding_time: minutes_or(seed.max_seeding_time, 1440), + max_inactive_seeding_time_enabled: seed.max_inactive_seeding_time.is_some(), + max_inactive_seeding_time: minutes_or(seed.max_inactive_seeding_time, 1440), }) } -/// `app/setPreferences`. `announce_port`, the queue settings +/// Minutes on the wire for a global limit, or `default` when it is off. +fn minutes_or(limit: Option, default: i64) -> i64 { + limit.map_or(default, |d| i64::from(minutes_to_qbit(Some(d)))) +} + +/// Apply an enabled flag and value pair to one global share limit. A flag +/// alone keeps the current value, or `default` when there is none. +fn merge_limit( + current: Option, + enabled: Option, + value: Option, + default: T, +) -> Option { + match enabled { + Some(false) => None, + Some(true) => value.or(current).or(Some(default)), + None => current.map(|c| value.unwrap_or(c)), + } +} + +/// The global share limits after a preferences update. +fn share_limits_update(preferences: &SetPreferences, current: ShareLimits) -> ShareLimits { + let minutes = |m: Option| m.and_then(minutes_from_qbit); + ShareLimits { + max_ratio: merge_limit( + current.max_ratio, + preferences.max_ratio_enabled, + preferences.max_ratio.filter(|r| *r >= 0.0), + 2.0, + ), + max_seeding_time: merge_limit( + current.max_seeding_time, + preferences.max_seeding_time_enabled, + minutes(preferences.max_seeding_time), + std::time::Duration::from_secs(1440 * 60), + ), + max_inactive_seeding_time: merge_limit( + current.max_inactive_seeding_time, + preferences.max_inactive_seeding_time_enabled, + minutes(preferences.max_inactive_seeding_time), + std::time::Duration::from_secs(1440 * 60), + ), + } +} + +/// `app/setPreferences`. `announce_port`, the global share limits +/// (`max_ratio*`, `max_seeding_time*`, `max_inactive_seeding_time*`), `dht` and +/// `lsd` (saved for the next start; `pex` is always on), the queue settings /// (`queueing_enabled`, `max_active_downloads`, `max_active_uploads`) and the /// normal and alternative speed limits (`dl_limit`, `up_limit`, /// `alt_dl_limit`, `alt_up_limit`, bytes/s, 0 or -1 unlimited) are applied; @@ -115,6 +302,26 @@ pub(super) async fn h_app_set_preferences( { warn!(%error, "qbit compat: error saving queue limits"); } + let current = session.seed_limits(); + let limits = share_limits_update(&preferences, current); + if limits != current + && let Err(error) = session.set_seed_limits(limits).await + { + warn!(%error, "qbit compat: error saving global share limits"); + } + if preferences.dht.is_some() || preferences.lsd.is_some() { + // Discovery is chosen at startup, so these are saved for the next start. + if session.has_persistence() { + session.set_pending_network(crate::session_persistence::NetworkSettings { + dht_enabled: preferences.dht, + lsd_enabled: preferences.lsd, + ..Default::default() + }); + if let Err(error) = session.persist_session_settings().await { + warn!(%error, "qbit compat: error saving network settings"); + } + } + } if preferences.dl_limit.is_some() || preferences.up_limit.is_some() { let (download, upload) = session.normal_rate_limits(); let bps = |limit: i64| pref_limit(limit).and_then(limit_to_bps); diff --git a/crates/librtbit/src/http_api/handlers/qbit/categories.rs b/crates/librtbit/src/http_api/handlers/qbit/categories.rs index fd0e9019..68c6ad19 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/categories.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/categories.rs @@ -17,7 +17,20 @@ pub(super) async fn h_categories(State(state): State>) -> impl In .save_path .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(); - (name.clone(), QbitCategory { name, save_path }) + ( + name.clone(), + QbitCategory { + name, + save_path, + ratio_limit: category.ratio_limit.unwrap_or(-1.0), + seeding_time_limit: category + .seeding_time_limit + .map_or(-1, |d| (d.as_secs() / 60) as i64), + inactive_seeding_time_limit: category + .inactive_seeding_time_limit + .map_or(-1, |d| (d.as_secs() / 60) as i64), + }, + ) }) .collect(); axum::Json(serde_json::to_value(&map).unwrap_or_default()) @@ -29,6 +42,12 @@ pub(super) struct CreateCategoryForm { pub(super) category: String, #[serde(default, alias = "savePath")] pub(super) save_path: String, + #[serde(default, alias = "ratioLimit")] + pub(super) ratio_limit: Option, + #[serde(default, alias = "seedingTimeLimit")] + pub(super) seeding_time_limit: Option, + #[serde(default, alias = "inactiveSeedingTimeLimit")] + pub(super) inactive_seeding_time_limit: Option, } /// Upstream rejects names with a leading or trailing `/`, or an empty path @@ -89,7 +108,19 @@ async fn save_category(state: &QbitState, form: CreateCategoryForm) -> axum::res match state .api_state .api - .api_create_or_edit_category(form.category, save_path) + .api_create_or_edit_category( + form.category, + save_path, + crate::session::ShareLimits { + max_ratio: form.ratio_limit.filter(|v| *v >= 0.0), + max_seeding_time: form + .seeding_time_limit + .and_then(crate::http_api::handlers::qbit::torrentcreator::minutes_from_qbit), + max_inactive_seeding_time: form + .inactive_seeding_time_limit + .and_then(crate::http_api::handlers::qbit::torrentcreator::minutes_from_qbit), + }, + ) .await { Ok(_) => (StatusCode::OK, "Ok.").into_response(), diff --git a/crates/librtbit/src/http_api/handlers/qbit/mod.rs b/crates/librtbit/src/http_api/handlers/qbit/mod.rs index df85c1d7..cb822b5f 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/mod.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/mod.rs @@ -5,7 +5,7 @@ //! pretending to be qBittorrent. use std::{ - collections::{BTreeSet, HashMap, HashSet}, + collections::{BTreeSet, HashMap}, num::{NonZeroU16, NonZeroU32}, path::Path, path::PathBuf, @@ -22,6 +22,7 @@ use axum::{ }; use bytes::Bytes; use http::{HeaderMap, Method, StatusCode, header::RETRY_AFTER, header::SET_COOKIE}; +use network_interface::{NetworkInterface, NetworkInterfaceConfig}; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use tracing::{debug, info, warn}; @@ -458,6 +459,22 @@ struct SetPreferences { alt_dl_limit: Option, #[serde(default)] alt_up_limit: Option, + #[serde(default)] + max_ratio_enabled: Option, + #[serde(default)] + max_ratio: Option, + #[serde(default)] + max_seeding_time_enabled: Option, + #[serde(default)] + max_seeding_time: Option, + #[serde(default)] + max_inactive_seeding_time_enabled: Option, + #[serde(default)] + max_inactive_seeding_time: Option, + #[serde(default)] + dht: Option, + #[serde(default)] + lsd: Option, } /// A preferences byte-rate limit as rtbit stores it: 0 or negative means @@ -561,8 +578,12 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { .route("/webapiVersion", get(h_app_webapi_version)) .route("/buildInfo", get(h_app_build_info)) .route("/defaultSavePath", get(h_app_default_save_path)) + .route("/getFreeSpaceAtPath", get(h_app_get_free_space_at_path)) + .route("/networkInterfaceList", get(h_app_network_interface_list)) + .route("/networkInterfaceAddressList", get(h_app_network_interface_address_list)) .route("/preferences", get(h_app_preferences)) - .route("/setPreferences", post(h_app_set_preferences)); + .route("/setPreferences", post(h_app_set_preferences)) + .route("/shutdown", post(h_app_shutdown)); // Torrent endpoints let torrents_router = Router::new() @@ -573,6 +594,9 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { .route("/trackers", get(h_torrents_trackers)) .route("/webseeds", get(h_torrents_webseeds)) .route("/export", get(h_torrents_export)) + .route("/fetchMetadata", post(h_torrents_fetch_metadata)) + .route("/parseMetadata", post(h_torrents_parse_metadata)) + .route("/pieceAvailability", get(h_torrents_piece_availability)) .route("/pieceStates", get(h_torrents_piece_states)) .route("/pieceHashes", get(h_torrents_piece_hashes)) .route("/add", post(h_torrents_add)) @@ -612,6 +636,9 @@ pub(crate) fn make_qbit_router(api_state: ApiState) -> Router { .route("/setLocation", post(h_torrents_set_location)) .route("/setSavePath", post(h_torrents_set_location)) .route("/setCategory", post(h_torrents_set_category)) + .route("/setComment", post(h_torrents_set_comment)) + .route("/setAutoManagement", post(h_torrents_set_auto_management)) + .route("/setSuperSeeding", post(h_torrents_set_super_seeding)) .route("/tags", get(h_torrents_tags)) .route("/createTags", post(h_torrents_create_tags)) .route("/deleteTags", post(h_torrents_delete_tags)) diff --git a/crates/librtbit/src/http_api/handlers/qbit/sync.rs b/crates/librtbit/src/http_api/handlers/qbit/sync.rs index 4fdb532b..04df481e 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/sync.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/sync.rs @@ -67,7 +67,20 @@ pub(super) async fn h_sync_maindata( .save_path .map(|path| path.to_string_lossy().into_owned()) .unwrap_or_default(); - (name.clone(), QbitCategory { name, save_path }) + ( + name.clone(), + QbitCategory { + name, + save_path, + ratio_limit: category.ratio_limit.unwrap_or(-1.0), + seeding_time_limit: category + .seeding_time_limit + .map_or(-1, |d| (d.as_secs() / 60) as i64), + inactive_seeding_time_limit: category + .inactive_seeding_time_limit + .map_or(-1, |d| (d.as_secs() / 60) as i64), + }, + ) }) .collect(); @@ -93,7 +106,7 @@ pub(super) async fn h_sync_maindata( queueing: api.session().queueing_enabled(), use_alt_speed_limits: api.session().alt_speed_enabled(), refresh_interval: 1500, - free_space_on_disk: 0, + free_space_on_disk: free_space_on_save_volume(api), global_ratio, }; diff --git a/crates/librtbit/src/http_api/handlers/qbit/tests.rs b/crates/librtbit/src/http_api/handlers/qbit/tests.rs index d6f75cc2..d677fedc 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/tests.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/tests.rs @@ -1,5 +1,6 @@ use std::{net::Ipv4Addr, path::Path, sync::Arc, time::Duration}; +use axum::extract::FromRequest; use axum::response::IntoResponse; use bytes::Bytes; use http::StatusCode; @@ -17,11 +18,11 @@ use crate::{ use super::{ AddTaskForm, CreatorParams, CreatorStatus, CreatorTask, QbitSessions, QbitState, QbitTorrentCreator, RequiredTaskIdQuery, SyncSnapshots, creator_params, creator_task_status, - h_app_preferences, h_app_set_preferences, h_app_version, h_torrentcreator_add_task, - h_torrentcreator_delete_task, h_torrentcreator_torrent_file, h_torrents_add_tags, - h_torrents_create_tags, h_torrents_delete_tags, h_torrents_info, h_torrents_recheck, - h_torrents_remove_tags, h_torrents_rename_tag, h_torrents_set_tags, matches_category, - origin_allowed, qbit_file_name, + h_app_build_info, h_app_preferences, h_app_set_preferences, h_app_version, + h_torrentcreator_add_task, h_torrentcreator_delete_task, h_torrentcreator_torrent_file, + h_torrents_add_tags, h_torrents_create_tags, h_torrents_delete_tags, h_torrents_fetch_metadata, + h_torrents_info, h_torrents_parse_metadata, h_torrents_recheck, h_torrents_remove_tags, + h_torrents_rename_tag, h_torrents_set_tags, matches_category, origin_allowed, qbit_file_name, }; async fn qbit_state() -> (Arc, Arc, tempfile::TempDir) { @@ -83,6 +84,71 @@ async fn preferences_reports_fixed_listener_and_runtime_announce_port() { assert_eq!(preferences.web_ui_port, 3031); } +#[tokio::test] +async fn parse_metadata_returns_details_without_adding_torrent() { + let (state, session, output) = qbit_state().await; + let payload = output.path().join("payload.bin"); + std::fs::write(&payload, vec![7u8; 4096]).unwrap(); + let torrent = create_torrent( + &payload, + CreateTorrentOptions { + piece_length: Some(16_384), + ..Default::default() + }, + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + let boundary = "metadataBoundary"; + let body = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"payload.torrent\"\r\nContent-Type: application/x-bittorrent\r\n\r\n" + ) + .into_bytes() + .into_iter() + .chain(torrent) + .chain(format!("\r\n--{boundary}--\r\n").into_bytes()) + .collect::>(); + let request = axum::extract::Request::builder() + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(axum::body::Body::from(body)) + .unwrap(); + let response = h_torrents_parse_metadata( + axum::extract::State(state), + axum::extract::Multipart::from_request(request, &()) + .await + .unwrap(), + ) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let metadata: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(metadata["name"], "payload.bin"); + assert_eq!(metadata["size"], 4096); + assert_eq!(metadata["files"][0]["size"], 4096); + assert_eq!(session.with_torrents(|torrents| torrents.count()), 0); +} + +#[tokio::test] +async fn fetch_metadata_rejects_private_url_before_fetching() { + let (state, _session, _output) = qbit_state().await; + let response = h_torrents_fetch_metadata( + axum::extract::State(state), + Bytes::from_static(b"url=http%3A%2F%2F127.0.0.1%2Ftorrent"), + ) + .await + .into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn set_preferences_updates_port_idempotently_without_rebinding_listener() { let (state, session, _output) = qbit_state().await; @@ -141,6 +207,22 @@ async fn app_version_is_on_the_5x_line() { assert!(h_app_version().await.starts_with("v5.")); } +#[tokio::test] +async fn build_info_reports_compatible_schema_and_real_bitness() { + let build_info = serde_json::to_value(h_app_build_info().await.0).unwrap(); + let object = build_info.as_object().unwrap(); + + assert_eq!(object.len(), 5); + for key in ["qt", "libtorrent", "boost", "openssl"] { + assert!(build_info[key].is_string(), "{key} must be a string"); + } + assert_eq!(build_info["bitness"], usize::BITS); + assert_eq!( + build_info["boost"], + concat!("rtbit ", env!("CARGO_PKG_VERSION")) + ); +} + #[tokio::test] async fn preferences_report_seeding_limits_as_disabled() { let (state, _session, _output) = qbit_state().await; @@ -1609,3 +1691,43 @@ fn incomplete_read_only_payload_reports_missing_files() { &stats(None) )); } + +#[cfg(unix)] +#[tokio::test] +async fn free_space_at_path_answers_only_under_download_roots() { + use super::{FreeSpaceQuery, h_app_get_free_space_at_path}; + let (state, _session, output) = qbit_state().await; + let ask = |path: String| { + let state = state.clone(); + async move { + h_app_get_free_space_at_path( + axum::extract::State(state), + axum::extract::Query(FreeSpaceQuery { path }), + ) + .await + } + }; + let root = output.path().to_string_lossy().into_owned(); + let child = output + .path() + .join("not/yet/created") + .to_string_lossy() + .into_owned(); + for allowed in [root.clone(), child] { + let response = ask(allowed).await; + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 1024) + .await + .unwrap(); + assert!(std::str::from_utf8(&body).unwrap().parse::().is_ok()); + } + for refused in [ + String::new(), + "/".to_owned(), + "/tmp".to_owned(), + format!("{root}/../.."), + format!("{root}-sibling"), + ] { + assert_eq!(ask(refused).await.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/crates/librtbit/src/http_api/handlers/qbit/torrent_actions.rs b/crates/librtbit/src/http_api/handlers/qbit/torrent_actions.rs index dc34ee78..9d9cea41 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/torrent_actions.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/torrent_actions.rs @@ -2,6 +2,113 @@ use super::*; +const METADATA_RESOLUTION_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Serialize)] +struct QbitMetadataFile { + name: String, + size: u64, +} + +#[derive(Serialize)] +struct QbitMetadataResponse { + name: String, + size: u64, + files: Vec, + hash: String, +} + +#[derive(Deserialize, Default)] +struct FetchMetadataForm { + #[serde(default)] + magnet: String, + #[serde(default)] + url: String, +} + +fn metadata_response(details: crate::api::TorrentDetailsResponse) -> QbitMetadataResponse { + let files = details + .files + .unwrap_or_default() + .into_iter() + .map(|file| QbitMetadataFile { + name: file.name, + size: file.length, + }) + .collect::>(); + let size = files.iter().map(|file| file.size).sum(); + QbitMetadataResponse { + name: details.name.unwrap_or_default(), + size, + files, + hash: details.info_hash, + } +} + +async fn list_only_metadata( + state: &QbitState, + source: AddTorrent<'static>, +) -> Result { + let mut opts = AddTorrentOptions { + list_only: true, + ..Default::default() + }; + let response = tokio::time::timeout( + METADATA_RESOLUTION_TIMEOUT, + state + .api_state + .api + .api_add_torrent(source, Some(std::mem::take(&mut opts))), + ) + .await + .map_err(|_| (StatusCode::ACCEPTED, "metadata is still resolving"))? + .map_err(|_| (StatusCode::BAD_REQUEST, "unable to resolve metadata"))?; + Ok(metadata_response(response.details)) +} + +pub(super) async fn h_torrents_parse_metadata( + State(state): State>, + mut multipart: Multipart, +) -> impl IntoResponse { + let mut torrent = None; + while let Ok(Some(field)) = multipart.next_field().await { + if matches!(field.name(), Some("file" | "torrent" | "torrents")) { + torrent = field.bytes().await.ok(); + break; + } + } + let Some(torrent) = torrent.filter(|bytes| !bytes.is_empty()) else { + return qbit_error(StatusCode::BAD_REQUEST, "torrent file is required"); + }; + match list_only_metadata(&state, AddTorrent::TorrentFileBytes(torrent)).await { + Ok(metadata) => axum::Json(metadata).into_response(), + Err((status, message)) => qbit_error(status, message), + } +} + +pub(super) async fn h_torrents_fetch_metadata( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: FetchMetadataForm = serde_urlencoded::from_bytes(&body).unwrap_or_default(); + let source = if !form.magnet.trim().is_empty() { + form.magnet.trim().to_owned() + } else { + let url = form.url.trim().to_owned(); + if let Err(error) = crate::rss::fetch::check_feed_url(&url) { + return qbit_error(StatusCode::BAD_REQUEST, error.to_string()); + } + url + }; + if source.is_empty() { + return qbit_error(StatusCode::BAD_REQUEST, "magnet or url is required"); + } + match list_only_metadata(&state, AddTorrent::Url(source.into())).await { + Ok(metadata) => axum::Json(metadata).into_response(), + Err((status, message)) => qbit_error(status, message), + } +} + // --------------------------------------------------------------------------- // Torrent actions (add, pause, resume, delete) // --------------------------------------------------------------------------- @@ -632,6 +739,79 @@ pub(super) async fn h_torrents_set_category( (StatusCode::OK, "Ok.").into_response() } +#[derive(Deserialize, Default)] +pub(super) struct SetCommentForm { + #[serde(default)] + pub(super) hashes: String, + #[serde(default)] + pub(super) comment: String, +} + +pub(super) async fn h_torrents_set_comment( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + let form: SetCommentForm = 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.shared().comment_override.write() = Some(form.comment.clone()); + api.session().try_update_persistence_metadata(&handle).await; + } + } + (StatusCode::OK, "Ok.") +} + +#[derive(Deserialize, Default)] +pub(super) struct SetBooleanForm { + #[serde(default)] + pub(super) hashes: String, + #[serde(default)] + pub(super) value: String, +} + +async fn set_torrent_boolean( + state: &QbitState, + body: &[u8], + set: impl Fn(&crate::torrent_state::ManagedTorrentShared, bool), +) { + let form: SetBooleanForm = serde_urlencoded::from_bytes(body).unwrap_or_default(); + let enabled = form.value.eq_ignore_ascii_case("true"); + let api = &state.api_state.api; + for idx in resolve_hashes(api, &form.hashes) { + if let Ok(handle) = api.mgr_handle(idx) { + set(handle.shared(), enabled); + api.session().try_update_persistence_metadata(&handle).await; + } + } +} + +pub(super) async fn h_torrents_set_auto_management( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + set_torrent_boolean(&state, &body, |shared, enabled| { + shared + .auto_management + .store(enabled, std::sync::atomic::Ordering::Relaxed); + }) + .await; + (StatusCode::OK, "Ok.") +} + +pub(super) async fn h_torrents_set_super_seeding( + State(state): State>, + body: Bytes, +) -> impl IntoResponse { + set_torrent_boolean(&state, &body, |shared, enabled| { + shared + .super_seeding + .store(enabled, std::sync::atomic::Ordering::Relaxed); + }) + .await; + (StatusCode::OK, "Ok.") +} + #[derive(Deserialize, Default)] pub(super) struct AddTrackersForm { #[serde(default)] @@ -828,22 +1008,18 @@ pub(super) async fn h_torrents_file_prio( Ok(idx) => idx, Err(_) => return (StatusCode::NOT_FOUND, "Not found"), }; - let handle = match api.mgr_handle(idx) { - Ok(h) => h, + match api.mgr_handle(idx) { + Ok(_) => {} 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; + if !matches!(form.priority, 0 | 1 | 6 | 7) { + return (StatusCode::CONFLICT, "Invalid priority"); + } for id in form .id .split('|') @@ -852,20 +1028,19 @@ pub(super) async fn h_torrents_file_prio( if id >= num_files { return (StatusCode::CONFLICT, "Invalid file id"); } - if download { - included.insert(id); - } else { - included.remove(&id); + match api + .api_torrent_file_set_priority(idx, id, form.priority) + .await + { + Ok(_) => {} + Err(error) => { + warn!(%error, "qbit compat: error setting file priority"); + return (StatusCode::CONFLICT, "Failed"); + } } } - 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") - } + if form.id.trim().is_empty() { + return (StatusCode::CONFLICT, "Invalid file id"); } + (StatusCode::OK, "Ok.") } diff --git a/crates/librtbit/src/http_api/handlers/qbit/torrentcreator.rs b/crates/librtbit/src/http_api/handlers/qbit/torrentcreator.rs index e01a39b6..af08466f 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/torrentcreator.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/torrentcreator.rs @@ -140,6 +140,12 @@ pub(super) struct QbitCategory { pub(super) name: String, #[serde(rename = "savePath")] pub(super) save_path: String, + #[serde(rename = "ratioLimit")] + pub(super) ratio_limit: f64, + #[serde(rename = "seedingTimeLimit")] + pub(super) seeding_time_limit: i64, + #[serde(rename = "inactiveSeedingTimeLimit")] + pub(super) inactive_seeding_time_limit: i64, } /// Combined qBit compat state. diff --git a/crates/librtbit/src/http_api/handlers/qbit/torrents.rs b/crates/librtbit/src/http_api/handlers/qbit/torrents.rs index 43906899..ca879c5f 100644 --- a/crates/librtbit/src/http_api/handlers/qbit/torrents.rs +++ b/crates/librtbit/src/http_api/handlers/qbit/torrents.rs @@ -206,7 +206,10 @@ pub(super) fn build_torrent_info( QbitTorrentInfo { added_on, amount_left: stats.total_bytes.saturating_sub(stats.progress_bytes), - auto_tmm: false, + auto_tmm: handle + .shared() + .auto_management + .load(std::sync::atomic::Ordering::Relaxed), availability: -1, category, completed: stats.progress_bytes, @@ -257,7 +260,10 @@ pub(super) fn build_torrent_info( seq_dl: handle.is_sequential_download(), size: stats.total_bytes, state: qbit_state.to_string(), - super_seeding: false, + super_seeding: handle + .shared() + .super_seeding + .load(std::sync::atomic::Ordering::Relaxed), tags: handle .shared() .tags @@ -504,7 +510,12 @@ pub(super) async fn h_torrents_properties( save_path: output_folder, creation_date: meta.creation_date, piece_size, - comment: meta.comment, + comment: handle + .shared() + .comment_override + .read() + .clone() + .unwrap_or(meta.comment), total_wasted: 0, total_uploaded: stats.uploaded_bytes, total_uploaded_session: stats.uploaded_bytes, @@ -630,6 +641,7 @@ pub(super) async fn h_torrents_files( }) .unwrap_or_default(); let use_piece_ranges = piece_ranges.len() == details_files.len(); + let priorities = handle.file_priority_levels(); let files: Vec = details_files .iter() .enumerate() @@ -651,7 +663,10 @@ pub(super) async fn h_torrents_files( name, size: f.length, progress, - priority: if f.included { 1 } else { 0 }, + priority: priorities + .get(i) + .copied() + .unwrap_or(if f.included { 1 } else { 0 }), is_seed, piece_range: if use_piece_ranges { piece_ranges[i] @@ -746,6 +761,20 @@ pub(super) async fn h_torrents_piece_states( axum::Json(states).into_response() } +pub(super) async fn h_torrents_piece_availability( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { + let idx = match TorrentIdOrHash::parse(&query.hash) { + Ok(idx) => idx, + Err(_) => return qbit_error(StatusCode::NOT_FOUND, "Not found"), + }; + match state.api_state.api.api_piece_availability(idx) { + Ok(counts) => axum::Json(counts).into_response(), + Err(_) => qbit_error(StatusCode::NOT_FOUND, "Not found"), + } +} + pub(super) async fn h_torrents_piece_hashes( State(state): State>, Query(query): Query, diff --git a/crates/librtbit/src/http_api/handlers/qbit_parity.rs b/crates/librtbit/src/http_api/handlers/qbit_parity.rs index dc1d11d9..cd66bd76 100644 --- a/crates/librtbit/src/http_api/handlers/qbit_parity.rs +++ b/crates/librtbit/src/http_api/handlers/qbit_parity.rs @@ -654,7 +654,7 @@ async fn routed_responses_match_declared_shapes() { continue; }; let uri = format!( - "/{}?hash={hash}&hashes={hash}&taskID={task_id}", + "/{}?hash={hash}&hashes={hash}&taskID={task_id}&path={source}", ep.endpoint ); let response = get(&router, &uri).await; @@ -1672,7 +1672,7 @@ async fn bridge_endpoints_through_compat_router() { .json(); assert_eq!(states, Value::from(vec![2; pieces])); - // filePrio: 0 skips a file, any other level includes it again. + // filePrio preserves qBittorrent's skip/normal/high/max levels. let file_priorities = || async { get(&router, &format!("/torrents/files?hash={hash}")) .await @@ -1699,8 +1699,12 @@ async fn bridge_endpoints_through_compat_router() { }; assert_eq!(set_prio("1", "0").await, StatusCode::OK); assert_eq!(file_priorities().await, vec![1, 0]); + assert_eq!(set_prio("0", "6").await, StatusCode::OK); + assert_eq!(file_priorities().await, vec![6, 0]); assert_eq!(set_prio("1", "7").await, StatusCode::OK); - assert_eq!(file_priorities().await, vec![1, 1]); + assert_eq!(file_priorities().await, vec![6, 7]); + assert_eq!(set_prio("0", "1").await, StatusCode::OK); + assert_eq!(file_priorities().await, vec![1, 7]); assert_eq!(set_prio("5", "0").await, StatusCode::CONFLICT); let added = post_form( @@ -1870,6 +1874,28 @@ async fn post_add(router: &axum::Router, torrent: &[u8], fields: &[(&str, &str)] send(router, request).await } +/// Global share limits set through `app/setPreferences` are read back, and a +/// disabled flag turns the limit off again. +#[tokio::test] +async fn set_preferences_applies_global_share_limits() { + let (router, session, _output) = make_router().await; + let json = r#"{"max_ratio_enabled":true,"max_ratio":1.5,"max_seeding_time_enabled":true,"max_seeding_time":90}"#; + let set = post_form(&router, "/app/setPreferences", &[("json", json)]).await; + assert_eq!(set.status, StatusCode::OK); + let prefs = get(&router, "/app/preferences").await.json(); + assert_eq!(prefs["max_ratio_enabled"], true); + assert_eq!(prefs["max_ratio"], 1.5); + assert_eq!(prefs["max_seeding_time_enabled"], true); + assert_eq!(prefs["max_seeding_time"], 90); + assert_eq!(session.seed_limits().max_ratio, Some(1.5)); + + let json = r#"{"max_ratio_enabled":false}"#; + post_form(&router, "/app/setPreferences", &[("json", json)]).await; + let prefs = get(&router, "/app/preferences").await.json(); + assert_eq!(prefs["max_ratio_enabled"], false); + assert_eq!(prefs["max_seeding_time_enabled"], true); +} + /// The calls Sonarr and Radarr make: "Test Connection" (version, preferences, /// categories, creating their category) and then a grab (add stopped into the /// category with a content layout, read it back by category, properties and @@ -1902,7 +1928,8 @@ async fn arr_test_connection_and_grab_flow() { assert!(prefs.get(key).is_some(), "preferences lacks {key}: {prefs}"); } assert_eq!(prefs["max_ratio_enabled"], false); - assert_eq!(prefs["dht"], true); + assert!(prefs["dht"].is_boolean()); + assert_eq!(prefs["pex"], true); assert_eq!( get(&router, "/torrents/categories").await.json(), serde_json::json!({}) diff --git a/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json b/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json index 240b9e47..9924339e 100644 --- a/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json +++ b/crates/librtbit/src/http_api/handlers/qbit_parity_spec.json @@ -9,7 +9,7 @@ "endpoint": "app/buildInfo", "method": "GET", "status": "partial", - "notes": "Placeholder values for qt/libtorrent/boost/openssl.", + "notes": "Keeps qt/libtorrent keys for schema compatibility, reports the rtbit crate version in boost, the configured OpenSSL/rustls flavour in openssl, and the target pointer width in bitness.", "response": { "content": "json", "schema": { @@ -50,23 +50,28 @@ { "endpoint": "app/getFreeSpaceAtPath", "method": "GET", - "status": "missing" + "status": "full", + "response": { + "content": "text" + } }, { "endpoint": "app/networkInterfaceAddressList", "method": "GET", - "status": "missing" + "status": "full", + "response": { "content": "json", "schema": ["string"] } }, { "endpoint": "app/networkInterfaceList", "method": "GET", - "status": "missing" + "status": "full", + "response": { "content": "json", "schema": [{ "name": "string", "value": "string" }] } }, { "endpoint": "app/preferences", "method": "GET", "status": "partial", - "notes": "Subset of preference fields. dl_limit/up_limit and alt_dl_limit/alt_up_limit report the live normal and alternative session limits (bytes/s, 0 unlimited); scheduler_enabled is false. queueing_enabled, max_active_downloads and max_active_uploads reflect the session queue limits; max_active_torrents is -1 (no combined limit). Global seeding-limit fields (max_ratio_enabled, max_seeding_time_enabled, max_inactive_seeding_time_enabled and their values) are reported disabled with upstream default values; rtbit enforces seeding limits per torrent only (torrents/setShareLimits). dht/pex/lsd are reported true.", + "notes": "Subset of preference fields. dl_limit/up_limit and alt_dl_limit/alt_up_limit report the live normal and alternative session limits (bytes/s, 0 unlimited); scheduler_enabled is false. queueing_enabled, max_active_downloads and max_active_uploads reflect the session queue limits; max_active_torrents is -1 (no combined limit). Global seeding-limit fields (max_ratio*, max_seeding_time*, max_inactive_seeding_time*) report the session-wide seed limits (disabled with upstream default values when unset); max_ratio_act is always 0 (pause). dht and lsd report the running state, or the value saved for the next start; pex is always true.", "response": { "content": "json", "schema": { @@ -135,13 +140,15 @@ { "endpoint": "app/shutdown", "method": "POST", - "status": "missing" + "status": "full", + "response": { "content": "text" }, + "notes": "Requires authentication and initiates graceful session shutdown." }, { "endpoint": "app/version", "method": "GET", "status": "partial", - "notes": "Returns a fixed 5.x-line version string (v5.0.0) matching the advertised WebAPI version; not a real qBittorrent build.", + "notes": "Returns a fixed 5.x-line version string (v5.0.0) matching the advertised WebAPI version because *arr clients gate compatibility features on this value; it intentionally does not report the rtbit crate version.", "response": { "content": "text" } @@ -766,7 +773,22 @@ { "endpoint": "torrents/fetchMetadata", "method": "POST", - "status": "missing" + "status": "full", + "notes": "Resolves a magnet or validated public HTTP(S) URL without adding it; returns 202 while metadata resolution exceeds the bounded timeout.", + "response": { + "content": "json", + "schema": { + "name": "string", + "size": "integer", + "files": [ + { + "name": "string", + "size": "integer" + } + ], + "hash": "string" + } + } }, { "endpoint": "torrents/filePrio", @@ -889,7 +911,21 @@ { "endpoint": "torrents/parseMetadata", "method": "POST", - "status": "missing" + "status": "full", + "response": { + "content": "json", + "schema": { + "name": "string", + "size": "integer", + "files": [ + { + "name": "string", + "size": "integer" + } + ], + "hash": "string" + } + } }, { "endpoint": "torrents/pause", @@ -904,7 +940,14 @@ { "endpoint": "torrents/pieceAvailability", "method": "GET", - "status": "missing" + "status": "full", + "notes": "Per-piece count of connected peers that advertise the piece; empty array while a magnet is still resolving.", + "response": { + "content": "json", + "schema": [ + "integer" + ] + } }, { "endpoint": "torrents/pieceHashes", @@ -1077,7 +1120,11 @@ { "endpoint": "torrents/setAutoManagement", "method": "POST", - "status": "missing" + "status": "partial", + "notes": "Persists the per-torrent auto-management flag and reports it in torrents/info auto_tmm.", + "response": { + "content": "text" + } }, { "endpoint": "torrents/setCategory", @@ -1090,7 +1137,11 @@ { "endpoint": "torrents/setComment", "method": "POST", - "status": "missing" + "status": "partial", + "notes": "Persists a per-torrent comment and reports it in torrents/properties.", + "response": { + "content": "text" + } }, { "endpoint": "torrents/setDownloadLimit", @@ -1151,7 +1202,11 @@ { "endpoint": "torrents/setSuperSeeding", "method": "POST", - "status": "missing" + "status": "partial", + "notes": "Persists the per-torrent super-seeding flag and reports it in torrents/info super_seeding; peer behavior is not changed.", + "response": { + "content": "text" + } }, { "endpoint": "torrents/setTags", diff --git a/crates/librtbit/src/http_api/handlers/qbit_replay.rs b/crates/librtbit/src/http_api/handlers/qbit_replay.rs index c5fadf22..d66d9326 100644 --- a/crates/librtbit/src/http_api/handlers/qbit_replay.rs +++ b/crates/librtbit/src/http_api/handlers/qbit_replay.rs @@ -38,6 +38,8 @@ const FIXTURE_TORRENT: &[u8] = include_bytes!("qbit_replay/fixture.torrent"); const FIXTURES: &[(&str, &str)] = &[ ("sonarr", include_str!("qbit_replay/sonarr.json")), + ("radarr", include_str!("qbit_replay/radarr.json")), + ("lidarr", include_str!("qbit_replay/lidarr.json")), ( "qbittorrent_api", include_str!("qbit_replay/qbittorrent_api.json"), @@ -380,11 +382,23 @@ async fn replay_sonarr() { } #[tokio::test] -async fn replay_qbittorrent_api() { +async fn replay_radarr() { let (name, raw) = FIXTURES[1]; replay(&parse(name, raw)).await; } +#[tokio::test] +async fn replay_lidarr() { + let (name, raw) = FIXTURES[2]; + replay(&parse(name, raw)).await; +} + +#[tokio::test] +async fn replay_qbittorrent_api() { + let (name, raw) = FIXTURES[3]; + replay(&parse(name, raw)).await; +} + #[test] fn fixture_status_codes_are_valid() { for (name, raw) in FIXTURES { diff --git a/crates/librtbit/src/http_api/handlers/qbit_replay/lidarr.json b/crates/librtbit/src/http_api/handlers/qbit_replay/lidarr.json new file mode 100644 index 00000000..4e25f779 --- /dev/null +++ b/crates/librtbit/src/http_api/handlers/qbit_replay/lidarr.json @@ -0,0 +1,33 @@ +{ + "name": "lidarr", + "client": "Lidarr download-client proxy", + "notes": "Request sequence built from the current V2 proxy source: connection test, music add, post-import category, and delete.", + "steps": [ + { "name": "login", "method": "POST", "path": "/auth/login", + "form": { "username": "{{username}}", "password": "{{password}}" }, + "live_only": true, "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "detect v2 API", "method": "GET", "path": "/app/version", + "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "read API version", "method": "GET", "path": "/app/webapiVersion", + "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "create imported category", "method": "POST", "path": "/torrents/createCategory", + "form": { "category": "lidarr-imported", "savePath": "" }, + "expect": { "status": 200 } }, + { "name": "add album", "method": "POST", "path": "/torrents/add", + "multipart": { "torrent": true, "fields": { + "category": "lidarr", "stopped": "true" } }, + "expect": { "status": 200 } }, + { "name": "wait for album", "method": "GET", "path": "/torrents/info", + "query": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a" }, + "expect": { "status": 200, "eventually": true, "json": [ + { "path": "", "len": 1 }, + { "path": "0.category", "eq": "lidarr" } ] }, + "diff": { "values": ["0.hash", "0.name", "0.category"] } }, + { "name": "set imported category", "method": "POST", "path": "/torrents/setCategory", + "form": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a", "category": "lidarr-imported" }, + "expect": { "status": 200 } }, + { "name": "delete album", "method": "POST", "path": "/torrents/delete", + "form": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a", "deleteFiles": "true" }, + "expect": { "status": 200 } } + ] +} diff --git a/crates/librtbit/src/http_api/handlers/qbit_replay/radarr.json b/crates/librtbit/src/http_api/handlers/qbit_replay/radarr.json new file mode 100644 index 00000000..4633b019 --- /dev/null +++ b/crates/librtbit/src/http_api/handlers/qbit_replay/radarr.json @@ -0,0 +1,33 @@ +{ + "name": "radarr", + "client": "Radarr download-client proxy", + "notes": "Request sequence built from the current V2 proxy source: connection test, movie add, post-import category, and delete.", + "steps": [ + { "name": "login", "method": "POST", "path": "/auth/login", + "form": { "username": "{{username}}", "password": "{{password}}" }, + "live_only": true, "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "detect v2 API", "method": "GET", "path": "/app/version", + "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "read API version", "method": "GET", "path": "/app/webapiVersion", + "expect": { "status": 200 }, "diff": { "body": "ignore" } }, + { "name": "create imported category", "method": "POST", "path": "/torrents/createCategory", + "form": { "category": "radarr-imported", "savePath": "" }, + "expect": { "status": 200 } }, + { "name": "add movie", "method": "POST", "path": "/torrents/add", + "multipart": { "torrent": true, "fields": { + "category": "radarr", "stopped": "true" } }, + "expect": { "status": 200 } }, + { "name": "wait for movie", "method": "GET", "path": "/torrents/info", + "query": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a" }, + "expect": { "status": 200, "eventually": true, "json": [ + { "path": "", "len": 1 }, + { "path": "0.category", "eq": "radarr" } ] }, + "diff": { "values": ["0.hash", "0.name", "0.category"] } }, + { "name": "set imported category", "method": "POST", "path": "/torrents/setCategory", + "form": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a", "category": "radarr-imported" }, + "expect": { "status": 200 } }, + { "name": "delete movie", "method": "POST", "path": "/torrents/delete", + "form": { "hashes": "cffa828a1727e7fdc662fd07329aedc10105a74a", "deleteFiles": "true" }, + "expect": { "status": 200 } } + ] +} diff --git a/crates/librtbit/src/http_api/handlers/torrents.rs b/crates/librtbit/src/http_api/handlers/torrents.rs index 151366e0..4bb3fddd 100644 --- a/crates/librtbit/src/http_api/handlers/torrents.rs +++ b/crates/librtbit/src/http_api/handlers/torrents.rs @@ -22,7 +22,7 @@ use crate::api::{ use crate::{ AddTorrent, ApiError, CreateTorrentOptions, SUPPORTED_SCHEMES, api::{ApiTorrentListOpts, Result, TorrentIdOrHash}, - api_error::WithStatusError, + api_error::{WithStatus, WithStatusError}, http_api::timeout::Timeout, http_api_types::TorrentAddQueryParams, torrent_state::peer::stats::snapshot::{PeerStatsFilter, PeerStatsFilterState}, @@ -587,6 +587,37 @@ pub async fn h_torrent_file_set_included( .map(axum::Json) } +#[derive(Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct SetFilePriorityRequest { + priority: u8, +} + +#[cfg_attr(feature = "swagger", utoipa::path( + post, + path = "/torrents/{id}/files/{index}/priority", + params( + ("id" = String, Path, description = "Torrent ID or info hash"), + ("index" = usize, Path, description = "File index"), + ), + request_body(content = SetFilePriorityRequest, description = "File priority: 0, 1, 6, or 7"), + responses( + (status = 200, description = "File priority updated", body = EmptyJsonResponse), + (status = 400, description = "File index or priority is invalid") + ) +))] +pub async fn h_torrent_file_set_priority( + State(state): State, + Path((idx, file_index)): Path<(TorrentIdOrHash, usize)>, + axum::Json(req): axum::Json, +) -> Result { + state + .api + .api_torrent_file_set_priority(idx, file_index, req.priority) + .await + .map(axum::Json) +} + #[cfg_attr(feature = "swagger", utoipa::path( get, path = "/stats", @@ -814,6 +845,12 @@ pub async fn h_create_torrent( pub struct CreateOrEditCategoryRequest { pub name: String, pub save_path: Option, + #[serde(default)] + pub ratio_limit: Option, + #[serde(default)] + pub seeding_time_limit: Option, + #[serde(default)] + pub inactive_seeding_time_limit: Option, } #[derive(Deserialize)] @@ -821,6 +858,107 @@ pub struct SetTorrentCategoryRequest { pub category: Option, } +#[derive(Deserialize)] +pub struct BulkCategoryRequest { + pub ids: Vec, + pub category: Option, +} + +#[derive(Deserialize)] +pub struct BulkTagsRequest { + pub ids: Vec, + pub tags: Vec, +} + +#[derive(Serialize)] +pub struct BulkEditFailure { + pub id: u64, + pub error: String, +} + +#[derive(Serialize)] +pub struct BulkEditResponse { + pub failures: Vec, +} + +pub async fn h_bulk_set_torrent_category( + State(state): State, + axum::Json(req): axum::Json, +) -> impl IntoResponse { + let mut failures = Vec::new(); + for raw_id in req.ids { + let id = match usize::try_from(raw_id) { + Ok(id) => id, + Err(_) => { + return (StatusCode::BAD_REQUEST, "torrent id is out of range").into_response(); + } + }; + if let Err(error) = state + .api + .api_set_torrent_category(TorrentIdOrHash::Id(id), req.category.clone()) + .await + { + failures.push(BulkEditFailure { + id: raw_id, + error: format!("{error:#}"), + }); + } + } + axum::Json(BulkEditResponse { failures }).into_response() +} + +pub async fn h_bulk_add_torrent_tags( + State(state): State, + axum::Json(req): axum::Json, +) -> impl IntoResponse { + let mut failures = Vec::new(); + for raw_id in req.ids { + let id = match usize::try_from(raw_id) { + Ok(id) => id, + Err(_) => { + return (StatusCode::BAD_REQUEST, "torrent id is out of range").into_response(); + } + }; + if let Err(error) = state + .api + .api_add_torrent_tags(TorrentIdOrHash::Id(id), &req.tags) + .await + { + failures.push(BulkEditFailure { + id: raw_id, + error: format!("{error:#}"), + }); + } + } + axum::Json(BulkEditResponse { failures }).into_response() +} + +pub async fn h_bulk_remove_torrent_tags( + State(state): State, + axum::Json(req): axum::Json, +) -> impl IntoResponse { + let mut failures = Vec::new(); + for raw_id in req.ids { + let id = match usize::try_from(raw_id) { + Ok(id) => id, + Err(_) => { + return (StatusCode::BAD_REQUEST, "torrent id is out of range").into_response(); + } + }; + if let Err(error) = state + .api + .api_remove_torrent_tags(TorrentIdOrHash::Id(id), &req.tags) + .await + { + failures.push(BulkEditFailure { + id: raw_id, + error: format!("{error:#}"), + }); + } + } + axum::Json(BulkEditResponse { failures }).into_response() +} + pub async fn h_list_categories(State(state): State) -> impl IntoResponse { axum::Json(state.api.api_list_categories()) } @@ -831,7 +969,17 @@ pub async fn h_create_or_edit_category( ) -> Result { state .api - .api_create_or_edit_category(req.name, req.save_path) + .api_create_or_edit_category( + req.name, + req.save_path, + crate::session::ShareLimits { + max_ratio: req.ratio_limit, + max_seeding_time: req.seeding_time_limit.map(std::time::Duration::from_secs), + max_inactive_seeding_time: req + .inactive_seeding_time_limit + .map(std::time::Duration::from_secs), + }, + ) .await .map(axum::Json) } @@ -855,6 +1003,48 @@ pub async fn h_set_torrent_category( .map(axum::Json) } +#[derive(Deserialize)] +pub struct SetTorrentLocationRequest { + pub location: String, +} + +pub async fn h_set_torrent_location( + State(state): State, + Path(idx): Path, + axum::Json(req): axum::Json, +) -> Result { + let location = req.location.trim(); + if location.is_empty() { + return Err(anyhow::anyhow!("location is required").into()); + } + let handle = state.api.mgr_handle(idx)?; + handle + .set_location(PathBuf::from(location)) + .with_status(StatusCode::CONFLICT)?; + Ok(axum::Json(crate::api::EmptyJsonResponse::default())) +} + +#[derive(Deserialize)] +pub struct RenameTorrentRequest { + pub name: String, +} + +pub async fn h_rename_torrent( + State(state): State, + Path(idx): Path, + axum::Json(req): axum::Json, +) -> Result { + let name = req.name.trim(); + if name.is_empty() { + return Err(anyhow::anyhow!("name is required").into()); + } + state + .api + .mgr_handle(idx)? + .set_display_name(Some(name.to_owned())); + Ok(axum::Json(crate::api::EmptyJsonResponse::default())) +} + pub async fn h_get_torrent_rate_limits( State(state): State, Path(idx): Path, diff --git a/crates/librtbit/src/http_api/mod.rs b/crates/librtbit/src/http_api/mod.rs index bd9d6c8d..8fb4cb81 100644 --- a/crates/librtbit/src/http_api/mod.rs +++ b/crates/librtbit/src/http_api/mod.rs @@ -89,7 +89,7 @@ struct ApiDoc; /// An HTTP server for the API. pub struct HttpApi { api: Api, - opts: HttpApiOptions, + pub(crate) opts: HttpApiOptions, login_limiter: auth::LoginLimiter, } @@ -115,6 +115,7 @@ pub struct HttpApiOptions { pub rss_db: Option>>, /// RSS feed history limit (max items to keep). None = unlimited, default 500. pub rss_history_limit: Option, + pub shutdown_token: Option, } /// Constant-time byte comparison to prevent timing attacks on auth credentials. diff --git a/crates/librtbit/src/lib.rs b/crates/librtbit/src/lib.rs index 628a5296..954ad74a 100644 --- a/crates/librtbit/src/lib.rs +++ b/crates/librtbit/src/lib.rs @@ -73,6 +73,10 @@ pub mod rss; mod session; mod session_persistence; pub use session_persistence::TorrentRateLimits; +pub use session_persistence::missing_report::{ + MissingFileReport, MissingFileStatus, MissingTorrentDiagnosis, MissingTorrentReport, + report_missing_files, +}; pub mod session_stats; pub mod spawn_utils; pub mod tag; diff --git a/crates/librtbit/src/piece_tracker.rs b/crates/librtbit/src/piece_tracker.rs index 65c2a9b9..6f3fd493 100644 --- a/crates/librtbit/src/piece_tracker.rs +++ b/crates/librtbit/src/piece_tracker.rs @@ -427,6 +427,39 @@ mod tests { assert_eq!(picked, vec![0, 3, 1, 2, 4, 6, 5]); } + #[test] + fn test_file_priority_order_picks_max_before_high_and_normal() { + let (mut tracker, file_infos) = make_two_file_tracker(2, 2); + let file_priorities = vec![1, 7]; + let mut ordered_files = (0..file_infos.len()).collect::>(); + ordered_files.sort_unstable_by_key(|id| std::cmp::Reverse(file_priorities[*id])); + let picked = { + let mut picked = Vec::new(); + loop { + match tracker.acquire_piece(AcquireRequest { + peer: peer(1), + peer_avg_time: None, + priority_pieces: std::iter::empty(), + file_priorities: &ordered_files, + file_infos: &file_infos, + order: PieceOrder { + sequential: true, + first_last_piece_priority: false, + }, + peer_has_piece: |_| true, + can_steal: |_| false, + }) { + AcquireResult::Reserved(piece) => picked.push(piece.get()), + AcquireResult::NoneAvailable => break picked, + other => panic!("unexpected {other:?}"), + } + } + }; + assert_eq!(file_priorities, vec![1, 7]); + assert_eq!(ordered_files, vec![1, 0]); + assert_eq!(picked, vec![2, 3, 0, 1]); + } + #[test] fn test_sequential_order() { let (mut tracker, file_infos) = make_two_file_tracker(4, 3); diff --git a/crates/librtbit/src/session/mod.rs b/crates/librtbit/src/session/mod.rs index e66c4de7..e413d21e 100644 --- a/crates/librtbit/src/session/mod.rs +++ b/crates/librtbit/src/session/mod.rs @@ -755,7 +755,7 @@ impl Session { on_complete: RwLock::new(OnCompleteHook::default()), max_active_downloads: AtomicUsize::new(max_active_downloads), max_active_seeds: AtomicUsize::new(max_active_seeds), - min_free_disk_space: std::sync::atomic::AtomicU64::new(0), + min_free_disk_space: std::sync::atomic::AtomicU64::new(scheduler::DEFAULT_MIN_FREE_DISK_SPACE), free_space_provider: RwLock::new(None), use_part_files: std::sync::atomic::AtomicBool::new(use_part_files), seed_limits: RwLock::new(seed_limits), @@ -1293,6 +1293,21 @@ impl Session { opts.only_files_regex, opts.list_only, )?; + let file_count = metadata.info.iter_file_lengths().count(); + let mut file_priority_levels = opts + .file_priority_levels + .take() + .unwrap_or_else(|| vec![1; file_count]); + if file_priority_levels.len() != file_count { + file_priority_levels.resize(file_count, 1); + } + if let Some(only_files) = &only_files { + for (idx, priority) in file_priority_levels.iter_mut().enumerate() { + if !only_files.contains(&idx) { + *priority = 0; + } + } + } let default_output_folder = expand_folder_template(&self.output_folder.read().clone(), opts.category.as_deref()); @@ -1399,6 +1414,9 @@ impl Session { tags: RwLock::new(crate::tag::normalize_tags(&opts.tags)), ratelimit_override: RwLock::new(opts.ratelimits), name_override: RwLock::new(None), + comment_override: RwLock::new(None), + auto_management: std::sync::atomic::AtomicBool::new(false), + super_seeding: std::sync::atomic::AtomicBool::new(false), output_folder_override: RwLock::new(output_folder), tracker_status, added_on: opts.added_on.unwrap_or_else(|| { @@ -1427,6 +1445,7 @@ impl Session { minfo.clone(), metadata.clone(), only_files.clone(), + file_priority_levels.clone(), self.spawner .block_in_place(|| minfo.storage_factory.create_and_init(&minfo, &metadata))?, false, @@ -1436,6 +1455,7 @@ impl Session { paused: opts.paused, state: ManagedTorrentState::Initializing(initializing), only_files, + file_priority_levels, }), state_change_notify: Notify::new(), shared: minfo, diff --git a/crates/librtbit/src/session/scheduler.rs b/crates/librtbit/src/session/scheduler.rs index 140db4e7..4f2373d2 100644 --- a/crates/librtbit/src/session/scheduler.rs +++ b/crates/librtbit/src/session/scheduler.rs @@ -115,6 +115,32 @@ pub(crate) enum ShareLimitReason { /// `seeding_secs` is zero when the completion time is unknown, which disables /// the seeding-time check rather than tripping it. `inactive_secs` is the time /// since the upload counter last moved, and zero likewise disables its check. +fn effective_share_limits_for_torrent( + session: &Session, + handle: &ManagedTorrentHandle, + own: ShareLimits, +) -> ShareLimits { + if !own.is_unset() { + return own; + } + let category = handle.shared().category.read().clone(); + category + .as_deref() + .and_then(|name| session.categories().get(name)) + .map(|category| category.share_limits()) + .map(|category| { + let global = *session.seed_limits.read(); + ShareLimits { + max_ratio: category.max_ratio.or(global.max_ratio), + max_seeding_time: category.max_seeding_time.or(global.max_seeding_time), + max_inactive_seeding_time: category + .max_inactive_seeding_time + .or(global.max_inactive_seeding_time), + } + }) + .unwrap_or_else(|| *session.seed_limits.read()) +} +#[cfg(test)] pub(crate) fn effective_share_limits(own: ShareLimits, global: ShareLimits) -> ShareLimits { if own.is_unset() { global } else { own } } @@ -298,6 +324,9 @@ fn os_free_disk_space(path: &Path) -> Option { Some(stat.blocks_available() as u64 * stat.fragment_size() as u64) } +/// Default free-space floor for the low-disk guard: 1 GiB. +pub(crate) const DEFAULT_MIN_FREE_DISK_SPACE: u64 = 1 << 30; + /// Stand-in free-space reading for tests. `None` means "use the OS". pub(crate) type FreeSpaceFn = fn(&Path) -> Option; @@ -513,7 +542,7 @@ impl Session { && !handle.is_paused() { let own = *handle.shared().share_limits.read(); - let limits = effective_share_limits(own, *self.seed_limits.read()); + let limits = effective_share_limits_for_torrent(self, handle, own); let completed_at = handle.shared().completion_on.load(Ordering::Relaxed); let seeding_secs = if completed_at > 0 { now.saturating_sub(completed_at) @@ -647,6 +676,17 @@ mod tests { assert_eq!(effective_share_limits(own, global), own); } + #[test] + fn category_limits_fill_unset_global_fields() { + let category = limits(Some(1.5), Some(30)); + let global = limits(Some(2.0), Some(60)); + assert_eq!(category.max_ratio.or(global.max_ratio), Some(1.5)); + assert_eq!( + category.max_seeding_time.or(global.max_seeding_time), + Some(Duration::from_secs(30)) + ); + } + #[test] fn no_limits_never_triggers() { assert_eq!( diff --git a/crates/librtbit/src/session/types.rs b/crates/librtbit/src/session/types.rs index 9ad8e023..dd6c546a 100644 --- a/crates/librtbit/src/session/types.rs +++ b/crates/librtbit/src/session/types.rs @@ -115,6 +115,8 @@ pub struct AddTorrentOptions { /// An explicit list of file IDs to download. /// To see the file indices, run with "list_only". pub only_files: Option>, + #[serde(skip)] + pub file_priority_levels: Option>, /// Allow writing on top of existing files, including when resuming a torrent. /// You probably want to set it, however for safety it's not default. /// diff --git a/crates/librtbit/src/session_persistence/json.rs b/crates/librtbit/src/session_persistence/json.rs index bfaa81c8..e2d894b0 100644 --- a/crates/librtbit/src/session_persistence/json.rs +++ b/crates/librtbit/src/session_persistence/json.rs @@ -62,7 +62,13 @@ impl JsonSessionPersistenceStore { let mut rdr = tokio::io::BufReader::new(f); rdr.read_to_end(&mut buf).await?; - serde_json::from_reader(&buf[..]).context("error deserializing session database")? + match serde_json::from_slice(&buf) { + Ok(db) => db, + Err(e) => { + quarantine_corrupt(&db_filename, &e); + Default::default() + } + } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(), Err(e) => { @@ -106,19 +112,14 @@ impl JsonSessionPersistenceStore { trace!(?tmp_filename, "serialized DB as JSON"); // The database now holds the proxy URL, including its password, so the // file is owner-only the same way the other private config files are. - write_owner_only(&tmp_filename, &buf) - .with_context(|| format!("error writing {tmp_filename:?}"))?; - trace!(?tmp_filename, "wrote to temp file"); - - tokio::fs::rename(&tmp_filename, &self.db_filename) - .await - .context("error renaming persistence file")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&self.db_filename, std::fs::Permissions::from_mode(0o600)) - .with_context(|| format!("error restricting {:?}", self.db_filename))?; - } + atomic_write( + std::path::Path::new(&tmp_filename), + &self.db_filename, + &buf, + true, + ) + .await + .with_context(|| format!("error writing {:?}", self.db_filename))?; trace!(filename=?self.db_filename, "wrote persistence"); Ok(()) } @@ -170,6 +171,7 @@ impl JsonSessionPersistenceStore { // we don't serialize this here, but to a file instead. torrent_bytes: Default::default(), only_files: torrent.only_files().clone(), + file_priority_levels: Some(torrent.file_priority_levels()), is_paused: torrent.is_paused(), queued: policy.queued, held_for_disk: policy.held_for_disk, @@ -187,6 +189,15 @@ impl JsonSessionPersistenceStore { file_renames: torrent.renamed_files().into_iter().collect(), category: torrent.shared().category.read().clone(), tags: torrent.shared().tags.read().clone(), + comment: torrent.shared().comment_override.read().clone(), + auto_management: torrent + .shared() + .auto_management + .load(std::sync::atomic::Ordering::Relaxed), + super_seeding: torrent + .shared() + .super_seeding + .load(std::sync::atomic::Ordering::Relaxed), added_on: Some(torrent.shared().added_on), completion_on: match torrent .shared() @@ -276,20 +287,10 @@ impl BitVFactory for JsonSessionPersistenceStore { // Use a distinct .init_tmp suffix from the DiskBackedBitV flusher (which uses .bitv.tmp) // to avoid a race where an old flusher's rename steals our temp file before we rename it. let tmp_filename = format!("{}.init_tmp", filename.to_str().context("bug")?); - let mut dst = tokio::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&tmp_filename) - .await - .with_context(|| format!("error opening {tmp_filename:?}"))?; let data = crate::bitv::bitv_with_header(b.as_raw_slice()); - dst.write_all(&data) - .await - .with_context(|| format!("error writing bitslice to {tmp_filename:?}"))?; - tokio::fs::rename(&tmp_filename, &filename) + atomic_write(std::path::Path::new(&tmp_filename), &filename, &data, false) .await - .with_context(|| format!("error renaming {tmp_filename:?} to {filename:?}"))?; + .with_context(|| format!("error writing {filename:?}"))?; trace!(?filename, "stored initial check bitfield"); Ok(DiskBackedBitV::new(filename.clone(), self.spawner.clone()) .await @@ -408,9 +409,13 @@ impl SessionPersistenceStore for JsonSessionPersistenceStore { let mut buf = Vec::new(); let mut rdr = tokio::io::BufReader::new(f); rdr.read_to_end(&mut buf).await?; - let categories: HashMap = - serde_json::from_reader(&buf[..]).context("error deserializing categories")?; - Ok(categories) + match serde_json::from_slice(&buf) { + Ok(categories) => Ok(categories), + Err(e) => { + quarantine_corrupt(&filename, &e); + Ok(HashMap::new()) + } + } } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()), Err(e) => Err(e).context(format!("error opening categories file {filename:?}")), @@ -422,26 +427,11 @@ impl SessionPersistenceStore for JsonSessionPersistenceStore { categories: &HashMap, ) -> anyhow::Result<()> { let filename = self.categories_filename(); - let tmp_filename = format!("{}.tmp", filename.to_str().unwrap()); - let mut tmp = tokio::fs::OpenOptions::new() - .create(true) - .truncate(true) - .write(true) - .open(&tmp_filename) - .await - .with_context(|| format!("error opening {tmp_filename:?}"))?; - - let mut buf = Vec::new(); - serde_json::to_writer_pretty(&mut buf, categories) - .context("error serializing categories")?; - - tmp.write_all(&buf) - .await - .with_context(|| format!("error writing {tmp_filename:?}"))?; - - tokio::fs::rename(&tmp_filename, &filename) + let tmp_filename = filename.with_extension("json.tmp"); + let buf = serde_json::to_vec_pretty(categories).context("error serializing categories")?; + atomic_write(&tmp_filename, &filename, &buf, false) .await - .context("error renaming categories file")?; + .with_context(|| format!("error writing {filename:?}"))?; trace!(filename=?filename, "wrote categories"); Ok(()) } @@ -449,7 +439,13 @@ impl SessionPersistenceStore for JsonSessionPersistenceStore { async fn load_tags(&self) -> anyhow::Result> { let filename = self.tags_filename(); match tokio::fs::read(&filename).await { - Ok(buf) => serde_json::from_slice(&buf).context("error deserializing tags"), + Ok(buf) => match serde_json::from_slice(&buf) { + Ok(tags) => Ok(tags), + Err(e) => { + quarantine_corrupt(&filename, &e); + Ok(BTreeSet::new()) + } + }, Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeSet::new()), Err(e) => Err(e).context(format!("error reading tags file {filename:?}")), } @@ -459,35 +455,77 @@ impl SessionPersistenceStore for JsonSessionPersistenceStore { let filename = self.tags_filename(); let tmp_filename = filename.with_extension("json.tmp"); let buf = serde_json::to_vec_pretty(tags).context("error serializing tags")?; - tokio::fs::write(&tmp_filename, &buf) + atomic_write(&tmp_filename, &filename, &buf, false) .await - .with_context(|| format!("error writing {tmp_filename:?}"))?; - tokio::fs::rename(&tmp_filename, &filename) - .await - .context("error renaming tags file")?; + .with_context(|| format!("error writing {filename:?}"))?; trace!(filename=?filename, "wrote tags"); Ok(()) } } -/// Write bytes to a new file that only the owner can read. Used for the session -/// database because it stores the proxy URL with its password. -fn write_owner_only(path: &str, bytes: &[u8]) -> anyhow::Result<()> { - if let Some(parent) = std::path::Path::new(path).parent() { - std::fs::create_dir_all(parent)?; - } - let _ = std::fs::remove_file(path); - let mut options = std::fs::OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options.open(path)?; - use std::io::Write; - file.write_all(bytes)?; - file.sync_all()?; - Ok(()) +/// Crash-safe replace of `dest`: write `tmp`, fsync it, rename over `dest`, +/// then fsync the parent directory so the rename itself is durable. +async fn atomic_write( + tmp: &std::path::Path, + dest: &std::path::Path, + bytes: &[u8], + owner_only: bool, +) -> anyhow::Result<()> { + let (tmp, dest, bytes) = (tmp.to_owned(), dest.to_owned(), bytes.to_vec()); + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + use std::io::Write; + let _ = std::fs::remove_file(&tmp); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if owner_only { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let _ = owner_only; + let mut file = options + .open(&tmp) + .with_context(|| format!("error opening {tmp:?}"))?; + file.write_all(&bytes) + .with_context(|| format!("error writing {tmp:?}"))?; + file.sync_all() + .with_context(|| format!("error syncing {tmp:?}"))?; + drop(file); + std::fs::rename(&tmp, &dest) + .with_context(|| format!("error renaming {tmp:?} to {dest:?}"))?; + #[cfg(unix)] + if let Some(parent) = dest.parent() { + let parent = if parent.as_os_str().is_empty() { + std::path::Path::new(".") + } else { + parent + }; + std::fs::File::open(parent) + .and_then(|d| d.sync_all()) + .with_context(|| format!("error syncing directory {parent:?}"))?; + } + Ok(()) + }) + .await + .context("atomic write task failed")? +} + +/// Move an unparseable state file aside as `.corrupt` and warn, so the +/// daemon starts with empty state instead of refusing to start. +fn quarantine_corrupt(path: &std::path::Path, err: &dyn std::fmt::Display) { + let mut name = path.as_os_str().to_owned(); + name.push(".corrupt"); + let corrupt = PathBuf::from(name); + match std::fs::rename(path, &corrupt) { + Ok(()) => warn!( + ?path, + ?corrupt, + "corrupt state file ({err}); quarantined, starting with empty state" + ), + Err(re) => warn!( + ?path, + "corrupt state file ({err}); could not quarantine: {re}; starting with empty state" + ), + } } #[cfg(test)] @@ -575,6 +613,28 @@ mod tests { assert_eq!(again.load_session_settings().await.unwrap().proxy_url, None); } + #[tokio::test] + async fn truncated_state_files_are_quarantined() { + let dir = tempfile::tempdir().unwrap(); + let spawner = BlockingSpawner::new(1); + for name in ["tags.json", "categories.json", "session.json"] { + std::fs::write(dir.path().join(name), b"{\"trunc").unwrap(); + } + let store = JsonSessionPersistenceStore::new(dir.path().to_path_buf(), spawner) + .await + .unwrap(); + assert!(store.load_tags().await.unwrap().is_empty()); + assert!(store.load_categories().await.unwrap().is_empty()); + assert!(store.load_session_settings().await.is_ok()); + for name in ["tags.json", "categories.json", "session.json"] { + assert!(!dir.path().join(name).exists(), "{name} should be moved"); + assert!(dir.path().join(format!("{name}.corrupt")).exists()); + } + let tags: BTreeSet = ["a".to_owned()].into(); + store.store_tags(&tags).await.unwrap(); + assert_eq!(store.load_tags().await.unwrap(), tags); + } + #[tokio::test] async fn json_store_persists_tag_registry() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/librtbit/src/session_persistence/missing_report.rs b/crates/librtbit/src/session_persistence/missing_report.rs new file mode 100644 index 00000000..ed6fc89e --- /dev/null +++ b/crates/librtbit/src/session_persistence/missing_report.rs @@ -0,0 +1,221 @@ +use std::{collections::HashMap, path::Path}; + +use anyhow::{Context, bail}; +use librtbit_core::torrent_metainfo::torrent_from_bytes; +use serde::{Deserialize, Serialize}; + +use super::SerializedTorrent; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MissingFileStatus { + Exists, + WrongSize { actual_size: u64 }, + Absent, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum MissingTorrentDiagnosis { + PathProblem, + MissingPayload, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct MissingFileReport { + pub path: std::path::PathBuf, + pub expected_size: u64, + pub parent_dir_exists: bool, + pub status: MissingFileStatus, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct MissingTorrentReport { + pub id: usize, + pub info_hash: String, + pub name: String, + pub save_path: std::path::PathBuf, + pub diagnosis: MissingTorrentDiagnosis, + pub files: Vec, +} + +#[derive(Deserialize)] +struct SessionDatabase { + torrents: HashMap, +} + +pub async fn report_missing_files( + persistence_folder: &Path, +) -> anyhow::Result> { + let session_file = persistence_folder.join("session.json"); + let bytes = tokio::fs::read(&session_file) + .await + .with_context(|| format!("error reading session state {}", session_file.display()))?; + let database: SessionDatabase = serde_json::from_slice(&bytes) + .with_context(|| format!("error parsing session state {}", session_file.display()))?; + + let mut torrents = database.torrents.into_iter().collect::>(); + torrents.sort_by_key(|(id, _)| *id); + let mut reports = Vec::new(); + for (id, torrent) in torrents { + let info_hash = torrent.info_hash.as_string(); + let metainfo_file = persistence_folder.join(format!("{info_hash}.torrent")); + let metainfo_bytes = tokio::fs::read(&metainfo_file) + .await + .with_context(|| format!("error reading metainfo {}", metainfo_file.display()))?; + let metainfo = torrent_from_bytes(&metainfo_bytes) + .with_context(|| format!("error parsing metainfo {}", metainfo_file.display()))?; + let metainfo = metainfo + .info + .data + .validate() + .context("invalid torrent metainfo")?; + let name = metainfo + .name() + .unwrap_or_else(|| "".into()) + .into_owned(); + let mut files = Vec::new(); + let mut has_missing = false; + let mut has_missing_parent = false; + + for (file_id, details) in metainfo.iter_file_details().enumerate() { + if torrent + .only_files + .as_ref() + .is_some_and(|selected| !selected.contains(&file_id)) + { + continue; + } + let relative_path = torrent + .file_renames + .get(&file_id) + .cloned() + .unwrap_or_else(|| details.filename.to_pathbuf()); + if relative_path.is_absolute() { + bail!("saved file path is absolute: {}", relative_path.display()); + } + let path = torrent.output_folder.join(relative_path); + let parent_dir_exists = path.parent().is_some_and(Path::is_dir); + let status = match tokio::fs::metadata(&path).await { + Ok(metadata) if metadata.is_file() && metadata.len() == details.len => { + MissingFileStatus::Exists + } + Ok(metadata) if metadata.is_file() => MissingFileStatus::WrongSize { + actual_size: metadata.len(), + }, + Ok(_) => MissingFileStatus::WrongSize { actual_size: 0 }, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + MissingFileStatus::Absent + } + Err(error) => { + return Err(error) + .with_context(|| format!("error inspecting {}", path.display())); + } + }; + if status != MissingFileStatus::Exists { + has_missing = true; + has_missing_parent |= !parent_dir_exists; + } + files.push(MissingFileReport { + path, + expected_size: details.len, + parent_dir_exists, + status, + }); + } + + if has_missing { + reports.push(MissingTorrentReport { + id, + info_hash, + name, + save_path: torrent.output_folder, + diagnosis: if has_missing_parent { + MissingTorrentDiagnosis::PathProblem + } else { + MissingTorrentDiagnosis::MissingPayload + }, + files, + }); + } + } + Ok(reports) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CreateTorrentOptions, create_torrent, spawn_utils::BlockingSpawner}; + + #[tokio::test] + async fn reports_file_statuses_and_path_problem() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join("source"); + std::fs::create_dir(&source).unwrap(); + for (name, size) in [ + ("exists", 4), + ("wrong", 5), + ("absent", 6), + ("nested/missing", 7), + ] { + let path = source.join(name); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, vec![b'x'; size]).unwrap(); + } + let torrent = create_torrent( + &source, + CreateTorrentOptions::default(), + &BlockingSpawner::new(1), + ) + .await + .unwrap() + .as_bytes() + .unwrap() + .to_vec(); + let metadata = torrent_from_bytes(&torrent).unwrap(); + let info_hash = metadata.info_hash.as_string(); + let persistence = temp.path().join("state"); + let payload = temp.path().join("payload"); + std::fs::create_dir_all(&persistence).unwrap(); + std::fs::create_dir_all(&payload).unwrap(); + std::fs::write(payload.join("exists"), b"xxxx").unwrap(); + std::fs::write(payload.join("wrong"), b"x").unwrap(); + std::fs::write(persistence.join(format!("{info_hash}.torrent")), &torrent).unwrap(); + let session = serde_json::json!({ + "torrents": { + "1": { + "info_hash": info_hash, + "trackers": [], + "output_folder": payload, + "only_files": null, + "is_paused": true + } + } + }); + std::fs::write( + persistence.join("session.json"), + serde_json::to_vec(&session).unwrap(), + ) + .unwrap(); + + let reports = report_missing_files(&persistence).await.unwrap(); + assert_eq!(reports.len(), 1); + assert_eq!(reports[0].diagnosis, MissingTorrentDiagnosis::PathProblem); + assert_eq!(reports[0].files.len(), 4); + let status_for = |name: &str| { + reports[0] + .files + .iter() + .find(|file| file.path.ends_with(name)) + .unwrap() + }; + assert_eq!(status_for("absent").status, MissingFileStatus::Absent); + assert_eq!(status_for("exists").status, MissingFileStatus::Exists); + assert!(matches!( + status_for("wrong").status, + MissingFileStatus::WrongSize { actual_size: 1 } + )); + assert_eq!(status_for("missing").status, MissingFileStatus::Absent); + assert!(!status_for("missing").parent_dir_exists); + } +} diff --git a/crates/librtbit/src/session_persistence/mod.rs b/crates/librtbit/src/session_persistence/mod.rs index 32202f86..75ece3d0 100644 --- a/crates/librtbit/src/session_persistence/mod.rs +++ b/crates/librtbit/src/session_persistence/mod.rs @@ -1,4 +1,5 @@ pub mod json; +pub mod missing_report; #[cfg(feature = "postgres")] pub mod postgres; @@ -203,6 +204,8 @@ pub struct SerializedTorrent { #[serde(default, skip_serializing_if = "Option::is_none")] output_folder_root: Option, only_files: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + file_priority_levels: Option>, is_paused: bool, #[serde(default, skip_serializing_if = "Option::is_none")] category: Option, @@ -248,6 +251,12 @@ pub struct SerializedTorrent { /// means the paths from the info dict. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] file_renames: BTreeMap, + #[serde(default, skip_serializing_if = "Option::is_none")] + comment: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + auto_management: bool, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + super_seeding: bool, } /// The per-torrent speed limits kept in session state. `None` is unlimited. @@ -299,6 +308,9 @@ impl SerializedTorrent { force_start: self.force_start, sequential_download: self.sequential_download, first_last_piece_priority: self.first_last_piece_priority, + comment: self.comment.clone(), + auto_management: self.auto_management, + super_seeding: self.super_seeding, } } @@ -350,6 +362,7 @@ impl SerializedTorrent { output_folder, output_folder_root, only_files: self.only_files, + file_priority_levels: self.file_priority_levels, overwrite: true, category, tags, @@ -377,6 +390,9 @@ pub(crate) struct RestoredState { force_start: bool, sequential_download: bool, first_last_piece_priority: bool, + comment: Option, + auto_management: bool, + super_seeding: bool, } impl RestoredState { @@ -402,6 +418,13 @@ impl RestoredState { self.first_last_piece_priority, std::sync::atomic::Ordering::Relaxed, ); + *shared.comment_override.write() = self.comment; + shared + .auto_management + .store(self.auto_management, std::sync::atomic::Ordering::Relaxed); + shared + .super_seeding + .store(self.super_seeding, std::sync::atomic::Ordering::Relaxed); } } diff --git a/crates/librtbit/src/session_persistence/postgres.rs b/crates/librtbit/src/session_persistence/postgres.rs index 23237c70..340eacee 100644 --- a/crates/librtbit/src/session_persistence/postgres.rs +++ b/crates/librtbit/src/session_persistence/postgres.rs @@ -84,6 +84,9 @@ impl TorrentsTableRecord { is_paused: self.is_paused, category: None, tags: Default::default(), + comment: None, + auto_management: false, + super_seeding: false, added_on: None, completion_on: None, queued: self.queued, diff --git a/crates/librtbit/src/tests/policy_pass.rs b/crates/librtbit/src/tests/policy_pass.rs index 4a197080..9722196c 100644 --- a/crates/librtbit/src/tests/policy_pass.rs +++ b/crates/librtbit/src/tests/policy_pass.rs @@ -215,6 +215,11 @@ async fn disk_hold_survives_restart_and_releases() -> anyhow::Result<()> { session.policy_pass().await; assert!(download.is_paused()); assert!(download.shared().held_for_disk.load(Ordering::Relaxed)); + assert_eq!( + download.stats().error.as_deref(), + Some("low disk space"), + "native status must name the hold" + ); session.stop().await; drop(session); diff --git a/crates/librtbit/src/torrent_state/initializing.rs b/crates/librtbit/src/torrent_state/initializing.rs index f6fbbc0c..bd95ae0f 100644 --- a/crates/librtbit/src/torrent_state/initializing.rs +++ b/crates/librtbit/src/torrent_state/initializing.rs @@ -30,6 +30,7 @@ pub struct TorrentStateInitializing { pub(crate) shared: Arc, pub(crate) metadata: Arc, pub(crate) only_files: Option>, + pub(crate) file_priority_levels: Vec, pub(crate) checked_bytes: AtomicU64, /// True while the torrent is waiting for a concurrent init semaphore slot. pub(crate) queued_for_init: AtomicBool, @@ -44,6 +45,7 @@ impl TorrentStateInitializing { shared: Arc, metadata: Arc, only_files: Option>, + file_priority_levels: Vec, files: FileStorage, previously_errored: bool, ) -> Self { @@ -51,6 +53,7 @@ impl TorrentStateInitializing { shared, metadata, only_files, + file_priority_levels, files, checked_bytes: AtomicU64::new(0), queued_for_init: AtomicBool::new(false), @@ -327,6 +330,7 @@ impl TorrentStateInitializing { metadata: self.metadata.clone(), files: self.files.take()?, chunk_tracker, + file_priority_levels: self.file_priority_levels.clone(), streams: Arc::new(Default::default()), }) } diff --git a/crates/librtbit/src/torrent_state/live/mod.rs b/crates/librtbit/src/torrent_state/live/mod.rs index 988b4162..34909858 100644 --- a/crates/librtbit/src/torrent_state/live/mod.rs +++ b/crates/librtbit/src/torrent_state/live/mod.rs @@ -115,6 +115,7 @@ pub(crate) struct TorrentStateLocked { // The sorted file list in which order to download them. pub(crate) file_priorities: FilePriorities, + pub(crate) file_priority_levels: Vec, // If this is None, then it was already used fatal_errors_tx: Option>, @@ -255,11 +256,14 @@ impl TorrentStateLive { let mut pri = (0..paused.metadata.file_infos.len()).collect::>(); // sort by filename, cause many torrents have random sort order. pri.sort_unstable_by_key(|id| { - paused - .metadata - .file_infos - .get(*id) - .map(|fi| fi.relative_filename.as_path()) + ( + std::cmp::Reverse(paused.file_priority_levels.get(*id).copied().unwrap_or(1)), + paused + .metadata + .file_infos + .get(*id) + .map(|fi| fi.relative_filename.as_path()), + ) }); pri }; @@ -286,6 +290,7 @@ impl TorrentStateLive { _locked: RwLock::new(TorrentStateLocked { pieces: Some(PieceTracker::new(paused.chunk_tracker)), file_priorities, + file_priority_levels: paused.file_priority_levels, fatal_errors_tx: Some(fatal_errors_tx), unflushed_bitv_bytes: 0, }), @@ -624,6 +629,7 @@ impl TorrentStateLive { metadata: self.metadata.clone(), files: self.files.take()?, chunk_tracker, + file_priority_levels: g.file_priority_levels.clone(), streams: self.streams.clone(), }) } @@ -652,6 +658,43 @@ impl TorrentStateLive { Ok(()) } + pub(crate) fn update_file_priority( + &self, + file_index: usize, + priority: u8, + ) -> anyhow::Result<()> { + let mut g = self.lock_write("update_file_priority"); + let file_count = self.metadata.file_infos.len(); + if file_index >= file_count { + anyhow::bail!("file index out of range"); + } + if let Some(level) = g.file_priority_levels.get_mut(file_index) { + *level = priority; + } + let levels = g.file_priority_levels.clone(); + g.file_priorities.sort_unstable_by_key(|id| { + ( + std::cmp::Reverse(levels.get(*id).copied().unwrap_or(1)), + self.metadata + .file_infos + .get(*id) + .map(|fi| fi.relative_filename.as_path()), + ) + }); + let selected: HashSet<_> = levels + .iter() + .enumerate() + .filter_map(|(id, level)| (*level != 0).then_some(id)) + .collect(); + let hns = g + .get_pieces_mut()? + .update_only_files(&self.metadata.file_infos, &selected)?; + if !hns.finished() { + self.reconnect_all_not_needed_peers(); + } + Ok(()) + } + // If we have all selected pieces but not necessarily all pieces. pub(crate) fn is_finished(&self) -> bool { self.get_hns().map(|h| h.finished()).unwrap_or_default() diff --git a/crates/librtbit/src/torrent_state/live/peers/mod.rs b/crates/librtbit/src/torrent_state/live/peers/mod.rs index f70acd93..1767b4f6 100644 --- a/crates/librtbit/src/torrent_state/live/peers/mod.rs +++ b/crates/librtbit/src/torrent_state/live/peers/mod.rs @@ -54,6 +54,22 @@ impl PeerStates { }) } + /// Number of live peers that have each piece, from their advertised bitfields. + pub fn piece_availability(&self, total_pieces: usize) -> Vec { + let mut counts = vec![0u32; total_pieces]; + for entry in self.states.iter() { + let Some(live) = entry.get_live() else { + continue; + }; + for (count, has) in counts.iter_mut().zip(live.bitfield.iter()) { + if *has { + *count += 1; + } + } + } + counts + } + pub fn add_if_not_seen(&self, addr: SocketAddr) -> Option { use dashmap::mapref::entry::Entry; match self.states.entry(addr) { @@ -248,6 +264,30 @@ mod tests { SocketAddr::new(Ipv4Addr::new(127, 0, 0, 1).into(), port) } + #[test] + fn test_piece_availability_counts_peer_bitfields() { + let ps = make_peer_states(); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + for (port, bits) in [(1u16, 0b1010_0000u8), (2, 0b1100_0000)] { + let peer = Peer::new_live_for_incoming_connection( + addr(port), + Default::default(), + tx.clone(), + &ps, + crate::stream_connect::ConnectionKind::Tcp, + ); + ps.states.insert(addr(port), peer); + ps.update_bitfield( + addr(port), + BF::from_boxed_slice(vec![bits].into_boxed_slice()), + ); + } + // A queued peer without a live connection contributes nothing. + ps.add_if_not_seen(addr(3)); + assert_eq!(ps.piece_availability(4), vec![2, 1, 1, 0]); + assert_eq!(PeerStates::piece_availability(&ps, 0), Vec::::new()); + } + #[test] fn test_dead_peer_pruning_removes_stale_dead_peers() { let ps = make_peer_states(); diff --git a/crates/librtbit/src/torrent_state/mod.rs b/crates/librtbit/src/torrent_state/mod.rs index b6f88baf..7e7a5fe2 100644 --- a/crates/librtbit/src/torrent_state/mod.rs +++ b/crates/librtbit/src/torrent_state/mod.rs @@ -74,6 +74,9 @@ pub enum ManagedTorrentState { None, } +/// Status error shown while the low-disk guard holds a download. +pub const LOW_DISK_SPACE: &str = "low disk space"; + impl ManagedTorrentState { pub fn name(&self) -> &'static str { match self { @@ -110,6 +113,7 @@ pub(crate) struct ManagedTorrentLocked { pub(crate) paused: bool, pub(crate) state: ManagedTorrentState, pub(crate) only_files: Option>, + pub(crate) file_priority_levels: Vec, } #[derive(Default)] @@ -284,6 +288,9 @@ pub struct ManagedTorrentShared { /// precedence over the metadata/magnet name. `None` = use the torrent's /// own name. Not persisted across restarts. pub(crate) name_override: RwLock>, + pub(crate) comment_override: RwLock>, + pub(crate) auto_management: AtomicBool, + pub(crate) super_seeding: AtomicBool, /// Current storage root, mutable at runtime so a whole-torrent relocation /// (`set_location`) can re-anchor storage. Seeded from @@ -531,6 +538,10 @@ impl ManagedTorrent { self.locked.read().only_files.clone() } + pub fn file_priority_levels(&self) -> Vec { + self.locked.read().file_priority_levels.clone() + } + /// Current per-torrent speed limits (the runtime override). `None` bps means /// unlimited in that direction. pub fn rate_limits(&self) -> LimitsConfig { @@ -780,6 +791,7 @@ impl ManagedTorrent { t.shared.clone(), metadata.clone(), g.only_files.clone(), + g.file_priority_levels.clone(), t.shared .storage_factory .create_and_init(t.shared(), &metadata)?, @@ -914,6 +926,7 @@ impl ManagedTorrent { self.shared.clone(), metadata, locked.only_files.clone(), + locked.file_priority_levels.clone(), files, true, ))); @@ -960,9 +973,13 @@ impl ManagedTorrent { // and only while it is still the current initializer. self.shared.cancel_and_reset_token(); let files = init.files.take(); - g.state = match files - .and_then(|f| self.new_deferred_initializing(g.only_files.clone(), f)) - { + g.state = match files.and_then(|f| { + self.new_deferred_initializing( + g.only_files.clone(), + g.file_priority_levels.clone(), + f, + ) + }) { Ok(init) => ManagedTorrentState::Initializing(init), Err(e) => ManagedTorrentState::Error( e.context("error keeping storage after stopping the check"), @@ -985,14 +1002,21 @@ impl ManagedTorrent { fn new_deferred_initializing( &self, only_files: Option>, + file_priority_levels: Vec, files: crate::type_aliases::FileStorage, ) -> anyhow::Result> { let metadata = self .metadata .load_full() .context("torrent metadata was not loaded")?; - let init = - TorrentStateInitializing::new(self.shared.clone(), metadata, only_files, files, false); + let init = TorrentStateInitializing::new( + self.shared.clone(), + metadata, + only_files, + file_priority_levels, + files, + false, + ); init.deferred.store(true, Ordering::Relaxed); Ok(Arc::new(init)) } @@ -1058,6 +1082,8 @@ impl ManagedTorrent { resp.file_progress = p.chunk_tracker.per_file_have_bytes().to_owned(); if p.files.is_read_only() && !resp.finished { resp.error = Some(READ_ONLY_INCOMPLETE_PAYLOAD.to_owned()); + } else if self.shared.held_for_disk.load(Ordering::Relaxed) { + resp.error = Some(LOW_DISK_SPACE.to_owned()); } } ManagedTorrentState::Live(l) => { @@ -1170,6 +1196,38 @@ impl ManagedTorrent { g.only_files = Some(only_files.iter().copied().collect()); Ok(()) } + + pub(crate) fn update_file_priority( + &self, + file_index: usize, + priority: u8, + ) -> anyhow::Result<()> { + let mut g = self.locked.write(); + if file_index >= g.file_priority_levels.len() { + anyhow::bail!("file index out of range"); + } + g.file_priority_levels[file_index] = priority; + let levels = g.file_priority_levels.clone(); + let selected = g + .file_priority_levels + .iter() + .enumerate() + .filter_map(|(id, level)| (*level != 0).then_some(id)) + .collect::>(); + match &mut g.state { + ManagedTorrentState::Paused(p) => { + p.file_priority_levels = levels; + p.update_only_files(&selected)?; + } + ManagedTorrentState::Live(l) => l.update_file_priority(file_index, priority)?, + ManagedTorrentState::Initializing(_) => { + anyhow::bail!("can't update initializing torrent") + } + ManagedTorrentState::Error(_) | ManagedTorrentState::None => {} + } + g.only_files = Some(selected.into_iter().collect()); + Ok(()) + } } pub type ManagedTorrentHandle = Arc; diff --git a/crates/librtbit/src/torrent_state/paused.rs b/crates/librtbit/src/torrent_state/paused.rs index 0bcda9f5..65337b9b 100644 --- a/crates/librtbit/src/torrent_state/paused.rs +++ b/crates/librtbit/src/torrent_state/paused.rs @@ -12,6 +12,7 @@ pub struct TorrentStatePaused { pub(crate) metadata: Arc, pub(crate) files: FileStorage, pub(crate) chunk_tracker: ChunkTracker, + pub(crate) file_priority_levels: Vec, pub(crate) streams: Arc, } diff --git a/crates/rtbit-client/src/client.rs b/crates/rtbit-client/src/client.rs index 52f24c1e..50d01907 100644 --- a/crates/rtbit-client/src/client.rs +++ b/crates/rtbit-client/src/client.rs @@ -149,6 +149,82 @@ impl Client { .await } + /// `POST /torrents/{id}/recheck`. Verifies the payload on disk again. + pub async fn recheck_torrent(&mut self, id: u64) -> Result<(), Error> { + self.authed_post_empty(&format!("torrents/{id}/recheck")) + .await + } + + /// `POST /torrents/{id}/set_category`. `None` clears the category. + pub async fn set_torrent_category( + &mut self, + id: u64, + category: Option<&str>, + ) -> Result<(), Error> { + self.authed_post_json( + &format!("torrents/{id}/set_category"), + &serde_json::json!({ "category": category }), + ) + .await + } + + /// `POST /torrents/{id}/set_location`. Moves the torrent payload to a new root. + pub async fn set_torrent_location(&mut self, id: u64, location: &str) -> Result<(), Error> { + self.authed_post_json( + &format!("torrents/{id}/set_location"), + &serde_json::json!({ "location": location }), + ) + .await + } + + /// `POST /torrents/{id}/rename`. Changes the torrent's display name. + pub async fn rename_torrent(&mut self, id: u64, name: &str) -> Result<(), Error> { + self.authed_post_json( + &format!("torrents/{id}/rename"), + &serde_json::json!({ "name": name }), + ) + .await + } + + /// Set or clear a category for several torrents in one request. + pub async fn set_torrents_category( + &mut self, + ids: &[u64], + category: Option<&str>, + ) -> Result { + self.authed_post_json_as( + "torrents/bulk/category", + &serde_json::json!({ "ids": ids, "category": category }), + ) + .await + } + + /// Add tags to several torrents in one request. + pub async fn add_torrents_tags( + &mut self, + ids: &[u64], + tags: &[String], + ) -> Result { + self.authed_post_json_as( + "torrents/bulk/tags/add", + &serde_json::json!({ "ids": ids, "tags": tags }), + ) + .await + } + + /// Remove tags from several torrents in one request. + pub async fn remove_torrents_tags( + &mut self, + ids: &[u64], + tags: &[String], + ) -> Result { + self.authed_post_json_as( + "torrents/bulk/tags/remove", + &serde_json::json!({ "ids": ids, "tags": tags }), + ) + .await + } + /// `POST /torrents/{id}/delete`. Removes the torrent and its files. pub async fn delete_torrent(&mut self, id: u64) -> Result<(), Error> { self.authed_post_empty(&format!("torrents/{id}/delete")) @@ -226,6 +302,20 @@ impl Client { .await } + /// `POST /torrents/{id}/files/{index}/priority`. + pub async fn set_file_priority( + &mut self, + id: u64, + index: usize, + priority: u8, + ) -> Result<(), Error> { + self.authed_post_json( + &format!("torrents/{id}/files/{index}/priority"), + &FilePriorityBody { priority }, + ) + .await + } + /// Read every route the settings screen shows. pub async fn settings(&mut self) -> Result { use crate::types::SettingsSnapshot; @@ -697,7 +787,10 @@ async fn decode_or_default( } async fn error_with_body(status: http::StatusCode, response: reqwest::Response) -> Error { - if status != http::StatusCode::BAD_REQUEST { + if !matches!( + status, + http::StatusCode::BAD_REQUEST | http::StatusCode::CONFLICT + ) { return Error::from_status(status); } let body = response.text().await.unwrap_or_default(); @@ -714,6 +807,11 @@ struct FileIncludedBody { included: bool, } +#[derive(Serialize)] +struct FilePriorityBody { + priority: u8, +} + #[derive(Serialize)] struct TrackersBody<'a> { trackers: &'a [String], diff --git a/crates/rtbit-client/src/error.rs b/crates/rtbit-client/src/error.rs index 2041eea6..3a2e54a7 100644 --- a/crates/rtbit-client/src/error.rs +++ b/crates/rtbit-client/src/error.rs @@ -13,7 +13,7 @@ pub enum Error { #[error("daemon is not set up")] NotSetUp, /// The daemon answered with an error status. - /// `message` is set only for a 400 whose body is a short, plain validation + /// `message` is set only for a 400 or 409 whose body is a short, plain validation /// sentence. It never carries a token or a raw response body. #[error("daemon error {status}")] Daemon { @@ -34,7 +34,7 @@ impl Error { } else if status == StatusCode::FORBIDDEN || status == StatusCode::NOT_FOUND { Self::NotSetUp } else { - let message = if status == StatusCode::BAD_REQUEST { + let message = if matches!(status, StatusCode::BAD_REQUEST | StatusCode::CONFLICT) { safe_validation_message(body) } else { None diff --git a/crates/rtbit-client/src/lib.rs b/crates/rtbit-client/src/lib.rs index c556b94a..11727f3a 100644 --- a/crates/rtbit-client/src/lib.rs +++ b/crates/rtbit-client/src/lib.rs @@ -13,12 +13,12 @@ pub use error::Error; pub use http::StatusCode; pub use types::{ AdvancedConfig, AdvancedSaved, AltSpeedConfig, AltSpeedSchedule, AltSpeedStatus, AuthStatus, - ConnectionFamily, ConnectionKindStats, Connections, DownloadsConfig, FoldersConfig, - IpFilterConfig, LiveSpeeds, NetworkConfig, NetworkSettings, PeerCounts, PeerList, PeerView, - PieceProgress, ProxyConfig, RateLimits, Role, RssFeed, RssItem, RssRule, RssRuleBody, - RssRuleEpisode, RssSnapshot, SavedNetworkStatus, SeedLimits, SessionCounters, SessionStats, - SettingsSnapshot, Speed, TokenPair, Torrent, TorrentFile, TorrentList, TorrentState, - TorrentStats, TrackerList, TrackerState, TrackerStatus, + BulkEditFailure, BulkEditResponse, ConnectionFamily, ConnectionKindStats, Connections, + DownloadsConfig, FoldersConfig, IpFilterConfig, LiveSpeeds, NetworkConfig, NetworkSettings, + PeerCounts, PeerList, PeerView, PieceProgress, ProxyConfig, RateLimits, Role, RssFeed, RssItem, + RssRule, RssRuleBody, RssRuleEpisode, RssSnapshot, SavedNetworkStatus, SeedLimits, + SessionCounters, SessionStats, SettingsSnapshot, Speed, TokenPair, Torrent, TorrentFile, + TorrentList, TorrentState, TorrentStats, TrackerList, TrackerState, TrackerStatus, }; /// Caps for `AdvancedConfig`, matching the daemon's limits. diff --git a/crates/rtbit-client/src/types.rs b/crates/rtbit-client/src/types.rs index 737c7b8f..e1029c34 100644 --- a/crates/rtbit-client/src/types.rs +++ b/crates/rtbit-client/src/types.rs @@ -223,6 +223,18 @@ pub struct Torrent { pub files: Option>, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct BulkEditFailure { + pub id: u64, + pub error: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)] +pub struct BulkEditResponse { + #[serde(default)] + pub failures: Vec, +} + /// One file inside `GET /torrents/{id}`. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct TorrentFile { @@ -232,6 +244,13 @@ pub struct TorrentFile { pub length: u64, /// Whether the file is in the download selection. pub included: bool, + /// qbit-compatible file priority: 0 skip, 1 normal, 6 high, 7 max. + #[serde(default = "default_file_priority")] + pub priority: u8, +} + +const fn default_file_priority() -> u8 { + 1 } /// Announce state of one tracker. The strings are what the daemon sends. diff --git a/crates/rtbit-client/tests/wire.rs b/crates/rtbit-client/tests/wire.rs index 77534e2c..04de69e3 100644 --- a/crates/rtbit-client/tests/wire.rs +++ b/crates/rtbit-client/tests/wire.rs @@ -799,6 +799,29 @@ async fn set_file_included_posts_one_file_and_surfaces_bad_index() { server.verify().await; } +#[tokio::test] +async fn set_file_priority_posts_level() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth/login")) + .respond_with( + ResponseTemplate::new(200).set_body_raw(fixture("login.json"), "application/json"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/torrents/7/files/2/priority")) + .and(body_json(serde_json::json!({"priority": 6}))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let mut client = Client::new(server.uri()).unwrap(); + client.login("ada", "secret").await.unwrap(); + client.set_file_priority(7, 2, 6).await.unwrap(); + server.verify().await; +} + #[test] fn advanced_decodes_legacy_and_full_bodies() { // An older daemon sends only the three timeouts. The new knobs read as None. @@ -994,3 +1017,60 @@ async fn set_limits_posts_peer_and_init_limits_to_the_limits_route() { client.set_limits(&limits).await.unwrap(); server.verify().await; } + +#[tokio::test] +async fn bulk_category_is_one_request_and_decodes_refused_torrents() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/torrents/bulk/category")) + .and(header("authorization", "Bearer access-1")) + .and(body_json(serde_json::json!({ + "ids": [3, 5], + "category": "movies" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "failures": [{"id": 5, "error": "torrent is read-only"}] + }))) + .expect(1) + .mount(&server) + .await; + let mut client = logged_in(&server).await; + let response = client + .set_torrents_category(&[3, 5], Some("movies")) + .await + .unwrap(); + assert_eq!(response.failures.len(), 1); + assert_eq!(response.failures[0].id, 5); + assert_eq!(response.failures[0].error, "torrent is read-only"); + server.verify().await; +} + +#[tokio::test] +async fn bulk_tag_add_and_remove_each_use_one_request() { + let server = MockServer::start().await; + for (route, tags) in [ + ("/torrents/bulk/tags/add", serde_json::json!(["new", "tv"])), + ("/torrents/bulk/tags/remove", serde_json::json!(["old"])), + ] { + Mock::given(method("POST")) + .and(path(route)) + .and(header("authorization", "Bearer access-1")) + .and(body_json(serde_json::json!({"ids": [3, 5], "tags": tags}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "failures": [] + }))) + .expect(1) + .mount(&server) + .await; + } + let mut client = logged_in(&server).await; + client + .add_torrents_tags(&[3, 5], &["new".into(), "tv".into()]) + .await + .unwrap(); + client + .remove_torrents_tags(&[3, 5], &["old".into()]) + .await + .unwrap(); + server.verify().await; +} diff --git a/crates/rtbit-fluent/Cargo.toml b/crates/rtbit-fluent/Cargo.toml index 25167b41..cf584a66 100644 --- a/crates/rtbit-fluent/Cargo.toml +++ b/crates/rtbit-fluent/Cargo.toml @@ -18,9 +18,9 @@ fluent-layout = { git = "https://github.com/thedancingdeveloper/fluent-gpui", ta fluent-primitives = { git = "https://github.com/thedancingdeveloper/fluent-gpui", tag = "v3.0.0+zed.ba63a022c9a2" } rtbit-client = { path = "../rtbit-client" } rtbit-ui-core = { path = "../rtbit-ui-core" } +serde_json = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread", "sync", "time"] } [dev-dependencies] gpui = { git = "https://github.com/thedancingdeveloper/fluent-gpui", tag = "v3.0.0+zed.ba63a022c9a2", features = ["test-support"] } -serde_json = { workspace = true } wiremock = "0.6" diff --git a/crates/rtbit-fluent/src/bridge.rs b/crates/rtbit-fluent/src/bridge.rs index f53d64bc..eabab9df 100644 --- a/crates/rtbit-fluent/src/bridge.rs +++ b/crates/rtbit-fluent/src/bridge.rs @@ -21,6 +21,34 @@ use tokio::sync::{Mutex, oneshot}; /// How often [`Bridge::poll_snapshot`] refreshes the main screen. pub const SNAPSHOT_INTERVAL: Duration = Duration::from_secs(1); +/// Longest wait between polls while the daemon is unreachable. +pub const MAX_BACKOFF: Duration = Duration::from_secs(30); + +/// Consecutive failed polls before the shell shows the lost-connection banner. +pub const LOST_AFTER_FAILURES: u32 = 3; + +/// What the snapshot poll learned from its latest attempt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PollHealth { + /// A poll succeeded. + Ok, + /// `failures` polls in a row failed; the next attempt is in `retry_in`. + Failing { failures: u32, retry_in: Duration }, + /// The daemon refused the session (401); the user must sign in again. + Unauthorised, +} + +/// Delay before the next poll after `failures` consecutive failures: +/// 1s, 2s, 4s, ... capped at [`MAX_BACKOFF`]. Zero failures is the normal interval. +#[must_use] +pub fn backoff_delay(failures: u32) -> Duration { + if failures == 0 { + return SNAPSHOT_INTERVAL; + } + let factor = 1u32 << (failures - 1).min(5); + (SNAPSHOT_INTERVAL * factor).min(MAX_BACKOFF) +} + /// Worker threads for the bridge runtime. Calls are I/O bound. const WORKER_THREADS: usize = 2; @@ -138,8 +166,10 @@ impl Bridge { pub fn poll_snapshot( cx: &mut Context, select: fn(&mut V) -> &mut AsyncResource, + on_health: fn(&mut V, PollHealth, &mut Context), ) -> Poller { let task = cx.spawn(async move |view: WeakEntity, cx: &mut AsyncApp| { + let mut failures = 0u32; loop { let Ok(pending) = view.update(cx, |view, cx| { let request = select(view).begin_load(); @@ -149,21 +179,38 @@ impl Bridge { return; }; let (request, rx) = pending; - let reply = await_reply(rx).await; + let reply = match rx.await { + Ok(reply) => reply, + Err(_) => Err(Error::Unreachable("request cancelled".to_string())), + }; + let unauthorised = matches!(reply, Err(Error::Unauthorised)); + failures = if reply.is_ok() { 0 } else { failures + 1 }; + let delay = backoff_delay(failures); + let health = if reply.is_ok() { + PollHealth::Ok + } else if unauthorised { + PollHealth::Unauthorised + } else { + PollHealth::Failing { + failures, + retry_in: delay, + } + }; let updated = view.update(cx, |view, cx| { let resource = select(view); let accepted = match reply { Ok(value) => resource.resolve(request, value), - Err(err) => resource.reject(request, err), + Err(err) => resource.reject(request, err.to_string()), }; if accepted { cx.notify(); } + on_health(view, health, cx); }); - if updated.is_err() { + if updated.is_err() || unauthorised { return; } - cx.background_executor().timer(SNAPSHOT_INTERVAL).await; + cx.background_executor().timer(delay).await; } }); Poller { _task: task } @@ -279,6 +326,121 @@ mod tests { panic!("bridge reply never arrived"); } + #[test] + fn backoff_doubles_from_one_second_to_the_cap() { + let secs: Vec = (0..9).map(|n| backoff_delay(n).as_secs()).collect(); + assert_eq!(secs, [1, 1, 2, 4, 8, 16, 30, 30, 30]); + } + + #[test] + fn backoff_is_normal_interval_with_no_failures() { + assert_eq!(backoff_delay(0), SNAPSHOT_INTERVAL); + assert_eq!(backoff_delay(u32::MAX >> 1), MAX_BACKOFF); + } + + #[test] + fn await_reply_passes_value_and_stringifies_errors() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let (tx, rx) = oneshot::channel(); + tx.send(Ok(5u8)).unwrap(); + assert_eq!(rt.block_on(await_reply(rx)), Ok(5)); + let (tx, rx) = oneshot::channel::>(); + tx.send(Err(Error::Unauthorised)).unwrap(); + assert_eq!(rt.block_on(await_reply(rx)), Err("unauthorised".to_owned())); + let (tx, rx) = oneshot::channel::>(); + tx.send(Err(Error::Unreachable("refused".into()))).unwrap(); + assert_eq!( + rt.block_on(await_reply(rx)), + Err("daemon unreachable: refused".to_owned()) + ); + } + + #[test] + fn await_reply_reports_dropped_sender_as_cancelled() { + let rt = tokio::runtime::Runtime::new().unwrap(); + let (tx, rx) = oneshot::channel::>(); + drop(tx); + assert_eq!( + rt.block_on(await_reply(rx)), + Err("request cancelled".to_owned()) + ); + } + + /// A logged-in client against a daemon whose `/torrents` answers `status`. + fn client_with_torrents_status(rt: &Runtime, status: u16) -> (SharedClient, MockServer) { + rt.block_on(async { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/auth/login")) + .respond_with( + ResponseTemplate::new(200) + .set_body_raw(fixture("login.json"), "application/json"), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/auth/refresh")) + .respond_with(ResponseTemplate::new(401)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/torrents")) + .respond_with(ResponseTemplate::new(status)) + .mount(&server) + .await; + let mut client = Client::new(server.uri()).unwrap(); + client.login("admin", "secret").await.unwrap(); + (Arc::new(Mutex::new(client)), server) + }) + } + + #[test] + fn fetch_snapshot_maps_401_to_unauthorised() { + let rt = Runtime::new().unwrap(); + let (client, _server) = client_with_torrents_status(&rt, 401); + let err = rt.block_on(fetch_snapshot(client)).unwrap_err(); + assert!(matches!(err, Error::Unauthorised), "{err:?}"); + } + + #[test] + fn fetch_snapshot_maps_4xx_to_daemon_error_with_status() { + let rt = Runtime::new().unwrap(); + let (client, _server) = client_with_torrents_status(&rt, 418); + match rt.block_on(fetch_snapshot(client)).unwrap_err() { + Error::Daemon { status, message } => { + assert_eq!(status.as_u16(), 418); + assert_eq!(message, None); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn fetch_snapshot_maps_5xx_to_daemon_error_message() { + let rt = Runtime::new().unwrap(); + let (client, _server) = client_with_torrents_status(&rt, 503); + let err = rt.block_on(fetch_snapshot(client)).unwrap_err(); + assert!(matches!(&err, Error::Daemon { status, .. } if status.as_u16() == 503)); + assert_eq!(err.to_string(), "daemon error 503 Service Unavailable"); + } + + #[test] + fn fetch_snapshot_maps_transport_failure_to_unreachable() { + let rt = Runtime::new().unwrap(); + let (client, server) = client_with_torrents_status(&rt, 200); + drop(server); + let err = rt.block_on(fetch_snapshot(client)).unwrap_err(); + assert!(matches!(err, Error::Unreachable(_)), "{err:?}"); + } + + #[test] + fn call_delivers_the_closure_result() { + let bridge = Bridge::new("http://127.0.0.1:1").unwrap(); + let rx = bridge.call(|_| async { Err::<(), _>(Error::NotSetUp) }); + let reply = bridge.runtime().block_on(rx).unwrap(); + assert!(matches!(reply, Err(Error::NotSetUp))); + } + #[gpui::test] fn load_resolves_snapshot_from_daemon(cx: &mut TestAppContext) { let (_runtime, _server) = daemon(cx); @@ -330,7 +492,7 @@ mod tests { let (runtime, server) = daemon(cx); let screen = cx.new(|_| Screen::default()); screen.update(cx, |screen, cx| { - screen._poller = Some(Bridge::poll_snapshot(cx, snapshot_of)); + screen._poller = Some(Bridge::poll_snapshot(cx, snapshot_of, |_, _, _| {})); }); // Step the fake clock past each 1s timer until three polls have started. drive(cx, |cx| { diff --git a/crates/rtbit-fluent/src/lib.rs b/crates/rtbit-fluent/src/lib.rs index bfda0a8b..7cb7f59d 100644 --- a/crates/rtbit-fluent/src/lib.rs +++ b/crates/rtbit-fluent/src/lib.rs @@ -12,6 +12,7 @@ use rtbit_ui_core::Connection; pub mod bridge; pub mod screens; pub mod shell; +pub mod table_prefs; pub mod widgets; /// Window title shown in the title bar. @@ -21,8 +22,14 @@ pub const WINDOW_SIZE: (f32, f32) = (1100.0, 720.0); /// Open the rustTorrent window: frameless `FluentApp` with the fluent title bar. pub fn run() { + if std::env::args().any(|arg| arg == "--version") { + println!("rtbit-fluent {}", env!("CARGO_PKG_VERSION")); + return; + } + let (w, h) = WINDOW_SIZE; FluentApp::new(WINDOW_TITLE).window_size(w, h).run(|cx| { + screens::main_screen::bind_keys(cx); let status = cx.new(|_| widgets::status_bar::StatusBar::new()); let main = cx.new(|cx| screens::MainScreen::new(status.clone(), cx)); let shell = cx.new(|cx| shell::Shell::new(status.clone(), main, cx)); diff --git a/crates/rtbit-fluent/src/screens/add_dialog.rs b/crates/rtbit-fluent/src/screens/add_dialog.rs index fe6d4ae4..681ade40 100644 --- a/crates/rtbit-fluent/src/screens/add_dialog.rs +++ b/crates/rtbit-fluent/src/screens/add_dialog.rs @@ -10,8 +10,8 @@ use fluent_core::ThemeProvider as _; use fluent_layout::{MessageBar, MessageIntent}; use fluent_primitives::{Button, ButtonAppearance, Label, Switch, TextInput}; use gpui::{ - AsyncApp, Context, Entity, EventEmitter, IntoElement, PathPromptOptions, Render, Task, - WeakEntity, Window, div, prelude::*, px, + AsyncApp, Context, Entity, EventEmitter, Focusable as _, IntoElement, KeyDownEvent, + PathPromptOptions, Render, Task, WeakEntity, Window, div, prelude::*, px, }; use rtbit_ui_core::{AddRequest, AddSource}; @@ -19,7 +19,7 @@ use rtbit_ui_core::{AddRequest, AddSource}; #[derive(Clone, Debug, PartialEq, Eq)] pub enum AddDialogEvent { /// The input passed the checks. The owner sends the request and closes the dialog. - Submit(AddRequest), + Submit(Vec), /// The user cancelled. Nothing is sent. Cancel, } @@ -31,10 +31,11 @@ pub struct AddDialog { category: Entity, start_paused: bool, /// Name and bytes of the `.torrent` file the user picked. - file: Option<(String, Vec)>, + files: Vec<(String, Vec)>, error: Option, /// Set once a request has been emitted, so a second submit sends nothing. submitted: bool, + autofocused: bool, _pick: Option>, } @@ -48,9 +49,10 @@ impl AddDialog { save_path: cx.new(|_| TextInput::new().placeholder("Save path")), category: cx.new(|_| TextInput::new().placeholder("Category (optional)")), start_paused: false, - file: None, + files: Vec::new(), error: None, submitted: false, + autofocused: false, _pick: None, } } @@ -69,20 +71,37 @@ impl AddDialog { let magnet = self.magnet.read(cx).text().to_owned(); let save_path = self.save_path.read(cx).text().to_owned(); let category = self.category.read(cx).text().to_owned(); - let file = self - .file - .as_ref() - .map(|(name, bytes)| (name.as_str(), bytes.as_slice())); - match validate_add(&magnet, file, &save_path, &category, self.start_paused) { - Ok(request) => { + let files = if magnet.trim().is_empty() { + self.files + .iter() + .map(|(name, bytes)| (name.as_str(), bytes.as_slice())) + .collect::>() + } else { + vec![] + }; + let requests = if magnet.trim().is_empty() { + files + .into_iter() + .map(|file| validate_add("", Some(file), &save_path, &category, self.start_paused)) + .collect::, _>>() + } else { + validate_add(&magnet, None, &save_path, &category, self.start_paused) + .map(|request| vec![request]) + }; + match requests { + Ok(requests) if !requests.is_empty() => { self.submitted = true; self.error = None; - cx.emit(AddDialogEvent::Submit(request)); + cx.emit(AddDialogEvent::Submit(requests)); } Err(message) => { self.error = Some(message); cx.notify(); } + Ok(_) => { + self.error = Some("enter a magnet link or choose a .torrent file".to_owned()); + cx.notify(); + } } } @@ -98,6 +117,23 @@ impl AddDialog { cx.notify(); } + pub(crate) fn set_file(&mut self, name: String, bytes: Vec, cx: &mut Context) { + self.files = vec![(name, bytes)]; + self.error = None; + cx.notify(); + } + + pub(crate) fn set_files(&mut self, files: Vec<(String, Vec)>, cx: &mut Context) { + self.files = files; + self.error = None; + cx.notify(); + } + + pub(crate) fn set_error(&mut self, message: String, cx: &mut Context) { + self.error = Some(message); + cx.notify(); + } + /// Open the native file picker for one `.torrent` file and read it off the UI thread. fn pick_file(&mut self, cx: &mut Context) { let picked = cx.prompt_for_paths(PathPromptOptions { @@ -126,8 +162,7 @@ impl AddDialog { let _ = view.update(cx, |dialog, cx| { match read { Ok(bytes) => { - dialog.file = Some((name, bytes)); - dialog.error = None; + dialog.set_file(name, bytes, cx); } Err(err) => dialog.error = Some(format!("could not read {name}: {err}")), } @@ -137,10 +172,27 @@ impl AddDialog { ); } + /// Fill the magnet field, for a magnet taken from the clipboard. + pub(crate) fn prefill_magnet(&self, magnet: &str, cx: &mut Context) { + self.magnet + .update(cx, |field, cx| field.set_value(magnet.to_owned(), cx)); + } + + /// The text in the magnet field. + #[cfg(test)] + pub(crate) fn magnet_text(&self, cx: &gpui::App) -> String { + self.magnet.read(cx).text().to_owned() + } + + #[cfg(test)] + pub(crate) fn magnet_focus(&self, cx: &gpui::App) -> gpui::FocusHandle { + self.magnet.focus_handle(cx) + } + /// Stand in for the native picker: set the chosen `.torrent` file, for tests. #[cfg(test)] pub(crate) fn set_file_for_test(&mut self, name: &str, bytes: &[u8]) { - self.file = Some((name.to_owned(), bytes.to_vec())); + self.files = vec![(name.to_owned(), bytes.to_vec())]; } /// Type a magnet and a save path, for tests outside this module. @@ -163,12 +215,28 @@ fn field(label: &'static str, input: Entity) -> impl IntoElement { } impl Render for AddDialog { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.autofocused { + self.autofocused = true; + // The input only owns its focus handle once it has rendered, so focus it after + // this frame has drawn. + cx.defer_in(window, |dialog, window, cx| { + let focus = dialog.magnet.focus_handle(cx); + window.focus(&focus, cx); + }); + } let colors = cx.theme().colors.clone(); let file_label = self - .file - .as_ref() - .map_or_else(|| "No file chosen".to_owned(), |(name, _)| name.clone()); + .files + .iter() + .map(|(name, _)| name.as_str()) + .collect::>() + .join(", "); + let file_label = if file_label.is_empty() { + "No file chosen".to_owned() + } else { + file_label + }; let pick_me = cx.entity(); let paused_me = cx.entity(); let cancel_me = cx.entity(); @@ -253,6 +321,19 @@ impl Render for AddDialog { .items_center() .justify_center() .occlude() + .capture_key_down(cx.listener(|dialog, event: &KeyDownEvent, _, cx| { + match event.keystroke.key.as_str() { + "escape" => { + dialog.cancel(cx); + cx.stop_propagation(); + } + "enter" => { + dialog.submit(cx); + cx.stop_propagation(); + } + _ => {} + } + })) .bg(gpui::black().opacity(0.4)) .child(card) } diff --git a/crates/rtbit-fluent/src/screens/bulk_edit_dialog.rs b/crates/rtbit-fluent/src/screens/bulk_edit_dialog.rs new file mode 100644 index 00000000..9b81bd42 --- /dev/null +++ b/crates/rtbit-fluent/src/screens/bulk_edit_dialog.rs @@ -0,0 +1,161 @@ +use fluent_core::ThemeProvider as _; +use fluent_layout::{MessageBar, MessageIntent}; +use fluent_primitives::{Button, ButtonAppearance, Label, TextInput}; +use gpui::{Context, Entity, EventEmitter, IntoElement, Render, Window, div, prelude::*, px}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BulkEditRequest { + pub category: Option>, + pub add_tags: Vec, + pub remove_tags: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BulkEditDialogEvent { + Submit(BulkEditRequest), + Cancel, +} + +pub struct BulkEditDialog { + count: usize, + categories: Vec, + selected_category: Option>, + add_tags: Entity, + remove_tags: Entity, + error: Option, +} + +impl EventEmitter for BulkEditDialog {} + +impl BulkEditDialog { + pub fn new(count: usize, categories: Vec, cx: &mut Context) -> Self { + Self { + count, + categories, + selected_category: None, + add_tags: cx.new(|_| TextInput::new().placeholder("comma-separated tags")), + remove_tags: cx.new(|_| TextInput::new().placeholder("comma-separated tags")), + error: None, + } + } + + fn tags(input: &Entity, cx: &gpui::App) -> Vec { + input + .read(cx) + .text() + .split(',') + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(str::to_owned) + .collect() + } + + fn submit(&mut self, cx: &mut Context) { + let request = BulkEditRequest { + category: self.selected_category.clone(), + add_tags: Self::tags(&self.add_tags, cx), + remove_tags: Self::tags(&self.remove_tags, cx), + }; + if request.category.is_none() + && request.add_tags.is_empty() + && request.remove_tags.is_empty() + { + self.error = Some("Choose a category or enter tags first.".to_owned()); + cx.notify(); + return; + } + cx.emit(BulkEditDialogEvent::Submit(request)); + } +} + +fn field(label: &'static str, input: Entity) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_1() + .child(Label::new(label)) + .child(input) +} + +impl Render for BulkEditDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors.clone(); + let clear = cx.entity(); + let mut categories = div().flex().flex_wrap().gap_1().child( + Button::new("bulk-category-none") + .label("No category") + .appearance(if self.selected_category == Some(None) { + ButtonAppearance::Accent + } else { + ButtonAppearance::Neutral + }) + .on_click(move |_, _, app| { + clear.update(app, |dialog, cx| { + dialog.selected_category = Some(None); + cx.notify(); + }); + }), + ); + for (index, category) in self.categories.iter().cloned().enumerate() { + let me = cx.entity(); + let selected = self.selected_category.as_ref() == Some(&Some(category.clone())); + let value = category.clone(); + categories = categories.child( + Button::new(("bulk-category", index)) + .label(category) + .appearance(if selected { + ButtonAppearance::Accent + } else { + ButtonAppearance::Neutral + }) + .on_click(move |_, _, app| { + me.update(app, |dialog, cx| { + dialog.selected_category = Some(Some(value.clone())); + cx.notify(); + }); + }), + ); + } + let cancel = cx.entity(); + let submit = cx.entity(); + let mut card = div() + .flex() + .flex_col() + .gap_3() + .w(px(480.)) + .p_4() + .rounded_md() + .bg(colors.surface) + .border_1() + .border_color(colors.stroke_neutral) + .child(Label::new(format!("Edit {} selected torrents", self.count))) + .child(Label::new("Set category")) + .child(categories) + .child(field("Add tags", self.add_tags.clone())) + .child(field("Remove tags", self.remove_tags.clone())); + if let Some(error) = &self.error { + card = card.child(MessageBar::new(error.clone()).intent(MessageIntent::Error)); + } + card.child( + div() + .flex() + .gap_2() + .justify_end() + .child( + Button::new("bulk-cancel") + .label("Cancel") + .on_click(move |_, _, app| { + cancel.update(app, |_, cx| cx.emit(BulkEditDialogEvent::Cancel)); + }), + ) + .child( + Button::new("bulk-apply") + .label("Apply") + .appearance(ButtonAppearance::Accent) + .on_click(move |_, _, app| { + submit.update(app, |dialog, cx| dialog.submit(cx)); + }), + ), + ) + } +} diff --git a/crates/rtbit-fluent/src/screens/detail.rs b/crates/rtbit-fluent/src/screens/detail.rs index 61da1cbe..8c599e60 100644 --- a/crates/rtbit-fluent/src/screens/detail.rs +++ b/crates/rtbit-fluent/src/screens/detail.rs @@ -3,17 +3,25 @@ //! //! Every read and write runs on the bridge's tokio runtime. The pane keeps //! the last good [`DetailSnapshot`] in an [`AsyncResource`], so a reply from a -//! superseded selection cannot overwrite the current one. A file checkbox posts -//! the whole new selection with `update_only_files`, then reloads the detail. +//! superseded selection cannot overwrite the current one. The Files tab is a +//! `Tree` of folders and files. Double-clicking a node, or the checkbox and +//! priority buttons under the tree, posts the new selection and reloads. +//! +//! File priorities are skip, normal, high, and maximum. Folders summarize +//! their children and can apply a priority to every file below them. + +use std::collections::{BTreeMap, HashSet}; use fluent_core::AsyncResource; -use fluent_layout::{DataColumn, DataRow, DataTable, MessageBar, MessageIntent, TabItem, TabStrip}; -use fluent_primitives::{Checkbox, Label}; +use fluent_layout::{ + DataColumn, DataRow, DataTable, MessageBar, MessageIntent, TabItem, TabStrip, Tree, TreeItem, +}; +use fluent_primitives::{Button, ButtonAppearance, Checkbox, CheckboxState, Label, TextInput}; use gpui::{ - App, AsyncApp, Context, Entity, IntoElement, Render, Task, WeakEntity, Window, div, prelude::*, - px, + App, AsyncApp, ClickEvent, Context, Entity, IntoElement, Render, SharedString, Task, + WeakEntity, Window, div, prelude::*, px, }; -use rtbit_client::Error; +use rtbit_client::{Error, TorrentFile}; use rtbit_ui_core::{DetailSnapshot, format, map_error}; use crate::bridge::{Bridge, Poller, SharedClient}; @@ -27,12 +35,33 @@ const TABS: [(&str, &str); 5] = [ ("pieces", "Pieces"), ]; +const NO_TORRENT_SELECTED: &str = "No torrent selected."; +const METADATA_RESOLVING: &str = "Torrent metadata is still resolving."; +const NO_CONNECTED_PEERS: &str = "No connected peers."; +const NO_TRACKERS: &str = "No trackers (DHT-only)."; + +/// The named empty state associated with each detail tab. +fn empty_state_message(tab: &str) -> &'static str { + match tab { + "files" | "pieces" => METADATA_RESOLVING, + "peers" => NO_CONNECTED_PEERS, + "trackers" => NO_TRACKERS, + _ => NO_TORRENT_SELECTED, + } +} + /// The detail pane for one selected torrent. pub struct DetailPane { torrent_id: Option, detail: AsyncResource, tabs: Entity, + tracker_input: Entity, error: Option, + notice: Option, + /// The Files tree node the checkbox and priority buttons act on. + selected_node: Option, + /// Folder nodes the user collapsed; folders start expanded. + collapsed: HashSet, _load: Option>, _action: Option>, _poller: Option, @@ -67,13 +96,18 @@ impl DetailPane { } strip }); + let tracker_input = cx.new(|_| TextInput::new().placeholder("Tracker URL")); // A tab click notifies the strip; re-render the pane so the body follows. cx.observe(&tabs, |_, _, cx| cx.notify()).detach(); Self { torrent_id: None, detail: AsyncResource::default(), tabs, + tracker_input, error: None, + notice: None, + selected_node: None, + collapsed: HashSet::new(), _load: None, _action: None, _poller: None, @@ -107,8 +141,15 @@ impl DetailPane { /// Show torrent `id`: load its detail on the bridge. The previous detail /// stays until the new one lands, and superseded replies are dropped. pub fn select_torrent(&mut self, id: u64, cx: &mut Context) { + if self.torrent_id != Some(id) { + self.selected_node = None; + self.collapsed.clear(); + self.tracker_input + .update(cx, |input, cx| input.set_value("", cx)); + } self.torrent_id = Some(id); self.error = None; + self.notice = None; self._load = Some(Bridge::load(self, cx, detail_of, move |client| { fetch_detail(client, id) })); @@ -119,6 +160,9 @@ impl DetailPane { self.torrent_id = None; self.detail = AsyncResource::default(); self.error = None; + self.notice = None; + self.selected_node = None; + self.collapsed.clear(); self._load = None; self._action = None; cx.notify(); @@ -145,6 +189,137 @@ impl DetailPane { let mut client = client.lock().await; client.set_file_included(id, index, included).await }); + self.await_action(id, rx, cx); + } + + /// The selected Files tree node, if any. + #[must_use] + pub fn selected_node(&self) -> Option<&SharedString> { + self.selected_node.as_ref() + } + + /// Select a Files tree node for the checkbox and priority buttons. + pub fn select_node(&mut self, node: SharedString, cx: &mut Context) { + self.selected_node = Some(node); + cx.notify(); + } + + /// Expand or collapse a folder node. + pub fn set_expanded(&mut self, node: SharedString, expanded: bool, cx: &mut Context) { + if expanded { + self.collapsed.remove(&node); + } else { + self.collapsed.insert(node); + } + cx.notify(); + } + + /// Flip a tree node: a file or every file under a folder. A fully + /// included node is skipped; anything else is included. + pub fn toggle_node(&mut self, node: &str, cx: &mut Context) { + let Some(files) = self.files() else { + return; + }; + let indices = indices_under(&files, node); + let include = inclusion(&files, &indices) != CheckboxState::Checked; + self.set_included(&indices, include, cx); + } + + /// Include (priority "Normal") or skip ("Do not download") every listed + /// file. One file posts to its own route; several post the whole new + /// selection with `update_only_files`. + pub fn set_included(&mut self, indices: &[usize], included: bool, cx: &mut Context) { + let Some(id) = self.torrent_id else { + return; + }; + let Some(files) = self.files() else { + return; + }; + let indices: Vec = indices + .iter() + .copied() + .filter(|&i| files.get(i).is_some_and(|f| f.included != included)) + .collect(); + match indices.as_slice() { + [] => {} + [index] => { + let index = *index; + let rx = cx.global::().call(move |client| async move { + let mut client = client.lock().await; + client.set_file_included(id, index, included).await + }); + self.await_action(id, rx, cx); + } + _ => { + let selection = new_selection(&files, &indices, included); + let rx = cx.global::().call(move |client| async move { + let mut client = client.lock().await; + client.update_only_files(id, &selection).await + }); + self.await_action(id, rx, cx); + } + } + } + + /// Set the qbit-compatible priority for every file under a tree node. + pub fn set_priority(&mut self, indices: &[usize], priority: u8, cx: &mut Context) { + let Some(id) = self.torrent_id else { return }; + let Some(files) = self.files() else { return }; + let indices: Vec = indices + .iter() + .copied() + .filter(|&index| files.get(index).is_some()) + .collect(); + let rx = cx.global::().call(move |client| async move { + let mut client = client.lock().await; + for index in indices { + client.set_file_priority(id, index, priority).await?; + } + Ok(()) + }); + self.await_action(id, rx, cx); + } + + /// Add the tracker URL entered in the Trackers tab and reload the detail. + pub fn add_tracker(&mut self, cx: &mut Context) { + let Some(id) = self.torrent_id else { + return; + }; + let tracker = self.tracker_input.read(cx).text().trim().to_owned(); + self.notice = None; + if tracker.is_empty() { + self.error = Some("Tracker URL cannot be empty.".to_owned()); + cx.notify(); + return; + } + let rx = cx.global::().call(move |client| async move { + let mut client = client.lock().await; + client.add_trackers(id, &[tracker]).await + }); + self._action = Some( + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let reply = match rx.await { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => Err(map_error(&err)), + Err(_) => Err("request cancelled".to_owned()), + }; + let _ = this.update(cx, |this, cx| this.finish_add_tracker(id, reply, cx)); + }), + ); + } + + fn files(&self) -> Option> { + self.detail + .ready_value() + .and_then(|snapshot| snapshot.torrent.files.clone()) + } + + fn await_action( + &mut self, + id: u64, + rx: tokio::sync::oneshot::Receiver>, + cx: &mut Context, + ) { self._action = Some( cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { let reply = match rx.await { @@ -167,95 +342,336 @@ impl DetailPane { cx.notify(); } + fn finish_add_tracker(&mut self, id: u64, reply: Result<(), String>, cx: &mut Context) { + match reply { + Ok(()) if self.torrent_id == Some(id) => { + self.tracker_input + .update(cx, |input, cx| input.set_value("", cx)); + self.select_torrent(id, cx); + self.notice = Some("Tracker added.".to_owned()); + } + Ok(()) => {} + Err(message) => { + self.notice = None; + self.error = Some(message); + } + } + cx.notify(); + } + fn body(&self, cx: &mut Context) -> gpui::AnyElement { let Some(snapshot) = self.detail.ready_value() else { let text = if self.torrent_id.is_some() { "Loading…" } else { - "Select a torrent to see its details." + NO_TORRENT_SELECTED }; return Label::new(text).into_any_element(); }; match self.active_tab(cx) { - "files" => files(snapshot, cx).into_any_element(), + "files" if snapshot.torrent.files.is_none() => { + Label::new(empty_state_message("files")).into_any_element() + } + "files" => files(snapshot, self, cx).into_any_element(), "peers" => peers_table(snapshot).into_any_element(), - "trackers" => trackers_table(snapshot).into_any_element(), + "trackers" => trackers_table(snapshot, self, cx).into_any_element(), + "pieces" if snapshot.pieces.have.is_empty() => { + Label::new(empty_state_message("pieces")).into_any_element() + } "pieces" => pieces_summary(snapshot).into_any_element(), _ => general(snapshot).into_any_element(), } } } -/// One checkbox per file. Checking a box posts the new selection via `toggle_file`. -fn files(snapshot: &DetailSnapshot, cx: &mut Context) -> impl IntoElement { - let me = cx.entity(); - let rows = snapshot - .torrent - .files +/// Tree node id of a folder (its path joined with `/`). +fn dir_id(path: &str) -> SharedString { + format!("dir:{path}").into() +} + +/// Tree node id of file `index`. +fn file_id(index: usize) -> SharedString { + format!("file:{index}").into() +} + +/// A file's path parts. Older daemons send no `components`; split the name. +fn parts(file: &TorrentFile) -> Vec<&str> { + if file.components.is_empty() { + file.name.split('/').filter(|p| !p.is_empty()).collect() + } else { + file.components.iter().map(String::as_str).collect() + } +} + +/// Indices of the files a node covers: one for a file, every file below for a folder. +fn indices_under(files: &[TorrentFile], node: &str) -> Vec { + if let Some(index) = node.strip_prefix("file:") { + return index + .parse() + .ok() + .filter(|i| *i < files.len()) + .into_iter() + .collect(); + } + let Some(dir) = node.strip_prefix("dir:") else { + return Vec::new(); + }; + let prefix: Vec<&str> = dir.split('/').collect(); + files .iter() - .flatten() .enumerate() - .map(|(index, file)| { + .filter(|(_, file)| { + let parts = parts(file); + parts.len() > prefix.len() && parts[..prefix.len()] == prefix[..] + }) + .map(|(i, _)| i) + .collect() +} + +/// Checkbox state of a set of files: all in, none in, or a mix. +fn inclusion(files: &[TorrentFile], indices: &[usize]) -> CheckboxState { + let included = indices.iter().filter(|&&i| files[i].included).count(); + match included { + 0 => CheckboxState::Unchecked, + n if n == indices.len() => CheckboxState::Checked, + _ => CheckboxState::Indeterminate, + } +} + +/// The priority a checkbox state stands for. +fn priority_label(priority: u8) -> &'static str { + match priority { + 0 => "Do not download", + 1 => "Normal", + 6 => "High", + 7 => "Maximum", + _ => "Mixed", + } +} + +fn effective_priority(file: &TorrentFile) -> u8 { + if !file.included && file.priority == 1 { + 0 + } else { + file.priority + } +} + +fn node_priority(files: &[TorrentFile], indices: &[usize]) -> Option { + let first = effective_priority(files.get(*indices.first()?)?); + indices + .iter() + .all(|&index| { + files + .get(index) + .is_some_and(|file| effective_priority(file) == first) + }) + .then_some(first) +} + +/// The whole selection after setting `changed` to `included`. +fn new_selection(files: &[TorrentFile], changed: &[usize], included: bool) -> Vec { + files + .iter() + .enumerate() + .filter(|(i, file)| { + if changed.contains(i) { + included + } else { + file.included + } + }) + .map(|(i, _)| i) + .collect() +} + +/// Folder and file nodes, folders first and sorted by name, files in torrent order. +fn file_tree(files: &[TorrentFile], collapsed: &HashSet) -> Vec { + let all: Vec = (0..files.len()).collect(); + level(files, &all, &[], collapsed) +} + +fn level( + files: &[TorrentFile], + indices: &[usize], + prefix: &[&str], + collapsed: &HashSet, +) -> Vec { + let mut dirs: BTreeMap<&str, Vec> = BTreeMap::new(); + let mut leaves = Vec::new(); + for &i in indices { + let parts = parts(&files[i]); + match parts.get(prefix.len()) { + Some(name) if parts.len() > prefix.len() + 1 => dirs.entry(name).or_default().push(i), + _ => leaves.push(i), + } + } + let mut items = Vec::new(); + for (name, under) in dirs { + let mut path = prefix.to_vec(); + path.push(name); + let id = dir_id(&path.join("/")); + let size: u64 = under.iter().map(|&i| files[i].length).sum(); + let badge = format!( + "{} {}", + format::bytes(size), + priority_label(node_priority(files, &under).unwrap_or(255)) + ); + let mut item = TreeItem::new(id.clone(), name.to_owned()) + .badge(badge) + .expanded(!collapsed.contains(&id)); + item.children = level(files, &under, &path, collapsed); + items.push(item); + } + for i in leaves { + let file = &files[i]; + let name = parts(file) + .last() + .map_or(file.name.as_str(), |n| n) + .to_owned(); + let badge = format!( + "{} {}", + format::bytes(file.length), + priority_label(effective_priority(file)) + ); + items.push(TreeItem::new(file_id(i), name).badge(badge)); + } + items +} + +/// The file tree, then the selected node's checkbox and priority buttons. +/// Double-clicking a node flips it as the checkbox would. +fn files( + snapshot: &DetailSnapshot, + pane: &DetailPane, + cx: &mut Context, +) -> impl IntoElement { + let me = cx.entity(); + let list = snapshot.torrent.files.as_deref().unwrap_or_default(); + let mut tree = Tree::new() + .items(file_tree(list, &pane.collapsed)) + .on_select({ let me = me.clone(); - let checkbox = Checkbox::new(("torrent-file", index)) - .checked(file.included) - .on_click(move |_, _, _, cx| { - me.update(cx, |pane, cx| pane.toggle_file(index, cx)); - }); + move |node, _, _, cx| me.update(cx, |pane, cx| pane.select_node(node, cx)) + }) + .on_toggle({ + let me = me.clone(); + move |node, expanded, _, _, cx| { + me.update(cx, |pane, cx| pane.set_expanded(node, expanded, cx)); + } + }) + .on_activate({ + let me = me.clone(); + move |node, _, _, cx| me.update(cx, |pane, cx| pane.toggle_node(&node, cx)) + }); + if let Some(node) = &pane.selected_node { + tree = tree.selected(node.clone()); + } + let mut column = div().flex().flex_col().gap_2().child(tree); + let selected = pane + .selected_node + .as_ref() + .map(|node| (node.clone(), indices_under(list, node))) + .filter(|(_, indices)| !indices.is_empty()); + if let Some((node, indices)) = selected { + let state = inclusion(list, &indices); + let priority_button = |id: &'static str, label: &'static str, priority: u8| { + let me = me.clone(); + let indices = indices.clone(); + Button::new(id).label(label).on_click( + move |_: &ClickEvent, _: &mut Window, cx: &mut App| { + me.update(cx, |pane, cx| pane.set_priority(&indices, priority, cx)); + }, + ) + }; + let toggle = { + let me = me.clone(); + Checkbox::new("torrent-file-node") + .state(state) + .on_click(move |_, _, _, cx| me.update(cx, |pane, cx| pane.toggle_node(&node, cx))) + }; + column = column.child( div() .flex() + .items_center() .gap_2() - .child(checkbox) + .child(toggle) .child(Label::new(format!( - "{} {}", - file.name, - format::bytes(file.length) + "Priority: {}", + node_priority(list, &indices) + .map(priority_label) + .unwrap_or("Mixed") ))) - }); - div().flex().flex_col().gap_1().children(rows) + .child(priority_button("torrent-file-normal", "Normal", 1)) + .child(priority_button("torrent-file-high", "High", 6)) + .child(priority_button("torrent-file-maximum", "Maximum", 7)) + .child(priority_button("torrent-file-skip", "Do not download", 0)), + ); + } + column } /// The piece bar's numbers: how many pieces are held out of the total. -fn pieces_summary(snapshot: &DetailSnapshot) -> impl IntoElement { +fn pieces_text(snapshot: &DetailSnapshot) -> String { let have = snapshot.pieces.have.iter().filter(|h| **h).count(); let total = snapshot.pieces.have.len(); + format!("Pieces held: {have} of {total}") +} + +fn pieces_summary(snapshot: &DetailSnapshot) -> impl IntoElement { div() .flex() .flex_col() .gap_1() - .child(Label::new(format!("Pieces held: {have} of {total}"))) + .child(Label::new(pieces_text(snapshot))) } -fn general(snapshot: &DetailSnapshot) -> impl IntoElement { +/// The General tab's lines, in display order. +fn general_lines(snapshot: &DetailSnapshot) -> Vec { let torrent = &snapshot.torrent; let state = format::state_label(torrent.stats.as_ref().and_then(|stats| stats.state)); let have = snapshot.pieces.have.iter().filter(|h| **h).count() as u64; - div() - .flex() - .flex_col() - .gap_1() - .child(Label::new(torrent.name.clone().unwrap_or_default())) - .child(Label::new(format!("Info hash: {}", torrent.info_hash))) - .child(Label::new(format!("Folder: {}", torrent.output_folder))) - .child(Label::new(format!("State: {state}"))) - .child(Label::new(format!( + vec![ + torrent.name.clone().unwrap_or_default(), + format!("Info hash: {}", torrent.info_hash), + format!("Folder: {}", torrent.output_folder), + format!("State: {state}"), + format!( "Pieces: {}", format::progress(have, u64::from(torrent.total_pieces)) - ))) + ), + ] } -fn peers_table(snapshot: &DetailSnapshot) -> impl IntoElement { - let rows = snapshot.peers.peers.iter().map(|peer| { - DataRow::new( - peer.addr.clone(), +fn general(snapshot: &DetailSnapshot) -> impl IntoElement { + let mut column = div().flex().flex_col().gap_1(); + for line in general_lines(snapshot) { + column = column.child(Label::new(line)); + } + column +} + +/// One row per connected peer: address, state, downloaded, uploaded. +fn peer_rows(snapshot: &DetailSnapshot) -> Vec<[String; 4]> { + snapshot + .peers + .peers + .iter() + .map(|peer| { [ peer.addr.clone(), peer.state.clone(), format::bytes(peer.fetched_bytes), format::bytes(peer.uploaded_bytes), - ], - ) - }); + ] + }) + .collect() +} + +fn peers_table(snapshot: &DetailSnapshot) -> impl IntoElement { + let rows = peer_rows(snapshot) + .into_iter() + .map(|cells| DataRow::new(cells[0].clone(), cells)); DataTable::new() .columns([ DataColumn::new("addr", "Address").width(200.0), @@ -264,32 +680,64 @@ fn peers_table(snapshot: &DetailSnapshot) -> impl IntoElement { DataColumn::new("up", "Uploaded").width(110.0), ]) .rows(rows) - .empty_message("No live peers") + .empty_message(empty_state_message("peers")) .max_body_height(240.0) } -fn trackers_table(snapshot: &DetailSnapshot) -> impl IntoElement { - let rows = snapshot.trackers.trackers.iter().map(|tracker| { - DataRow::new( - tracker.url.clone(), +/// One row per tracker: url, state, seeders, leechers. +fn tracker_rows(snapshot: &DetailSnapshot) -> Vec<[String; 4]> { + snapshot + .trackers + .trackers + .iter() + .map(|tracker| { [ tracker.url.clone(), format!("{:?}", tracker.state), tracker.seeders.map_or_else(String::new, |n| n.to_string()), tracker.leechers.map_or_else(String::new, |n| n.to_string()), - ], + ] + }) + .collect() +} + +fn trackers_table( + snapshot: &DetailSnapshot, + pane: &DetailPane, + cx: &mut Context, +) -> impl IntoElement { + let rows = tracker_rows(snapshot) + .into_iter() + .map(|cells| DataRow::new(cells[0].clone(), cells)); + let add = cx.listener(|this, _: &ClickEvent, _window, cx| this.add_tracker(cx)); + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .gap_2() + .child(pane.tracker_input.clone()) + .child( + Button::new("add-tracker") + .label("Add") + .appearance(ButtonAppearance::Accent) + .on_click(add), + ), + ) + .child( + DataTable::new() + .columns([ + DataColumn::new("url", "Tracker").width(260.0), + DataColumn::new("state", "State").width(90.0), + DataColumn::new("seeders", "Seeders").width(80.0), + DataColumn::new("leechers", "Leechers").width(80.0), + ]) + .rows(rows) + .empty_message(empty_state_message("trackers")) + .max_body_height(240.0), ) - }); - DataTable::new() - .columns([ - DataColumn::new("url", "Tracker").width(260.0), - DataColumn::new("state", "State").width(90.0), - DataColumn::new("seeders", "Seeders").width(80.0), - DataColumn::new("leechers", "Leechers").width(80.0), - ]) - .rows(rows) - .empty_message("No trackers") - .max_body_height(240.0) } impl Render for DetailPane { @@ -304,6 +752,9 @@ impl Render for DetailPane { if let Some(message) = &self.error { pane = pane.child(MessageBar::new(message.clone()).intent(MessageIntent::Error)); } + if let Some(message) = &self.notice { + pane = pane.child(Label::new(message.clone())); + } pane.child(self.body(cx)) } } @@ -400,6 +851,21 @@ mod tests { assert_eq!(labels, ["General", "Files", "Peers", "Trackers", "Pieces"]); } + #[test] + fn every_detail_tab_has_the_expected_named_empty_state() { + assert_eq!(empty_state_message("general"), "No torrent selected."); + assert_eq!( + empty_state_message("files"), + "Torrent metadata is still resolving." + ); + assert_eq!(empty_state_message("peers"), "No connected peers."); + assert_eq!(empty_state_message("trackers"), "No trackers (DHT-only)."); + assert_eq!( + empty_state_message("pieces"), + "Torrent metadata is still resolving." + ); + } + #[gpui::test] fn pieces_tab_shows_held_out_of_total(cx: &mut TestAppContext) { let (_runtime, _server) = daemon(cx); @@ -471,4 +937,350 @@ mod tests { assert!(p.error().is_none(), "post must not fail") }); } + + fn file(path: &str, included: bool) -> TorrentFile { + TorrentFile { + name: path.to_owned(), + components: path.split('/').map(str::to_owned).collect(), + length: 10, + included, + priority: if included { 1 } else { 0 }, + } + } + + fn sample() -> Vec { + vec![ + file("show/s1/e1.mkv", true), + file("show/s1/e2.mkv", false), + file("show/notes.txt", true), + file("readme.txt", true), + ] + } + + #[test] + fn tree_nests_folders_before_files() { + let tree = file_tree(&sample(), &HashSet::new()); + let ids: Vec<&str> = tree.iter().map(|item| item.id.as_ref()).collect(); + assert_eq!(ids, ["dir:show", "file:3"]); + let show = &tree[0]; + let ids: Vec<&str> = show.children.iter().map(|item| item.id.as_ref()).collect(); + assert_eq!(ids, ["dir:show/s1", "file:2"]); + assert_eq!(show.children[0].children.len(), 2); + assert!(show.badge.as_deref().unwrap().ends_with("Mixed")); + assert!( + show.children[0].children[1] + .badge + .as_deref() + .unwrap() + .ends_with("Do not download") + ); + } + + #[test] + fn collapsed_folders_stay_collapsed() { + let collapsed: HashSet = [dir_id("show")].into_iter().collect(); + let tree = file_tree(&sample(), &collapsed); + assert!(!tree[0].expanded); + assert!(tree[0].children[0].expanded); + } + + #[test] + fn folder_covers_every_file_below_it() { + let files = sample(); + assert_eq!(indices_under(&files, "dir:show"), [0, 1, 2]); + assert_eq!(indices_under(&files, "dir:show/s1"), [0, 1]); + assert_eq!(indices_under(&files, "file:3"), [3]); + assert!(indices_under(&files, "file:9").is_empty()); + assert_eq!(inclusion(&files, &[0, 1]), CheckboxState::Indeterminate); + assert_eq!(inclusion(&files, &[0, 2]), CheckboxState::Checked); + assert_eq!(inclusion(&files, &[1]), CheckboxState::Unchecked); + } + + #[test] + fn new_selection_sets_only_the_changed_files() { + let files = sample(); + assert_eq!(new_selection(&files, &[0, 1], true), [0, 1, 2, 3]); + assert_eq!(new_selection(&files, &[0, 1, 2], false), [3]); + } + + #[gpui::test] + fn skipping_a_folder_posts_the_whole_selection(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx); + // Both fixture files sit at the root; skipping both leaves nothing selected. + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/7/update_only_files")) + .and(body_string(r#"{"only_files":[]}"#)) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server), + ); + let pane = cx.new(DetailPane::new); + pane.update(cx, |pane, cx| pane.select_torrent(7, cx)); + drive(cx, |cx| pane.read_with(cx, |p, _| p.snapshot().is_some())); + pane.update(cx, |pane, cx| pane.set_included(&[0, 1, 0], false, cx)); + let count_posts = |runtime: &Runtime, server: &MockServer| { + runtime + .block_on(server.received_requests()) + .unwrap() + .iter() + .filter(|r| r.url.path() == "/torrents/7/update_only_files") + .count() + }; + drive(cx, |_| count_posts(&runtime, &server) == 1); + pane.read_with(cx, |p, _| assert!(p.error().is_none())); + } + + fn loaded(cx: &mut TestAppContext) -> (Arc, MockServer, Entity) { + let (runtime, server) = daemon(cx); + let pane = cx.new(DetailPane::new); + pane.update(cx, |pane, cx| pane.select_torrent(7, cx)); + drive(cx, |cx| pane.read_with(cx, |p, _| p.snapshot().is_some())); + (runtime, server, pane) + } + + fn count_posts(runtime: &Runtime, server: &MockServer, route: &str) -> usize { + runtime + .block_on(server.received_requests()) + .unwrap() + .iter() + .filter(|r| r.method.as_str() == "POST" && r.url.path() == route) + .count() + } + + #[gpui::test] + fn general_tab_shows_name_hash_folder_and_pieces(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, _| { + let lines = general_lines(p.snapshot().unwrap()); + assert_eq!(lines[0], "example"); + assert_eq!(lines[1], "Info hash: abc"); + assert_eq!(lines[2], "Folder: /data"); + assert_eq!(lines[3], "State: —"); + assert_eq!(lines[4], "Pieces: 1 B / 10 B"); + }); + } + + #[gpui::test] + fn general_tab_is_the_default_tab(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, cx| assert_eq!(p.active_tab(cx), "general")); + } + + #[gpui::test] + fn files_tab_tree_lists_fixture_files_with_sizes_and_priority(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, _| { + let files = p.files().unwrap(); + let tree = file_tree(&files, &HashSet::new()); + let ids: Vec<&str> = tree.iter().map(|i| i.id.as_ref()).collect(); + assert_eq!(ids, ["file:0", "file:1"]); + assert_eq!(tree[0].badge.as_deref(), Some("100 B Normal")); + assert_eq!(tree[1].badge.as_deref(), Some("50 B Do not download")); + }); + } + + #[test] + fn priority_label_maps_daemon_levels() { + assert_eq!(priority_label(0), "Do not download"); + assert_eq!(priority_label(1), "Normal"); + assert_eq!(priority_label(6), "High"); + assert_eq!(priority_label(7), "Maximum"); + assert_eq!(node_priority(&sample(), &[0]), Some(1)); + assert_eq!(node_priority(&sample(), &[0, 1]), None); + } + + #[gpui::test] + fn peers_tab_shows_the_peer_row(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, _| { + let rows = peer_rows(p.snapshot().unwrap()); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0], ["1.2.3.4:6881", "live", "20 B", "5 B"]); + }); + } + + #[gpui::test] + fn trackers_tab_shows_status_and_swarm_counts(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, _| { + let rows = tracker_rows(p.snapshot().unwrap()); + assert_eq!( + rows, + [["http://tracker.example/announce", "Working", "4", "1"]] + ); + }); + } + + #[gpui::test] + fn empty_tracker_is_refused_without_dropping_selection(cx: &mut TestAppContext) { + let (_runtime, _server, pane) = loaded(cx); + pane.update(cx, |pane, cx| pane.add_tracker(cx)); + pane.read_with(cx, |pane, _| { + assert_eq!(pane.torrent_id(), Some(7)); + assert_eq!(pane.error(), Some("Tracker URL cannot be empty.")); + }); + } + + #[gpui::test] + fn adding_tracker_posts_refreshes_and_reports_success(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + runtime.block_on(async { + Mock::given(method("POST")) + .and(path("/torrents/7/trackers")) + .and(body_string( + r#"{"trackers":["https://new.example/announce"]}"#, + )) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + }); + pane.update(cx, |pane, cx| { + pane.tracker_input.update(cx, |input, cx| { + input.set_value("https://new.example/announce", cx) + }); + pane.add_tracker(cx); + }); + drive(cx, |_| { + count_posts(&runtime, &server, "/torrents/7/trackers") == 1 + }); + drive(cx, |cx| pane.read_with(cx, |pane, _| pane.notice.is_some())); + pane.read_with(cx, |pane, cx| { + assert_eq!(pane.torrent_id(), Some(7)); + assert_eq!(pane.notice.as_deref(), Some("Tracker added.")); + assert!(pane.error().is_none()); + assert!(pane.tracker_input.read(cx).text().is_empty()); + }); + } + + #[gpui::test] + fn refused_tracker_keeps_input_and_selection(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + runtime.block_on(async { + Mock::given(method("POST")) + .and(path("/torrents/7/trackers")) + .respond_with(ResponseTemplate::new(400).set_body_string("invalid tracker URL")) + .mount(&server) + .await; + }); + pane.update(cx, |pane, cx| { + pane.tracker_input + .update(cx, |input, cx| input.set_value("not-a-url", cx)); + pane.add_tracker(cx); + }); + drive(cx, |cx| { + pane.read_with(cx, |pane, _| pane.error().is_some()) + }); + pane.read_with(cx, |pane, cx| { + assert_eq!(pane.torrent_id(), Some(7)); + assert!(pane.error().is_some()); + assert_eq!(pane.tracker_input.read(cx).text(), "not-a-url"); + }); + } + + #[gpui::test] + fn pieces_tab_text_reports_one_of_ten(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.read_with(cx, |p, _| { + assert_eq!(pieces_text(p.snapshot().unwrap()), "Pieces held: 1 of 10"); + }); + } + + #[gpui::test] + fn tab_switch_follows_the_strip(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + for (i, (id, _)) in TABS.iter().enumerate() { + pane.update(cx, |pane, cx| { + pane.tabs.update(cx, |strip, _| strip.active = i); + assert_eq!(pane.active_tab(cx), *id); + }); + } + } + + #[gpui::test] + fn toggle_node_on_an_excluded_file_posts_included_true(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/7/files/1/included")) + .and(body_string(r#"{"included":true}"#)) + .respond_with(ResponseTemplate::new(200)) + .mount(&server), + ); + pane.update(cx, |pane, cx| pane.toggle_node("file:1", cx)); + drive(cx, |_| { + count_posts(&runtime, &server, "/torrents/7/files/1/included") == 1 + }); + pane.read_with(cx, |p, _| assert!(p.error().is_none())); + } + + #[gpui::test] + fn toggle_node_on_an_included_file_posts_included_false(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/7/files/0/included")) + .and(body_string(r#"{"included":false}"#)) + .respond_with(ResponseTemplate::new(200)) + .mount(&server), + ); + pane.update(cx, |pane, cx| pane.toggle_node("file:0", cx)); + drive(cx, |_| { + count_posts(&runtime, &server, "/torrents/7/files/0/included") == 1 + }); + } + + #[gpui::test] + fn including_an_already_included_file_posts_nothing(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + pane.update(cx, |pane, cx| pane.set_included(&[0], true, cx)); + cx.run_until_parked(); + assert_eq!( + count_posts(&runtime, &server, "/torrents/7/files/0/included"), + 0 + ); + } + + #[gpui::test] + fn failed_file_post_shows_an_error(cx: &mut TestAppContext) { + let (runtime, server, pane) = loaded(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/7/files/1/included")) + .respond_with(ResponseTemplate::new(500)) + .mount(&server), + ); + pane.update(cx, |pane, cx| pane.toggle_file(1, cx)); + drive(cx, |cx| pane.read_with(cx, |p, _| p.error().is_some())); + } + + #[gpui::test] + fn clear_drops_the_snapshot_and_selection(cx: &mut TestAppContext) { + let (_r, _s, pane) = loaded(cx); + pane.update(cx, |pane, cx| { + pane.select_node("file:0".into(), cx); + assert!(pane.selected_node().is_some()); + pane.clear(cx); + }); + pane.read_with(cx, |p, _| { + assert!(p.snapshot().is_none()); + assert_eq!(p.torrent_id(), None); + assert!(p.selected_node().is_none()); + }); + } + + #[gpui::test] + fn toggle_without_a_loaded_torrent_is_ignored(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx); + let pane = cx.new(DetailPane::new); + pane.update(cx, |pane, cx| { + pane.toggle_file(0, cx); + pane.toggle_node("file:0", cx); + }); + cx.run_until_parked(); + assert_eq!( + count_posts(&runtime, &server, "/torrents/7/files/0/included"), + 0 + ); + } } diff --git a/crates/rtbit-fluent/src/screens/main_screen.rs b/crates/rtbit-fluent/src/screens/main_screen.rs index e95502d7..c9871e79 100644 --- a/crates/rtbit-fluent/src/screens/main_screen.rs +++ b/crates/rtbit-fluent/src/screens/main_screen.rs @@ -2,47 +2,214 @@ //! //! Rows come from the bridge snapshot poll. Sorting happens here, not in //! `DataTable`: `DataTable` sorts by display text, which orders sizes and -//! speeds wrongly. `DataTable` has no right-click hook, so the row context -//! menu opens on a double click. +//! speeds wrongly. `DataTable` has no right-click hook, so the screen catches the +//! right click on the table and maps the pointer to a row from the table geometry. +use std::cell::Cell; use std::cmp::Ordering; +use std::collections::HashSet; +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::Arc; -use fluent_core::AsyncResource; +use fluent_core::{AsyncResource, ThemeProvider as _}; use fluent_layout::{ - ConfirmModal, ContextMenu, DataCell, DataColumn, DataRow, DataSortDirection, DataTable, - MessageIntent, Toast, ToastHost, ToastStack, Toolbar, ToolbarItem, + ConfirmModal, ContextMenu, ContextMenuItem, DataCell, DataColumn, DataRow, DataSortDirection, + DataTable, MessageIntent, ModalStack, Toast, ToastHost, ToastStack, }; -use fluent_primitives::{Label, LabelSize}; +use fluent_primitives::{Label, LabelSize, TextInput, Tooltip}; use gpui::{ - App, AsyncApp, ClickEvent, Context, Entity, IntoElement, Pixels, Point, Render, SharedString, - Subscription, Task, WeakEntity, Window, div, prelude::*, + App, AsyncApp, Bounds, ClipboardItem, Context, Entity, FocusHandle, Focusable as _, Hsla, + IntoElement, KeyBinding, KeyDownEvent, MouseButton, MouseDownEvent, Pixels, Point, Render, + SharedString, Subscription, Task, WeakEntity, Window, actions, canvas, div, prelude::*, px, }; use rtbit_client::{Torrent, TorrentState}; use rtbit_ui_core::{AddRequest, AddSource, RowAction, Snapshot, StatusFilter, format, map_error}; -use crate::bridge::{Bridge, Poller, fetch_snapshot}; +use crate::bridge::{Bridge, LOST_AFTER_FAILURES, PollHealth, Poller, fetch_snapshot}; use crate::screens::add_dialog::{AddDialog, AddDialogEvent}; +use crate::screens::bulk_edit_dialog::{BulkEditDialog, BulkEditDialogEvent, BulkEditRequest}; use crate::screens::detail::DetailPane; +use crate::screens::torrent_edit_dialog::{ + TorrentEditDialog, TorrentEditDialogEvent, TorrentEditKind, +}; +use crate::table_prefs::TablePrefs; use crate::widgets::status_bar::StatusBar; +actions!( + main_screen, + [ + OpenAdd, + PasteMagnet, + DeleteSelected, + TogglePause, + SelectAll, + FocusFilter, + Refresh, + OpenContextMenu + ] +); + +/// Daemon reachability changes the shell reacts to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MainEvent { + /// Polls keep failing; the next attempt is in `retry_in_secs`. + DaemonLost { retry_in_secs: u64 }, + /// A poll succeeded after the daemon was reported lost. + DaemonRecovered, + /// The daemon refused the session; show the connect screen. + Unauthorised, +} + +/// Key context of the main screen. Its shortcuts are bound to this context only. +pub const KEY_CONTEXT: &str = "MainScreen"; + +#[derive(Clone, Copy)] +struct ToolbarButtonSpec { + id: &'static str, + icon: &'static str, + label: &'static str, +} + +const TOOLBAR_BUTTONS: [ToolbarButtonSpec; 4] = [ + ToolbarButtonSpec { + id: "add", + icon: "+", + label: "Add torrent", + }, + ToolbarButtonSpec { + id: "resume", + icon: "▶", + label: "Resume torrent", + }, + ToolbarButtonSpec { + id: "pause", + icon: "Ⅱ", + label: "Pause torrent", + }, + ToolbarButtonSpec { + id: "delete", + icon: "×", + label: "Delete torrent", + }, +]; + +/// Keep the command names in one place so an icon-only toolbar can never lose +/// the text used for its tooltip and accessible name. +fn toolbar_buttons() -> &'static [ToolbarButtonSpec; 4] { + &TOOLBAR_BUTTONS +} + +fn icon_toolbar_button( + spec: ToolbarButtonSpec, + background: Hsla, + focus_ring: Hsla, + on_activate: impl Fn(&mut Window, &mut App) + 'static, +) -> impl IntoElement { + debug_assert!( + !spec.label.trim().is_empty(), + "toolbar button needs a label" + ); + let on_activate = Arc::new(on_activate); + let click = on_activate.clone(); + let key = on_activate; + let button = div() + .id(spec.id) + .focusable() + .tab_stop(true) + .focus(move |style| style.border_color(focus_ring)) + .on_key_down(move |event: &KeyDownEvent, window, cx| { + if matches!(event.keystroke.key.as_str(), "enter" | "space") { + key(window, cx); + cx.stop_propagation(); + } + }) + .on_click(move |_, window, cx| click(window, cx)) + .flex() + .items_center() + .justify_center() + .size(px(32.0)) + .border_2() + .border_color(background) + .rounded(px(4.0)) + .cursor_pointer() + .child(spec.icon); + Tooltip::new(spec.label).trigger(button) +} + +fn toolbar_activate( + me: &Entity, + action: RowAction, +) -> impl Fn(&mut Window, &mut App) + 'static { + let me = me.clone(); + move |_, app| dispatch(&me, app, action) +} + +/// Contexts for the shortcuts that would clash with typing: Space, Delete, Ctrl+A and Ctrl+V +/// stay with a focused text field. +const OUTSIDE_TEXT_INPUT: &str = "MainScreen && !TextInput"; + +/// One line listing the shortcuts, shown under the table. +pub const SHORTCUT_HELP: &str = "Ctrl+O add · Ctrl+V paste magnet · Space pause/resume · Delete remove · Ctrl+A select all · Ctrl+F filter · F5 refresh · Shift+F10 menu"; + +/// Bind the main-screen shortcuts. Call once at startup. +pub fn bind_keys(cx: &mut App) { + cx.bind_keys([ + KeyBinding::new("ctrl-o", OpenAdd, Some(KEY_CONTEXT)), + KeyBinding::new("ctrl-v", PasteMagnet, Some(OUTSIDE_TEXT_INPUT)), + KeyBinding::new("delete", DeleteSelected, Some(OUTSIDE_TEXT_INPUT)), + KeyBinding::new("space", TogglePause, Some(OUTSIDE_TEXT_INPUT)), + KeyBinding::new("ctrl-a", SelectAll, Some(OUTSIDE_TEXT_INPUT)), + KeyBinding::new("ctrl-f", FocusFilter, Some(KEY_CONTEXT)), + KeyBinding::new("f5", Refresh, Some(KEY_CONTEXT)), + KeyBinding::new("shift-f10", OpenContextMenu, Some(OUTSIDE_TEXT_INPUT)), + KeyBinding::new("menu", OpenContextMenu, Some(OUTSIDE_TEXT_INPUT)), + ]); +} + /// Torrent table, toolbar and context menu. Owns the snapshot the poll fills. pub struct MainScreen { pub(crate) snapshot: AsyncResource, filter: StatusFilter, + category_filter: Option, + tag_filter: Option, + tracker_filter: Option, status: Entity, /// Detail pane for the selected row. detail: Entity, /// Selected row id (the daemon id, or the info hash when the daemon omits one). selected: Option, + /// Rows marked by Select all or a Shift+arrow range. + marked: HashSet, + /// Fixed end of a Shift+arrow range selection. + selection_anchor: Option, + detail_open: bool, + /// Case-insensitive name filter typed in the filter box. + query: String, + filter_input: Entity, + focus: FocusHandle, + autofocused: bool, + /// Set while the delete confirmation is on screen. + delete_prompt: bool, sort: Option<(String, DataSortDirection)>, + /// Column widths overriding the defaults, persisted with the sort. + prefs: TablePrefs, + prefs_path: Option, menu: Option>, + /// Where the table was last painted, so a pointer position maps to a row. + table_geometry: Rc>, action_error: Option, applied_generation: u64, /// Add-torrent dialog, while it is open. add: Option>, _add_events: Option, _add_send: Option>, + bulk_edit: Option>, + _bulk_edit_events: Option, + torrent_edit: Option>, + _torrent_edit_events: Option, toasts: Entity, + daemon_lost: bool, pub(crate) _poller: Option, pub(crate) _load: Option>, _action: Option>, @@ -51,35 +218,137 @@ pub struct MainScreen { impl MainScreen { #[must_use] pub fn new(status: Entity, cx: &mut Context) -> Self { + let me = cx.weak_entity(); + let filter_input = cx.new(|_| { + TextInput::new() + .placeholder("Filter (Ctrl+F)") + .on_change(move |text, app| { + let _ = me.update(app, |screen, cx| screen.set_query(text.to_string(), cx)); + }) + }); + // Tests must not read or write the real config directory. + let prefs_path = if cfg!(test) { + None + } else { + TablePrefs::default_path() + }; + let prefs = prefs_path + .as_deref() + .map(TablePrefs::load) + .unwrap_or_default(); + let sort = prefs.sort.clone().map(|(column, ascending)| { + let direction = if ascending { + DataSortDirection::Ascending + } else { + DataSortDirection::Descending + }; + (column, direction) + }); Self { snapshot: AsyncResource::new(), - filter: StatusFilter::All, + filter: prefs + .status + .as_deref() + .and_then(parse_status) + .unwrap_or(StatusFilter::All), + category_filter: prefs.category.clone(), + tag_filter: prefs.tag.clone(), + tracker_filter: prefs.tracker.clone(), status, detail: cx.new(DetailPane::new), selected: None, - sort: None, + marked: HashSet::new(), + selection_anchor: None, + detail_open: false, + query: String::new(), + filter_input, + focus: cx.focus_handle(), + autofocused: false, + delete_prompt: false, + sort, + prefs, + prefs_path, menu: None, + table_geometry: Rc::default(), action_error: None, applied_generation: 0, add: None, _add_events: None, _add_send: None, + bulk_edit: None, + _bulk_edit_events: None, + torrent_edit: None, + _torrent_edit_events: None, toasts: cx.new(ToastHost::new), + daemon_lost: false, _poller: None, _load: None, _action: None, } } + #[cfg(test)] + pub(crate) fn start_polling_for_test(&mut self, cx: &mut Context) { + self.ensure_polling(cx); + } + + #[cfg(test)] + pub(crate) fn add_open(&self) -> bool { + self.add.is_some() + } + + /// Whether the daemon is reported lost. Actions that need it are refused meanwhile. + #[must_use] + pub fn daemon_lost(&self) -> bool { + self.daemon_lost + } + + /// Fold a poll outcome into the lost state and tell the shell on a change. + fn on_poll_health(&mut self, health: PollHealth, cx: &mut Context) { + match health { + PollHealth::Ok => { + if std::mem::take(&mut self.daemon_lost) { + cx.emit(MainEvent::DaemonRecovered); + cx.notify(); + } + } + PollHealth::Failing { failures, retry_in } if failures >= LOST_AFTER_FAILURES => { + self.daemon_lost = true; + cx.emit(MainEvent::DaemonLost { + retry_in_secs: retry_in.as_secs(), + }); + cx.notify(); + } + PollHealth::Failing { .. } => {} + PollHealth::Unauthorised => { + self.daemon_lost = false; + self._poller = None; + cx.emit(MainEvent::Unauthorised); + } + } + } + + /// Refuse an action that needs the daemon while it is lost. + fn refuse_if_lost(&mut self, cx: &mut Context) -> bool { + if self.daemon_lost { + self.action_error = Some("Lost connection to daemon.".to_owned()); + cx.notify(); + } + self.daemon_lost + } + /// Open the add-torrent dialog over the table. pub(crate) fn open_add(&mut self, cx: &mut Context) { + if self.refuse_if_lost(cx) { + return; + } self.menu = None; let dialog = cx.new(AddDialog::new); let events = cx.subscribe( &dialog, |screen: &mut Self, dialog: Entity, event: &AddDialogEvent, cx| match event { AddDialogEvent::Cancel => screen.close_add(cx), - AddDialogEvent::Submit(request) => screen.send_add(dialog, request.clone(), cx), + AddDialogEvent::Submit(requests) => screen.send_add(dialog, requests.clone(), cx), }, ); self.add = Some(dialog); @@ -87,16 +356,92 @@ impl MainScreen { cx.notify(); } + pub(crate) fn open_add_with_magnet(&mut self, magnet: &str, cx: &mut Context) { + self.open_add(cx); + if let Some(dialog) = &self.add { + dialog.update(cx, |dialog, cx| dialog.prefill_magnet(magnet.trim(), cx)); + } + } + + pub(crate) fn open_add_with_paths(&mut self, paths: &[PathBuf], cx: &mut Context) { + self.open_add(cx); + if paths.is_empty() { + return; + } + let Some(dialog) = &self.add else { return }; + let dialog = dialog.clone(); + let paths = paths.to_owned(); + let task = cx.background_executor().spawn(async move { + paths + .into_iter() + .map(|path| { + let name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("torrent") + .to_owned(); + (name, std::fs::read(path)) + }) + .collect::>() + }); + cx.spawn(async move |_, cx| { + let results = task.await; + dialog.update(cx, |dialog, cx| { + let mut files = Vec::new(); + for (name, result) in results { + match result { + Ok(bytes) => files.push((name, bytes)), + Err(err) => { + dialog.set_error(format!("could not read torrent: {err}"), cx); + return; + } + } + } + dialog.set_files(files, cx); + }); + }) + .detach(); + } + + pub(crate) fn show_not_torrent_toast(&mut self, cx: &mut Context) { + ToastStack::push( + Toast::new("not-torrent", "That drop is not a torrent.").intent(MessageIntent::Warning), + cx, + ); + } + + /// Open the add dialog with the clipboard's magnet link filled in. Does nothing when the + /// clipboard holds anything else. + fn paste_magnet(&mut self, cx: &mut Context) { + let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { + return; + }; + let magnet = text.trim(); + if !magnet.starts_with("magnet:?") { + return; + } + self.open_add(cx); + if let Some(dialog) = &self.add { + dialog.update(cx, |dialog, cx| dialog.prefill_magnet(magnet, cx)); + } + } + /// Close the add dialog. Nothing is sent. fn close_add(&mut self, cx: &mut Context) { self.add = None; self._add_events = None; + self.autofocused = false; cx.notify(); } /// Send an add request through the bridge. The dialog closes on success and /// shows the daemon's reason on failure. - fn send_add(&mut self, dialog: Entity, request: AddRequest, cx: &mut Context) { + fn send_add( + &mut self, + dialog: Entity, + requests: Vec, + cx: &mut Context, + ) { if !cx.has_global::() { dialog.update(cx, |dialog, cx| { dialog.refused("Not connected to a daemon.".to_owned(), cx); @@ -104,31 +449,39 @@ impl MainScreen { return; } let rx = cx.global::().call(move |client| async move { - let AddRequest { + let mut client = client.lock().await; + for AddRequest { source, save_path, category, start_paused, - } = request; - let category = (!category.is_empty()).then_some(category); - let mut client = client.lock().await; - match source { - AddSource::Magnet(magnet) => { - client - .add_magnet(&magnet, Some(&save_path), category.as_deref(), start_paused) - .await - } - AddSource::File { bytes, .. } => { - client - .add_torrent_file( - &bytes, - Some(&save_path), - category.as_deref(), - start_paused, - ) - .await + } in requests + { + let category = (!category.is_empty()).then_some(category); + match source { + AddSource::Magnet(magnet) => { + client + .add_magnet( + &magnet, + Some(&save_path), + category.as_deref(), + start_paused, + ) + .await? + } + AddSource::File { bytes, .. } => { + client + .add_torrent_file( + &bytes, + Some(&save_path), + category.as_deref(), + start_paused, + ) + .await? + } } } + Ok(()) }); self._add_send = Some( cx.spawn(async move |view: WeakEntity, cx: &mut AsyncApp| { @@ -156,8 +509,84 @@ impl MainScreen { /// Set the sidebar status filter the table shows. pub fn set_filter(&mut self, filter: StatusFilter, cx: &mut Context) { self.filter = filter; + self.prefs.status = (filter != StatusFilter::All).then(|| filter.label().to_owned()); + self.save_prefs(); + cx.notify(); + } + + fn set_category(&mut self, value: Option, cx: &mut Context) { + self.category_filter = value; + self.save_filter_prefs(); + cx.notify(); + } + fn set_tag(&mut self, value: Option, cx: &mut Context) { + self.tag_filter = value; + self.save_filter_prefs(); + cx.notify(); + } + fn set_tracker(&mut self, value: Option, cx: &mut Context) { + self.tracker_filter = value; + self.save_filter_prefs(); cx.notify(); } + fn save_filter_prefs(&mut self) { + self.prefs.category = self.category_filter.clone(); + self.prefs.tag = self.tag_filter.clone(); + self.prefs.tracker = self.tracker_filter.clone(); + self.save_prefs(); + } + + /// Filter the table to names containing `query`, ignoring case. + pub(crate) fn set_query(&mut self, query: String, cx: &mut Context) { + self.query = query.to_lowercase(); + cx.notify(); + } + + /// Mark every visible row, so the next command acts on all of them. + fn select_all(&mut self, cx: &mut Context) { + self.marked = self.visible_rows().into_iter().map(row_id).collect(); + cx.notify(); + } + + /// Number of rows marked by Select all. + #[must_use] + pub fn marked_count(&self) -> usize { + self.marked.len() + } + + /// Torrents a command applies to: the marked rows, else the selected row. + fn targets(&self) -> Vec<&Torrent> { + if self.marked.is_empty() { + return self.selected_torrent().into_iter().collect(); + } + self.snapshot.value().map_or_else(Vec::new, |snapshot| { + snapshot + .torrents + .torrents + .iter() + .filter(|torrent| self.marked.contains(&row_id(torrent))) + .collect() + }) + } + + /// Pause what is running and resume what is paused, for each target. + fn toggle_pause(&mut self, cx: &mut Context) { + let jobs: Vec<(u64, Job)> = self + .targets() + .into_iter() + .filter_map(|torrent| { + let paused = torrent.stats.as_ref().and_then(|stats| stats.state) + == Some(TorrentState::Paused); + let action = if paused { + RowAction::Start + } else { + RowAction::Pause + }; + torrent.id.map(|id| (id, Job::Row(action))) + }) + .collect(); + self.run_jobs(jobs, cx); + } /// Number of torrents in the last good snapshot, before filters. #[must_use] @@ -180,6 +609,9 @@ impl MainScreen { /// Select a row and load its detail. A row with no daemon id clears the pane. fn select_row(&mut self, row: &str, cx: &mut Context) { self.selected = Some(row.to_owned()); + self.marked.clear(); + self.selection_anchor = Some(row.to_owned()); + self.detail_open = true; let id = self.selected_torrent().and_then(|torrent| torrent.id); self.detail.update(cx, |pane, cx| match id { Some(id) => pane.select_torrent(id, cx), @@ -188,6 +620,94 @@ impl MainScreen { cx.notify(); } + /// Move the keyboard cursor through the visible, sorted rows. + fn move_selection( + &mut self, + movement: SelectionMovement, + extend: bool, + cx: &mut Context, + ) { + let rows = self + .visible_rows() + .into_iter() + .map(row_id) + .collect::>(); + if rows.is_empty() { + return; + } + let current = self + .selected + .as_ref() + .and_then(|id| rows.iter().position(|row| row == id)) + .unwrap_or(0); + let next = match movement { + SelectionMovement::Previous => current.saturating_sub(1), + SelectionMovement::Next => (current + 1).min(rows.len() - 1), + SelectionMovement::First => 0, + SelectionMovement::Last => rows.len() - 1, + }; + let next_row = rows[next].clone(); + let anchor = if extend { + self.selection_anchor + .clone() + .unwrap_or_else(|| rows[current].clone()) + } else { + next_row.clone() + }; + self.selected = Some(next_row.clone()); + self.selection_anchor = Some(anchor.clone()); + self.marked.clear(); + if extend { + let anchor_index = rows.iter().position(|row| row == &anchor).unwrap_or(next); + let (start, end) = if anchor_index <= next { + (anchor_index, next) + } else { + (next, anchor_index) + }; + self.marked.extend(rows[start..=end].iter().cloned()); + } else { + self.marked.insert(next_row); + } + cx.notify(); + } + + fn open_detail(&mut self, cx: &mut Context) { + let Some(id) = self.selected_torrent().and_then(|torrent| torrent.id) else { + return; + }; + self.detail_open = true; + self.detail + .update(cx, |pane, cx| pane.select_torrent(id, cx)); + cx.notify(); + } + + fn close_detail(&mut self, cx: &mut Context) { + if self.detail_open { + self.detail_open = false; + cx.notify(); + } + } + + fn handle_table_key(&mut self, event: &KeyDownEvent, cx: &mut Context) -> bool { + let movement = match event.keystroke.key.as_str() { + "up" => Some(SelectionMovement::Previous), + "down" => Some(SelectionMovement::Next), + "home" => Some(SelectionMovement::First), + "end" => Some(SelectionMovement::Last), + "return" => { + self.open_detail(cx); + return true; + } + "escape" => { + self.close_detail(cx); + return true; + } + _ => return false, + }; + self.move_selection(movement.unwrap(), event.keystroke.modifiers.shift, cx); + true + } + /// Ask for a fresh snapshot now, in addition to the poll. fn refresh(&mut self, cx: &mut Context) { if !cx.has_global::() { @@ -196,10 +716,18 @@ impl MainScreen { self._load = Some(Bridge::load(self, cx, snapshot_of, fetch_snapshot)); } + pub(crate) fn on_poll_health_cb(screen: &mut Self, health: PollHealth, cx: &mut Context) { + screen.on_poll_health(health, cx); + } + /// Start the poll once a bridge is installed. Idempotent. fn ensure_polling(&mut self, cx: &mut Context) { if self._poller.is_none() && cx.has_global::() { - self._poller = Some(Bridge::poll_snapshot(cx, snapshot_of)); + self._poller = Some(Bridge::poll_snapshot( + cx, + snapshot_of, + Self::on_poll_health_cb, + )); } } @@ -216,34 +744,325 @@ impl MainScreen { } } - fn open_menu(&mut self, row: &str, position: Point, cx: &mut Context) { - self.select_row(row, cx); + /// Right click at `position`: act on the row under the pointer. A row outside the marked + /// set becomes the selection first; a row inside it keeps the whole marked set. + fn open_menu_at_pointer(&mut self, position: Point, cx: &mut Context) { + let Some(row) = self.row_at(position).map(row_id) else { + return; + }; + if !self.marked.contains(&row) { + self.select_row(&row, cx); + } + self.open_menu(position, cx); + } + + /// Shift+F10 or the Menu key: open the menu beside the selected row. + fn open_menu_for_selection(&mut self, cx: &mut Context) { + let geometry = self.table_geometry.get(); + let index = self.selected.as_deref().and_then(|selected| { + self.visible_rows() + .iter() + .position(|torrent| row_id(torrent) == selected) + }); + let Some(index) = index else { + self.action_error = Some("Select a torrent first.".to_owned()); + cx.notify(); + return; + }; + let position = geometry.row_origin(index); + self.open_menu(position, cx); + } + + /// Table row under a pointer position, from the painted geometry. + fn row_at(&self, position: Point) -> Option<&Torrent> { + let index = self.table_geometry.get().row_index(position)?; + self.visible_rows().into_iter().nth(index) + } + + /// Build the menu for the current targets. Items that cannot apply are disabled. + fn open_menu(&mut self, position: Point, cx: &mut Context) { + let targets = self.targets(); + let available = MenuAvailability::of(&targets); + let categories = category_names(self.snapshot.value().map(|s| &s.torrents.torrents[..])); let screen = cx.entity(); - let (resume, pause, delete) = (screen.clone(), screen.clone(), screen); - let menu = cx.new(|_| { - ContextMenu::build(position) - .action("Resume", move |_, _, app| { - dispatch(&resume, app, RowAction::Start); - }) - .action("Pause", move |_, _, app| { - dispatch(&pause, app, RowAction::Pause); - }) - .separator() - .action("Delete…", move |_, _, app| { - dispatch(&delete, app, RowAction::Delete); - }) + let item = |label: &str, enabled: bool, command: MenuCommand| { + let screen = screen.clone(); + ContextMenuItem::action(label.to_owned(), move |_, _, app| { + run_menu_command(&screen, app, command.clone()); + }) + .disabled(!enabled) + }; + let mut category_items = vec![item( + "No category", + available.category, + MenuCommand::SetCategory(None), + )]; + category_items.extend(categories.into_iter().map(|name| { + item( + &name.clone(), + available.category, + MenuCommand::SetCategory(Some(name)), + ) + })); + let menu = ContextMenu::build(position) + .item(item("Start", available.start, MenuCommand::Start)) + .item(item("Stop", available.stop, MenuCommand::Stop)) + .item(item( + "Force recheck", + available.recheck, + MenuCommand::Recheck, + )) + .separator() + .item( + ContextMenuItem::submenu("Set category", category_items) + .disabled(!available.category), + ) + .item(item( + "Edit category and tags…", + !targets.is_empty(), + MenuCommand::BulkEdit, + )) + .item(item( + "Set location…", + available.edit, + MenuCommand::SetLocation, + )) + .item(item("Rename…", available.edit, MenuCommand::Rename)) + .separator() + .item(item( + "Copy magnet link", + available.copy, + MenuCommand::CopyMagnet, + )) + .item(item("Copy name", available.copy, MenuCommand::CopyName)) + .separator() + .item(item("Delete…", available.delete, MenuCommand::Delete)); + self.menu = Some(cx.new(|_| menu)); + cx.notify(); + } + + /// Run a context-menu command on the whole selection. + fn run_menu_command(&mut self, command: MenuCommand, cx: &mut Context) { + self.menu = None; + match command { + MenuCommand::BulkEdit => self.open_bulk_edit(cx), + MenuCommand::SetLocation => self.open_torrent_edit(TorrentEditKind::Location, cx), + MenuCommand::Rename => self.open_torrent_edit(TorrentEditKind::Name, cx), + MenuCommand::Delete => self.request(RowAction::Delete, cx), + MenuCommand::CopyMagnet | MenuCommand::CopyName => { + let text = copy_text(&self.targets(), &command); + match text { + Some(text) => { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + self.action_error = None; + ToastStack::push( + Toast::new("copied", "Copied to clipboard.") + .intent(MessageIntent::Success), + cx, + ); + } + None => self.action_error = Some("Select a torrent first.".to_owned()), + } + cx.notify(); + } + command => { + if self.refuse_if_lost(cx) { + return; + } + let jobs = menu_jobs(&self.targets(), &command); + self.run_jobs(jobs, cx); + } + } + } + + fn open_torrent_edit(&mut self, kind: TorrentEditKind, cx: &mut Context) { + let Some(target) = self.targets().first().copied() else { + self.action_error = Some("Select a torrent first.".to_owned()); + cx.notify(); + return; + }; + let initial = match kind { + TorrentEditKind::Location => target.output_folder.clone(), + TorrentEditKind::Name => torrent_name(target), + }; + let dialog = cx.new(|cx| TorrentEditDialog::new(kind, initial, cx)); + let events = cx.subscribe(&dialog, move |screen, dialog, event, cx| match event { + TorrentEditDialogEvent::Cancel => { + screen.torrent_edit = None; + screen._torrent_edit_events = None; + cx.notify(); + } + TorrentEditDialogEvent::Submit(value) => { + screen.send_torrent_edit(dialog.clone(), kind, value.clone(), cx); + } }); - self.menu = Some(menu); + self.torrent_edit = Some(dialog); + self._torrent_edit_events = Some(events); cx.notify(); } + fn send_torrent_edit( + &mut self, + dialog: Entity, + kind: TorrentEditKind, + value: String, + cx: &mut Context, + ) { + let Some(id) = self.targets().first().and_then(|torrent| torrent.id) else { + dialog.update(cx, |dialog, cx| { + dialog.set_error("This torrent has no daemon id.".to_owned(), cx) + }); + return; + }; + let Some(bridge) = cx.try_global::() else { + dialog.update(cx, |dialog, cx| { + dialog.set_error("Not connected to a daemon.".to_owned(), cx) + }); + return; + }; + let rx = bridge.call(move |client| async move { + let mut client = client.lock().await; + match kind { + TorrentEditKind::Location => client.set_torrent_location(id, &value).await, + TorrentEditKind::Name => client.rename_torrent(id, &value).await, + } + }); + self._action = Some(cx.spawn(async move |view, cx| { + let reply = match rx.await { + Ok(Ok(())) => Ok(()), + Ok(Err(error)) => Err(map_error(&error)), + Err(_) => Err("request cancelled".to_owned()), + }; + let _ = view.update(cx, |screen, cx| match reply { + Ok(()) => { + screen.torrent_edit = None; + screen._torrent_edit_events = None; + screen.refresh(cx); + } + Err(error) => dialog.update(cx, |dialog, cx| dialog.set_error(error, cx)), + }); + })); + } + + fn open_bulk_edit(&mut self, cx: &mut Context) { + let count = self.targets().len(); + if count == 0 { + self.action_error = Some("Select a torrent first.".to_owned()); + cx.notify(); + return; + } + let categories = category_names(self.snapshot.value().map(|s| &s.torrents.torrents[..])); + let dialog = cx.new(|cx| BulkEditDialog::new(count, categories, cx)); + let events = cx.subscribe(&dialog, |screen, _, event, cx| match event { + BulkEditDialogEvent::Cancel => { + screen.bulk_edit = None; + screen._bulk_edit_events = None; + cx.notify(); + } + BulkEditDialogEvent::Submit(request) => screen.send_bulk_edit(request.clone(), cx), + }); + self.bulk_edit = Some(dialog); + self._bulk_edit_events = Some(events); + cx.notify(); + } + + fn send_bulk_edit(&mut self, request: BulkEditRequest, cx: &mut Context) { + let targets = self + .targets() + .into_iter() + .filter_map(|torrent| torrent.id.map(|id| (id, torrent.name.clone()))) + .collect::>(); + let ids = targets.iter().map(|(id, _)| *id).collect::>(); + let names = targets + .into_iter() + .collect::>(); + let Some(bridge) = cx.try_global::() else { + self.action_error = Some("Not connected to a daemon.".to_owned()); + cx.notify(); + return; + }; + self.bulk_edit = None; + self._bulk_edit_events = None; + let rx = bridge.call(move |client| async move { + let mut client = client.lock().await; + let mut failures = Vec::new(); + if let Some(category) = request.category.as_ref() { + failures.extend( + client + .set_torrents_category(&ids, category.as_deref()) + .await? + .failures, + ); + } + if !request.add_tags.is_empty() { + failures.extend( + client + .add_torrents_tags(&ids, &request.add_tags) + .await? + .failures, + ); + } + if !request.remove_tags.is_empty() { + failures.extend( + client + .remove_torrents_tags(&ids, &request.remove_tags) + .await? + .failures, + ); + } + Ok(failures) + }); + self._action = Some(cx.spawn(async move |view, cx| { + let result = match rx.await { + Ok(result) => result, + Err(_) => Err(rtbit_client::Error::Unreachable( + "request cancelled".to_owned(), + )), + }; + let _ = view.update(cx, |screen, cx| match result { + Ok(failures) if failures.is_empty() => { + screen.action_error = None; + screen.refresh(cx); + } + Ok(failures) => { + screen.action_error = Some( + failures + .into_iter() + .map(|failure| { + let name = names + .get(&failure.id) + .and_then(|name| name.as_deref()) + .unwrap_or("unnamed torrent"); + format!("{name}: {}", failure.error) + }) + .collect::>() + .join("; "), + ); + cx.notify(); + } + Err(error) => { + screen.action_error = Some(map_error(&error)); + cx.notify(); + } + }); + })); + } + /// Run a toolbar or menu command. Delete asks first. fn request(&mut self, action: RowAction, cx: &mut Context) { self.menu = None; - let Some(name) = self.selected_torrent().map(torrent_name) else { - self.action_error = Some("Select a torrent first.".to_owned()); - cx.notify(); + if self.refuse_if_lost(cx) { return; + } + let targets = self.targets(); + let name = match targets.as_slice() { + [] => { + self.action_error = Some("Select a torrent first.".to_owned()); + cx.notify(); + return; + } + [only] => format!("“{}”", torrent_name(only)), + many => format!("{} torrents", many.len()), }; if action != RowAction::Delete { self.act(action, cx); @@ -252,36 +1071,59 @@ impl MainScreen { let me = cx.entity(); ConfirmModal::new( "Delete torrent?", - format!("“{name}” will be removed from the daemon."), + format!("{name} will be removed from the daemon."), ) .confirm_label("Delete") .on_confirm(move |app| { confirm_delete(&me, app); }) .show(cx); + self.delete_prompt = true; cx.notify(); } - /// Send one row command to the daemon, then refresh the table. + /// Send one row command for every target, then refresh the table. fn act(&mut self, action: RowAction, cx: &mut Context) { + let jobs = self + .targets() + .into_iter() + .filter_map(|torrent| torrent.id.map(|id| (id, Job::Row(action)))) + .collect(); + self.run_jobs(jobs, cx); + } + + /// Send row commands to the daemon in order, then refresh the table. + fn run_jobs(&mut self, jobs: Vec<(u64, Job)>, cx: &mut Context) { self.menu = None; - let Some(id) = self.selected_torrent().and_then(|torrent| torrent.id) else { + self.delete_prompt = false; + if jobs.is_empty() { self.action_error = Some("Select a torrent first.".to_owned()); cx.notify(); return; - }; + } if !cx.has_global::() { self.action_error = Some("Not connected to a daemon.".to_owned()); cx.notify(); return; } + let deleting = jobs + .iter() + .any(|(_, job)| *job == Job::Row(RowAction::Delete)); let rx = cx.global::().call(move |client| async move { let mut client = client.lock().await; - match action { - RowAction::Start => client.start_torrent(id).await, - RowAction::Pause => client.pause_torrent(id).await, - RowAction::Delete => client.delete_torrent(id).await, + for (id, job) in jobs { + let sent = match job { + Job::Row(RowAction::Start) => client.start_torrent(id).await, + Job::Row(RowAction::Pause) => client.pause_torrent(id).await, + Job::Row(RowAction::Delete) => client.delete_torrent(id).await, + Job::Recheck => client.recheck_torrent(id).await, + Job::Category(category) => { + client.set_torrent_category(id, category.as_deref()).await + } + }; + sent?; } + Ok(()) }); self._action = Some( cx.spawn(async move |view: WeakEntity, cx: &mut AsyncApp| { @@ -293,8 +1135,9 @@ impl MainScreen { let _ = view.update(cx, |screen, cx| match reply { Ok(()) => { screen.action_error = None; - if action == RowAction::Delete { + if deleting { screen.selected = None; + screen.marked.clear(); screen.detail.update(cx, |pane, cx| pane.clear(cx)); } screen.refresh(cx); @@ -310,9 +1153,33 @@ impl MainScreen { fn sort_by(&mut self, column: &str, direction: DataSortDirection, cx: &mut Context) { self.sort = Some((column.to_owned(), direction)); + self.prefs.sort = Some(( + column.to_owned(), + matches!(direction, DataSortDirection::Ascending), + )); + self.save_prefs(); cx.notify(); } + /// Remember a column width (clamped to the minimum) and persist it. + #[allow(dead_code)] // No resize handle in `DataTable` yet; widths come from the file. + pub(crate) fn set_column_width(&mut self, column: &str, width: f32, cx: &mut Context) { + let width = width.max(crate::table_prefs::MIN_COLUMN_WIDTH); + self.prefs.widths.insert(column.to_owned(), width); + self.save_prefs(); + cx.notify(); + } + + fn column_width(&self, column: &str, default: f32) -> f32 { + self.prefs.widths.get(column).copied().unwrap_or(default) + } + + fn save_prefs(&self) { + if let Some(path) = &self.prefs_path { + let _ = self.prefs.save(path); + } + } + /// Rows for the table: filtered by the sidebar status, then sorted by the column. fn visible_rows(&self) -> Vec<&Torrent> { let Some(snapshot) = self.snapshot.value() else { @@ -323,6 +1190,30 @@ impl MainScreen { .torrents .iter() .filter(|torrent| matches_filter(self.filter, torrent)) + .filter(|torrent| { + self.category_filter + .as_deref() + .is_none_or(|v| torrent.category.as_deref() == Some(v)) + }) + .filter(|torrent| { + self.tag_filter.as_deref().is_none_or(|v| { + torrent + .tags + .as_ref() + .is_some_and(|tags| tags.iter().any(|tag| tag == v)) + }) + }) + .filter(|torrent| { + self.tracker_filter.as_deref().is_none_or(|v| { + torrent.trackers.as_ref().is_some_and(|urls| { + urls.iter() + .any(|url| tracker_host(url).as_deref() == Some(v)) + }) + }) + }) + .filter(|torrent| { + self.query.is_empty() || torrent_name(torrent).to_lowercase().contains(&self.query) + }) .collect(); if let Some((column, direction)) = &self.sort { rows.sort_by(|a, b| { @@ -338,17 +1229,19 @@ impl MainScreen { fn table(&self, me: &Entity) -> DataTable { let columns = [ - DataColumn::new("name", "Name").width(320.0).sortable(true), - DataColumn::new("size", "Size").width(90.0).sortable(true), - DataColumn::new("progress", "Progress") - .width(90.0) - .sortable(true), - DataColumn::new("speed", "Down").width(110.0).sortable(true), - DataColumn::new("eta", "ETA").width(90.0).sortable(true), - DataColumn::new("state", "State") - .width(110.0) - .sortable(true), - ]; + ("name", "Name", 320.0), + ("size", "Size", 90.0), + ("progress", "Progress", 90.0), + ("speed", "Down", 110.0), + ("upload", "Up", 110.0), + ("eta", "ETA", 90.0), + ("state", "State", 110.0), + ] + .map(|(id, title, width)| { + DataColumn::new(id, title) + .width(self.column_width(id, width)) + .sortable(true) + }); let rows = self.visible_rows().into_iter().map(|torrent| { DataRow::new( row_id(torrent), @@ -357,6 +1250,7 @@ impl MainScreen { DataCell::new(size_text(torrent)), DataCell::new(progress_text(torrent)), DataCell::new(speed_text(torrent)), + DataCell::new(upload_text(torrent)), DataCell::new(eta_text(torrent)), DataCell::new(state_of(torrent)), ], @@ -374,27 +1268,160 @@ impl MainScreen { let id = id.to_string(); select_me.update(app, |screen, cx| screen.select_row(&id, cx)); }) - .on_row_click(move |id: SharedString, event: &ClickEvent, _, app| { - if event.click_count() < 2 { - return; - } + .on_row_click(move |id: SharedString, _, _, app| { let id = id.to_string(); - let position = event.position(); - click_me.update(app, |screen, cx| screen.open_menu(&id, position, cx)); + click_me.update(app, |screen, cx| screen.select_row(&id, cx)); }) .on_sort(move |column: SharedString, direction, _, _, app| { let column = column.to_string(); sort_me.update(app, |screen, cx| screen.sort_by(&column, direction, cx)); }); + if let Some((column, direction)) = &self.sort { + table = table.sort(column.clone(), *direction); + } if let Some(selected) = &self.selected { table = table.selected(selected.clone()); } table } + fn filter_sidebar( + &self, + me: &Entity, + background: Hsla, + accent: Hsla, + stroke: Hsla, + ) -> impl IntoElement { + let rows = self + .snapshot + .value() + .map(|s| &s.torrents.torrents[..]) + .unwrap_or(&[]); + let mut panel = div() + .id("filter-sidebar") + .flex() + .flex_col() + .gap_1() + .w(px(190.0)) + .p_2() + .border_1() + .border_color(stroke) + .child(Label::new("Filters").size(LabelSize::Body)); + for status in StatusFilter::ALL { + let target = me.clone(); + let count = rows + .iter() + .filter(|torrent| matches_filter(status, torrent)) + .count(); + let label = format!("{} ({count})", status.label()); + panel = panel.child( + div() + .id(format!("status-{}", status.label())) + .px_2() + .py_1() + .bg(if self.filter == status { + accent + } else { + background + }) + .cursor_pointer() + .on_click(move |_, _, cx| { + target.update(cx, |screen, cx| screen.set_filter(status, cx)) + }) + .child(Label::new(label).size(LabelSize::Caption)), + ); + } + let mut categories: Vec = rows + .iter() + .filter_map(|t| t.category.clone()) + .filter(|v| !v.is_empty()) + .collect(); + categories.sort(); + categories.dedup(); + panel = panel.child(Label::new("Categories").size(LabelSize::Caption)); + for value in categories { + let target = me.clone(); + let selected = self.category_filter.as_deref() == Some(value.as_str()); + let label = value.clone(); + panel = panel.child( + div() + .id(format!("category-{value}")) + .px_2() + .py_1() + .cursor_pointer() + .bg(if selected { accent } else { background }) + .on_click(move |_, _, cx| { + target.update(cx, |screen, cx| { + screen.set_category((!selected).then_some(label.clone()), cx) + }) + }) + .child(Label::new(value).size(LabelSize::Caption)), + ); + } + let mut tags: Vec = rows + .iter() + .flat_map(|t| t.tags.clone().unwrap_or_default()) + .collect(); + tags.sort(); + tags.dedup(); + panel = panel.child(Label::new("Tags").size(LabelSize::Caption)); + for value in tags { + let target = me.clone(); + let selected = self.tag_filter.as_deref() == Some(value.as_str()); + let label = value.clone(); + panel = panel.child( + div() + .id(format!("tag-{value}")) + .px_2() + .py_1() + .cursor_pointer() + .bg(if selected { accent } else { background }) + .on_click(move |_, _, cx| { + target.update(cx, |screen, cx| { + screen.set_tag((!selected).then_some(label.clone()), cx) + }) + }) + .child(Label::new(value).size(LabelSize::Caption)), + ); + } + let mut trackers: Vec = rows + .iter() + .flat_map(|torrent| torrent.trackers.clone().unwrap_or_default()) + .filter_map(|url| tracker_host(&url)) + .collect(); + trackers.sort(); + trackers.dedup(); + panel = panel.child(Label::new("Trackers").size(LabelSize::Caption)); + for value in trackers { + let target = me.clone(); + let selected = self.tracker_filter.as_deref() == Some(value.as_str()); + let label = value.clone(); + panel = panel.child( + div() + .id(format!("tracker-{value}")) + .px_2() + .py_1() + .cursor_pointer() + .bg(if selected { accent } else { background }) + .on_click(move |_, _, cx| { + target.update(cx, |screen, cx| { + screen.set_tracker((!selected).then_some(label.clone()), cx) + }) + }) + .child(Label::new(value).size(LabelSize::Caption)), + ); + } + panel + } } +impl gpui::EventEmitter for MainScreen {} + impl Render for MainScreen { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.autofocused { + self.autofocused = true; + window.focus(&self.focus, cx); + } self.ensure_polling(cx); self.apply_to_status(cx); if self.menu.as_ref().is_some_and(|menu| !menu.read(cx).open) { @@ -403,50 +1430,369 @@ impl Render for MainScreen { let me = cx.entity(); let add_me = me.clone(); - let toolbar = Toolbar::new().items([ - ToolbarItem::action("add", "Add", move |_, _, app| { - add_me.update(app, |screen, cx| screen.open_add(cx)); - }), - ToolbarItem::separator(), - ToolbarItem::action("resume", "Resume", toolbar_click(&me, RowAction::Start)), - ToolbarItem::action("pause", "Pause", toolbar_click(&me, RowAction::Pause)), - ToolbarItem::separator(), - ToolbarItem::action("delete", "Delete", toolbar_click(&me, RowAction::Delete)), - ]); + let colors = cx.theme().colors.clone(); + let components = &cx.theme().components; + let (header_height, row_height) = ( + px(components.content_tab_strip_height), + px(components.menu_item_height), + ); + let geometry = self.table_geometry.clone(); + let toolbar = div() + .id("torrent-toolbar") + .flex() + .items_center() + .gap_1() + .p_1() + .bg(colors.surface_dim) + .child(icon_toolbar_button( + toolbar_buttons()[0], + colors.surface_dim, + colors.stroke_accent, + move |_, app| add_me.update(app, |screen, cx| screen.open_add(cx)), + )) + .child(icon_toolbar_button( + toolbar_buttons()[1], + colors.surface_dim, + colors.stroke_accent, + toolbar_activate(&me, RowAction::Start), + )) + .child(icon_toolbar_button( + toolbar_buttons()[2], + colors.surface_dim, + colors.stroke_accent, + toolbar_activate(&me, RowAction::Pause), + )) + .child(icon_toolbar_button( + toolbar_buttons()[3], + colors.surface_dim, + colors.stroke_accent, + toolbar_activate(&me, RowAction::Delete), + )); let message = self .action_error .clone() .or_else(|| self.snapshot.error().cloned()); let mut root = div() + .key_context(KEY_CONTEXT) + .track_focus(&self.focus) + .capture_key_down(cx.listener(|screen, event: &KeyDownEvent, window, cx| { + if !screen.delete_prompt { + return; + } + match event.keystroke.key.as_str() { + "escape" => { + screen.delete_prompt = false; + ModalStack::pop(cx); + window.focus(&screen.focus, cx); + cx.notify(); + cx.stop_propagation(); + } + "enter" => { + screen.delete_prompt = false; + ModalStack::pop(cx); + screen.act(RowAction::Delete, cx); + window.focus(&screen.focus, cx); + cx.stop_propagation(); + } + _ => {} + } + })) + .on_mouse_down( + MouseButton::Left, + cx.listener(|screen, _, window, cx| { + if !screen.focus.contains_focused(window, cx) { + window.focus(&screen.focus, cx); + } + }), + ) + .on_action(cx.listener(|screen, _: &OpenAdd, _, cx| { + if screen.add.is_none() { + screen.open_add(cx); + } + })) + .on_action(cx.listener(|screen, _: &PasteMagnet, _, cx| { + if screen.add.is_none() { + screen.paste_magnet(cx); + } + })) + .on_action(cx.listener(|screen, _: &DeleteSelected, _, cx| { + if screen.add.is_none() { + screen.request(RowAction::Delete, cx); + } + })) + .on_action(cx.listener(|screen, _: &TogglePause, _, cx| { + if screen.add.is_none() { + screen.toggle_pause(cx); + } + })) + .on_action(cx.listener(|screen, _: &SelectAll, _, cx| { + if screen.add.is_none() { + screen.select_all(cx); + } + })) + .on_action(cx.listener(|screen, _: &FocusFilter, window, cx| { + if screen.add.is_none() { + let handle = screen.filter_input.focus_handle(cx); + window.focus(&handle, cx); + } + })) + .on_action(cx.listener(|screen, _: &Refresh, _, cx| screen.refresh(cx))) + .on_action(cx.listener(|screen, _: &OpenContextMenu, _, cx| { + if screen.add.is_none() { + screen.open_menu_for_selection(cx); + } + })) .flex() .flex_col() .flex_1() .p_4() .gap_2() - .child(toolbar); + .child(toolbar) + .child(self.filter_input.clone()); if let Some(message) = message { root = root.child(Label::new(message).size(LabelSize::Caption)); } - root = root.child(self.table(&me)); - root = root.child(self.detail.clone()); + root = root.child( + div() + .tab_index(0) + .relative() + .key_context("TorrentTable") + .on_mouse_down( + MouseButton::Right, + cx.listener(|screen, event: &MouseDownEvent, _, cx| { + if screen.add.is_none() { + screen.open_menu_at_pointer(event.position, cx); + } + }), + ) + .capture_key_down(cx.listener(|screen, event: &KeyDownEvent, _, cx| { + if screen.handle_table_key(event, cx) { + cx.stop_propagation(); + } + })) + .child( + canvas( + move |bounds, _, _| { + geometry.set(TableGeometry { + bounds, + header_height, + row_height, + }); + }, + |_, (), _, _| {}, + ) + .absolute() + .size_full(), + ) + .child(self.filter_sidebar( + &me, + colors.surface_dim, + colors.accent, + colors.stroke_neutral, + )) + .child(self.table(&me)), + ); + root = root.child(Label::new(SHORTCUT_HELP).size(LabelSize::Caption)); + if self.detail_open { + root = root.child(self.detail.clone()); + } if let Some(menu) = &self.menu { root = root.child(menu.clone()); } if let Some(add) = &self.add { root = root.child(add.clone()); } + if let Some(dialog) = &self.bulk_edit { + root = root.child(dialog.clone()); + } + if let Some(dialog) = &self.torrent_edit { + root = root.child(dialog.clone()); + } root = root.child(self.toasts.clone()); root } } -fn toolbar_click( - me: &Entity, - action: RowAction, -) -> impl Fn(&ClickEvent, &mut gpui::Window, &mut App) + 'static { - let me = me.clone(); - move |_, _, app| dispatch(&me, app, action) +#[derive(Clone, Copy)] +enum SelectionMovement { + Previous, + Next, + First, + Last, +} + +/// Where the table was painted and how tall its header and rows are. `DataTable` exposes no +/// per-row hit testing, so the right click maps the pointer to a row from these. +#[derive(Clone, Copy, Default)] +struct TableGeometry { + bounds: Bounds, + header_height: Pixels, + row_height: Pixels, +} + +impl TableGeometry { + /// One pixel each for the table border, the header rule and every row rule. + const RULE: Pixels = px(1.0); + + fn first_row_top(&self) -> Pixels { + self.bounds.origin.y + Self::RULE + self.header_height + Self::RULE + } + + fn row_stride(&self) -> Pixels { + self.row_height + Self::RULE + } + + /// Index of the row under `position`, if the pointer is over the table body. + fn row_index(&self, position: Point) -> Option { + let stride = f32::from(self.row_stride()); + if stride <= 0.0 || !self.bounds.contains(&position) { + return None; + } + let offset = f32::from(position.y - self.first_row_top()); + (offset >= 0.0).then(|| (offset / stride) as usize) + } + + /// Where a menu opened from the keyboard for row `index` goes. + fn row_origin(&self, index: usize) -> Point { + Point { + x: self.bounds.origin.x + px(24.0), + y: self.first_row_top() + self.row_stride() * index as f32 + self.row_height, + } + } +} + +/// A command from the row context menu. +#[derive(Clone, Debug, PartialEq, Eq)] +enum MenuCommand { + Start, + Stop, + Recheck, + SetCategory(Option), + BulkEdit, + SetLocation, + Rename, + CopyMagnet, + CopyName, + Delete, +} + +/// One daemon call for one torrent. +#[derive(Clone, Debug, PartialEq, Eq)] +enum Job { + Row(RowAction), + Recheck, + Category(Option), +} + +/// Which context-menu items apply to the current targets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct MenuAvailability { + start: bool, + stop: bool, + recheck: bool, + category: bool, + edit: bool, + copy: bool, + delete: bool, +} + +impl MenuAvailability { + fn of(targets: &[&Torrent]) -> Self { + let state = |torrent: &&Torrent| torrent.stats.as_ref().and_then(|stats| stats.state); + let has_id = |torrent: &&Torrent| torrent.id.is_some(); + let any = |test: &dyn Fn(&&Torrent) -> bool| targets.iter().any(test); + Self { + start: any(&|t| has_id(t) && !is_running(state(t))), + stop: any(&|t| has_id(t) && is_stoppable(state(t))), + recheck: any(&|t| has_id(t) && state(t) != Some(TorrentState::Initializing)), + category: any(&has_id), + edit: targets.len() == 1 && any(&has_id), + copy: !targets.is_empty(), + delete: any(&has_id), + } + } +} + +/// Live or still starting: there is nothing for Start to do. +fn is_running(state: Option) -> bool { + matches!(state, Some(TorrentState::Live | TorrentState::Initializing)) +} + +/// Running, starting or of unknown state: Stop has something to do. +fn is_stoppable(state: Option) -> bool { + matches!( + state, + None | Some(TorrentState::Live | TorrentState::Initializing) + ) +} + +/// The daemon calls a menu command makes, one per target it applies to. +fn menu_jobs(targets: &[&Torrent], command: &MenuCommand) -> Vec<(u64, Job)> { + targets + .iter() + .filter_map(|torrent| { + let id = torrent.id?; + let state = torrent.stats.as_ref().and_then(|stats| stats.state); + let job = match command { + MenuCommand::Start if !is_running(state) => Job::Row(RowAction::Start), + MenuCommand::Stop if is_stoppable(state) => Job::Row(RowAction::Pause), + MenuCommand::Recheck if state != Some(TorrentState::Initializing) => Job::Recheck, + MenuCommand::SetCategory(category) => Job::Category(category.clone()), + _ => return None, + }; + Some((id, job)) + }) + .collect() +} + +/// Clipboard text for Copy magnet link and Copy name: one line per target. +fn copy_text(targets: &[&Torrent], command: &MenuCommand) -> Option { + if targets.is_empty() { + return None; + } + let lines: Vec = targets + .iter() + .map(|torrent| match command { + MenuCommand::CopyMagnet => magnet_link(torrent), + _ => torrent_name(torrent), + }) + .collect(); + Some(lines.join("\n")) +} + +/// `magnet:?xt=urn:btih:&dn=`, with the name percent-encoded. +fn magnet_link(torrent: &Torrent) -> String { + let mut link = format!("magnet:?xt=urn:btih:{}", torrent.info_hash); + if let Some(name) = torrent.name.as_deref().filter(|name| !name.is_empty()) { + link.push_str("&dn="); + for byte in name.bytes() { + if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) { + link.push(char::from(byte)); + } else { + link.push_str(&format!("%{byte:02X}")); + } + } + } + link +} + +/// Sorted, distinct, non-empty category names in use. +fn category_names(torrents: Option<&[Torrent]>) -> Vec { + let mut names: Vec = torrents + .unwrap_or_default() + .iter() + .filter_map(|torrent| torrent.category.clone()) + .filter(|name| !name.is_empty()) + .collect(); + names.sort(); + names.dedup(); + names +} + +/// Hand a context-menu command to the screen entity. +fn run_menu_command(me: &Entity, app: &mut App, command: MenuCommand) { + me.update(app, |screen, cx| screen.run_menu_command(command, cx)); } /// Shown in the table when the daemon has no torrents. @@ -478,6 +1824,18 @@ fn matches_filter(filter: StatusFilter, torrent: &Torrent) -> bool { } } +fn parse_status(value: &str) -> Option { + StatusFilter::ALL + .into_iter() + .find(|status| status.label() == value) +} + +fn tracker_host(url: &str) -> Option { + let rest = url.split_once("://").map_or(url, |(_, rest)| rest); + let host = rest.split(['/', ':']).next().unwrap_or(""); + (!host.is_empty()).then(|| host.to_owned()) +} + fn row_id(torrent: &Torrent) -> String { torrent .id @@ -513,6 +1871,19 @@ fn download_bytes_per_sec(torrent: &Torrent) -> f64 { .map_or(0.0, |live| live.download_speed.mbps * 1024.0 * 1024.0) } +fn upload_speed(torrent: &Torrent) -> rtbit_client::Speed { + torrent + .stats + .as_ref() + .and_then(|stats| stats.live.as_ref()) + .map(|live| live.upload_speed.clone()) + .unwrap_or_default() +} + +fn upload_text(torrent: &Torrent) -> String { + format::speed(&upload_speed(torrent)) +} + fn size_text(torrent: &Torrent) -> String { format::bytes(total_bytes(torrent)) } @@ -567,6 +1938,7 @@ fn column_order(column: &str, a: &Torrent, b: &Torrent) -> Ordering { "size" => total_bytes(a).cmp(&total_bytes(b)), "progress" => done_fraction(a).total_cmp(&done_fraction(b)), "speed" => download_bytes_per_sec(a).total_cmp(&download_bytes_per_sec(b)), + "upload" => upload_speed(a).mbps.total_cmp(&upload_speed(b).mbps), "eta" => eta_seconds(a) .unwrap_or(f64::INFINITY) .total_cmp(&eta_seconds(b).unwrap_or(f64::INFINITY)), @@ -588,6 +1960,17 @@ mod tests { "/../rtbit-client/tests/fixtures" ); + #[test] + fn every_toolbar_button_has_a_non_empty_label() { + assert!( + toolbar_buttons() + .iter() + .all(|button| !button.label.trim().is_empty()), + "toolbar buttons need text for their tooltip and accessible name" + ); + assert_eq!(toolbar_buttons().len(), 4); + } + fn fixture(name: &str) -> String { std::fs::read_to_string(format!("{FIXTURES}/{name}")).unwrap() } @@ -674,6 +2057,34 @@ mod tests { assert_eq!(column_order("size", &small, &big), Ordering::Less); } + #[test] + fn numeric_columns_sort_by_value() { + let (mut slow, mut fast) = (row("a", 1), row("b", 1)); + let live = |mbps| rtbit_client::LiveSpeeds { + download_speed: rtbit_client::Speed { + mbps, + human_readable: String::new(), + }, + upload_speed: rtbit_client::Speed { + mbps, + human_readable: String::new(), + }, + }; + slow.stats.as_mut().unwrap().live = Some(live(9.0)); + fast.stats.as_mut().unwrap().live = Some(live(10.0)); + for column in ["speed", "upload"] { + assert_eq!( + column_order(column, &slow, &fast), + Ordering::Less, + "{column}" + ); + } + let (mut half, mut most) = (row("a", 100), row("b", 100)); + half.stats.as_mut().unwrap().progress_bytes = 9; + most.stats.as_mut().unwrap().progress_bytes = 10; + assert_eq!(column_order("progress", &half, &most), Ordering::Less); + } + #[test] fn eta_is_dash_without_speed() { assert_eq!(eta_text(&row("idle", 100)), "—"); @@ -685,7 +2096,11 @@ mod tests { let status = cx.new(|_| StatusBar::new()); let screen = cx.new(|cx| MainScreen::new(status.clone(), cx)); screen.update(cx, |screen, cx| { - screen._poller = Some(Bridge::poll_snapshot(cx, snapshot_of)); + screen._poller = Some(Bridge::poll_snapshot( + cx, + snapshot_of, + MainScreen::on_poll_health_cb, + )); }); drive(cx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); screen.read_with(cx, |screen, _| { @@ -762,6 +2177,58 @@ mod tests { }); } + #[gpui::test] + fn keyboard_navigation_moves_extends_and_jumps_selection(cx: &mut TestAppContext) { + let (_runtime, _server) = daemon(cx); + let status = cx.new(|_| StatusBar::new()); + let screen = cx.new(|cx| MainScreen::new(status, cx)); + screen.update(cx, |screen, cx| { + screen._load = Some(Bridge::load(screen, cx, snapshot_of, fetch_snapshot)); + }); + drive(cx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); + screen.update(cx, |screen, cx| { + let mut snapshot = screen.snapshot.ready_value().unwrap().clone(); + for id in [4, 5] { + let mut torrent = snapshot.torrents.torrents[0].clone(); + torrent.id = Some(id); + torrent.name = Some(format!("torrent-{id}")); + snapshot.torrents.torrents.push(torrent); + } + snapshot.torrents.total = 3; + screen.snapshot = AsyncResource::ready(snapshot); + + screen.move_selection(SelectionMovement::Next, false, cx); + assert_eq!(screen.selected.as_deref(), Some("4")); + screen.move_selection(SelectionMovement::Next, true, cx); + assert_eq!(screen.selected.as_deref(), Some("5")); + assert_eq!(screen.marked.len(), 2); + assert!(screen.marked.contains("4")); + assert!(screen.marked.contains("5")); + screen.move_selection(SelectionMovement::First, false, cx); + assert_eq!(screen.selected.as_deref(), Some("3")); + screen.move_selection(SelectionMovement::Last, false, cx); + assert_eq!(screen.selected.as_deref(), Some("5")); + }); + } + + #[gpui::test] + fn enter_opens_and_escape_closes_detail(cx: &mut TestAppContext) { + let (_runtime, _server) = daemon(cx); + let status = cx.new(|_| StatusBar::new()); + let screen = cx.new(|cx| MainScreen::new(status, cx)); + screen.update(cx, |screen, cx| { + screen._load = Some(Bridge::load(screen, cx, snapshot_of, fetch_snapshot)); + }); + drive(cx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); + screen.update(cx, |screen, cx| { + screen.selected = Some("3".to_owned()); + screen.open_detail(cx); + assert!(screen.detail_open, "Enter must open the detail pane"); + screen.close_detail(cx); + assert!(!screen.detail_open, "Escape must close the detail pane"); + }); + } + #[gpui::test] fn pause_action_hits_daemon_endpoint(cx: &mut TestAppContext) { let (runtime, server) = daemon(cx); @@ -985,4 +2452,534 @@ mod tests { .count(); assert_eq!(hits, 1, "start must hit the daemon exactly once"); } + + // --- keyboard shortcuts: each test sends real keystrokes through the keymap --- + + fn drive_window( + cx: &mut gpui::VisualTestContext, + mut done: impl FnMut(&mut gpui::VisualTestContext) -> bool, + ) { + for _ in 0..500 { + cx.run_until_parked(); + if done(cx) { + return; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("daemon reply never arrived"); + } + + /// A main screen in a window with the shortcuts bound, the snapshot loaded and row 3 shown. + fn shortcut_screen( + cx: &mut TestAppContext, + ) -> ( + Entity, + &mut gpui::VisualTestContext, + tokio::runtime::Runtime, + MockServer, + ) { + let (runtime, server) = daemon(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/3/start")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server), + ); + cx.update(|app| { + fluent_core::Theme::init(app); + fluent_layout::ModalStack::init(app); + bind_keys(app); + }); + let status = cx.new(|_| StatusBar::new()); + let (screen, vcx) = cx.add_window_view(|_, cx| MainScreen::new(status, cx)); + screen.update(vcx, |screen, cx| { + screen._load = Some(Bridge::load(screen, cx, snapshot_of, fetch_snapshot)); + }); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); + (screen, vcx, runtime, server) + } + + fn posts_to(runtime: &tokio::runtime::Runtime, server: &MockServer, route: &str) -> usize { + std::thread::sleep(Duration::from_millis(50)); + runtime + .block_on(server.received_requests()) + .unwrap() + .iter() + .filter(|req| req.method.as_str() == "POST" && req.url.path() == route) + .count() + } + + #[gpui::test] + fn ctrl_o_opens_the_add_dialog(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + screen.read_with(vcx, |s, _| assert!(s.add.is_none())); + vcx.simulate_keystrokes("ctrl-o"); + screen.read_with(vcx, |s, _| assert!(s.add.is_some())); + } + + #[gpui::test] + fn add_dialog_escape_cancels_and_enter_submits_only_valid_input(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + vcx.update(|_, cx| ToastStack::init(cx)); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server), + ); + + vcx.simulate_keystrokes("ctrl-o"); + let dialog = screen.read_with(vcx, |s, _| s.add.clone()).unwrap(); + vcx.run_until_parked(); + vcx.update(|window, cx| assert!(dialog.read(cx).magnet_focus(cx).is_focused(window))); + vcx.simulate_keystrokes("enter"); + dialog.read_with(vcx, |dialog, _| assert!(dialog.error().is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents"), 0); + vcx.simulate_keystrokes("escape"); + screen.read_with(vcx, |s, _| assert!(s.add.is_none())); + + vcx.simulate_keystrokes("ctrl-o"); + let dialog = screen.read_with(vcx, |s, _| s.add.clone()).unwrap(); + dialog.update(vcx, |dialog, cx| { + dialog.fill_for_test(cx, HASH_MAGNET, "/data"); + }); + vcx.simulate_keystrokes("enter"); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s.add.is_none())); + assert_eq!(posts_to(&runtime, &server, "/torrents"), 1); + } + + #[gpui::test] + fn ctrl_v_with_a_magnet_opens_the_dialog_prefilled(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + vcx.write_to_clipboard(gpui::ClipboardItem::new_string(format!( + " {HASH_MAGNET}\n" + ))); + vcx.simulate_keystrokes("ctrl-v"); + let dialog = screen.read_with(vcx, |s, _| s.add.clone()).unwrap(); + dialog.read_with(vcx, |dialog, cx| { + assert_eq!(dialog.magnet_text(cx), HASH_MAGNET) + }); + } + + #[gpui::test] + fn ctrl_v_without_a_magnet_opens_nothing(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + vcx.write_to_clipboard(gpui::ClipboardItem::new_string("just some text".to_owned())); + vcx.simulate_keystrokes("ctrl-v"); + screen.read_with(vcx, |s, _| assert!(s.add.is_none())); + } + + #[gpui::test] + fn delete_asks_before_removing_the_selection(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + vcx.simulate_keystrokes("delete"); + screen.read_with(vcx, |s, _| { + assert_eq!(s.action_error.as_deref(), Some("Select a torrent first.")); + assert!(!s.delete_prompt); + }); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + vcx.simulate_keystrokes("delete"); + screen.read_with(vcx, |s, _| assert!(s.delete_prompt)); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/delete"), 0); + } + + #[gpui::test] + fn delete_confirmation_escape_cancels_and_enter_confirms(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/3/delete")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server), + ); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + vcx.simulate_keystrokes("delete escape"); + screen.read_with(vcx, |s, _| assert!(!s.delete_prompt)); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/delete"), 0); + + vcx.simulate_keystrokes("delete enter"); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/delete"), 1); + } + + #[gpui::test] + fn space_pauses_the_selected_torrent(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + vcx.simulate_keystrokes("space"); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + // The fixture row is not paused, so Space pauses it. + assert_eq!(posts_to(&runtime, &server, "/torrents/3/pause"), 1); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/start"), 0); + } + + #[gpui::test] + fn ctrl_a_marks_every_visible_row(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + vcx.simulate_keystrokes("ctrl-a"); + screen.read_with(vcx, |s, _| assert_eq!(s.marked_count(), 1)); + // Picking a row again drops the marks. + screen.update(vcx, |s, cx| s.select_row("3", cx)); + screen.read_with(vcx, |s, _| assert_eq!(s.marked_count(), 0)); + } + + #[gpui::test] + fn ctrl_f_focuses_the_filter_and_typing_keys_stay_there(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + vcx.simulate_keystrokes("ctrl-f"); + let filter = screen.read_with(vcx, |s, _| s.filter_input.clone()); + let focused = vcx.update(|window, cx| filter.focus_handle(cx).is_focused(window)); + assert!(focused, "Ctrl+F must focus the filter box"); + // Space belongs to the text field now: it must not pause the torrent. + vcx.simulate_keystrokes("space"); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/pause"), 0); + } + + #[gpui::test] + fn the_filter_box_narrows_the_rows(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + screen.update(vcx, |s, cx| s.set_query("EXAM".to_owned(), cx)); + screen.read_with(vcx, |s, _| assert_eq!(s.visible_rows().len(), 1)); + screen.update(vcx, |s, cx| s.set_query("nomatch".to_owned(), cx)); + screen.read_with(vcx, |s, _| assert!(s.visible_rows().is_empty())); + } + + #[gpui::test] + fn f5_asks_the_daemon_for_a_fresh_snapshot(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + let gets = |runtime: &tokio::runtime::Runtime, server: &MockServer| { + runtime + .block_on(server.received_requests()) + .unwrap() + .iter() + .filter(|req| req.method.as_str() == "GET" && req.url.path() == "/torrents") + .count() + }; + let before = gets(&runtime, &server); + screen.update(vcx, |s, _| s._load = None); + vcx.simulate_keystrokes("f5"); + screen.read_with(vcx, |s, _| assert!(s._load.is_some())); + drive_window(vcx, |_| gets(&runtime, &server) > before); + } + + fn with_state(id: u64, state: TorrentState) -> Torrent { + let mut torrent = row(&format!("t{id}"), 1); + torrent.id = Some(id); + torrent.stats.as_mut().unwrap().state = Some(state); + torrent + } + + #[test] + fn menu_items_follow_the_state_of_the_targets() { + let live = with_state(1, TorrentState::Live); + let paused = with_state(2, TorrentState::Paused); + let starting = with_state(3, TorrentState::Initializing); + + let running = MenuAvailability::of(&[&live]); + assert!(!running.start, "Start is greyed while running"); + assert!(running.stop && running.recheck && running.delete && running.copy); + + let stopped = MenuAvailability::of(&[&paused]); + assert!(stopped.start && !stopped.stop); + + let mixed = MenuAvailability::of(&[&live, &paused]); + assert!(mixed.start && mixed.stop, "a mixed selection allows both"); + + assert!(!MenuAvailability::of(&[&starting]).recheck); + + let mut missing = with_state(4, TorrentState::Error); + missing.name = Some("missingFiles".to_owned()); + assert!(MenuAvailability::of(&[&missing]).edit); + + let none = MenuAvailability::of(&[]); + assert!(!(none.start || none.stop || none.recheck || none.category || none.copy)); + assert!(!none.delete); + } + + #[gpui::test] + fn set_location_success_closes_the_dialog(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/3/set_location")) + .and(wiremock::matchers::body_json(serde_json::json!({ + "location": "/new/place" + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server), + ); + let status = cx.new(|_| StatusBar::new()); + let screen = cx.new(|cx| MainScreen::new(status, cx)); + screen.update(cx, |screen, cx| { + screen._load = Some(Bridge::load(screen, cx, snapshot_of, fetch_snapshot)); + }); + drive(cx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); + screen.update(cx, |screen, cx| { + screen.select_row("3", cx); + screen.open_torrent_edit(TorrentEditKind::Location, cx); + let dialog = screen.torrent_edit.clone().unwrap(); + screen.send_torrent_edit( + dialog, + TorrentEditKind::Location, + "/new/place".to_owned(), + cx, + ); + }); + drive(cx, |cx| { + screen.read_with(cx, |s, _| s.torrent_edit.is_none()) + }); + runtime.block_on(server.verify()); + } + + #[gpui::test] + fn set_location_409_stays_in_the_dialog(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx); + runtime.block_on( + Mock::given(method("POST")) + .and(path("/torrents/3/set_location")) + .respond_with( + ResponseTemplate::new(409) + .set_body_string("cross-device move is not supported"), + ) + .expect(1) + .mount(&server), + ); + let status = cx.new(|_| StatusBar::new()); + let screen = cx.new(|cx| MainScreen::new(status, cx)); + screen.update(cx, |screen, cx| { + screen._load = Some(Bridge::load(screen, cx, snapshot_of, fetch_snapshot)); + }); + drive(cx, |cx| screen.read_with(cx, |s, _| s.torrent_count() > 0)); + screen.update(cx, |screen, cx| { + screen.select_row("3", cx); + screen.open_torrent_edit(TorrentEditKind::Location, cx); + let dialog = screen.torrent_edit.clone().unwrap(); + screen.send_torrent_edit(dialog, TorrentEditKind::Location, "/other".to_owned(), cx); + }); + drive(cx, |cx| { + screen.read_with(cx, |screen, cx| { + screen.torrent_edit.as_ref().is_some_and(|dialog| { + dialog.read(cx).error.as_deref() == Some("cross-device move is not supported") + }) + }) + }); + runtime.block_on(server.verify()); + } + + #[test] + fn menu_jobs_cover_only_the_targets_a_command_applies_to() { + let live = with_state(1, TorrentState::Live); + let paused = with_state(2, TorrentState::Paused); + let targets = [&live, &paused]; + assert_eq!( + menu_jobs(&targets, &MenuCommand::Start), + vec![(2, Job::Row(RowAction::Start))] + ); + assert_eq!( + menu_jobs(&targets, &MenuCommand::Stop), + vec![(1, Job::Row(RowAction::Pause))] + ); + assert_eq!( + menu_jobs(&targets, &MenuCommand::Recheck), + vec![(1, Job::Recheck), (2, Job::Recheck)] + ); + assert_eq!( + menu_jobs(&targets, &MenuCommand::SetCategory(Some("tv".to_owned()))), + vec![ + (1, Job::Category(Some("tv".to_owned()))), + (2, Job::Category(Some("tv".to_owned()))) + ] + ); + assert!(menu_jobs(&targets, &MenuCommand::CopyName).is_empty()); + } + + #[test] + fn copy_text_builds_magnets_and_names_per_target() { + let mut one = row("My Show.S01", 1); + one.info_hash = "abc123".to_owned(); + let two = row("plain", 1); + assert_eq!( + copy_text(&[&one], &MenuCommand::CopyMagnet).unwrap(), + "magnet:?xt=urn:btih:abc123&dn=My%20Show.S01" + ); + assert_eq!( + copy_text(&[&one, &two], &MenuCommand::CopyName).unwrap(), + "My Show.S01\nplain" + ); + assert_eq!(copy_text(&[], &MenuCommand::CopyName), None); + } + + #[test] + fn pointer_maps_to_table_rows() { + let geometry = TableGeometry { + bounds: Bounds { + origin: gpui::point(px(10.0), px(100.0)), + size: gpui::size(px(400.0), px(300.0)), + }, + header_height: px(30.0), + row_height: px(20.0), + }; + // Body starts below the table border, header and header rule. + let at = |y: f32| geometry.row_index(gpui::point(px(50.0), px(y))); + assert_eq!(at(110.0), None, "header"); + assert_eq!(at(132.0), Some(0)); + assert_eq!(at(132.0 + 21.0), Some(1)); + assert_eq!(geometry.row_index(gpui::point(px(5.0), px(150.0))), None); + } + + #[gpui::test] + fn right_click_selects_the_row_and_opens_the_menu(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + vcx.run_until_parked(); + let (position, id) = screen.read_with(vcx, |s, _| { + let geometry = s.table_geometry.get(); + (geometry.row_origin(0), row_id(s.visible_rows()[0])) + }); + // `row_origin` sits a row below the row's top; step back into the row. + let inside = gpui::point(position.x, position.y - px(10.0)); + vcx.simulate_event(MouseDownEvent { + button: MouseButton::Right, + position: inside, + modifiers: gpui::Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + screen.read_with(vcx, |s, _| { + assert_eq!(s.selected.as_deref(), Some(id.as_str())); + assert!(s.menu.is_some(), "right click must open the menu"); + }); + } + + #[gpui::test] + fn shift_f10_opens_the_menu_for_the_selection(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = shortcut_screen(cx); + vcx.simulate_keystrokes("shift-f10"); + screen.read_with(vcx, |s, _| { + assert!(s.menu.is_none()); + assert_eq!(s.action_error.as_deref(), Some("Select a torrent first.")); + }); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + vcx.simulate_keystrokes("shift-f10"); + screen.read_with(vcx, |s, _| assert!(s.menu.is_some())); + } + + /// Mount the context-menu routes and select the fixture row. + fn menu_screen( + cx: &mut TestAppContext, + ) -> ( + Entity, + &mut gpui::VisualTestContext, + tokio::runtime::Runtime, + MockServer, + ) { + let (screen, vcx, runtime, server) = shortcut_screen(cx); + for route in ["/torrents/3/recheck", "/torrents/3/set_category"] { + runtime.block_on( + Mock::given(method("POST")) + .and(path(route)) + .respond_with(ResponseTemplate::new(200).set_body_raw("{}", "application/json")) + .mount(&server), + ); + } + vcx.update(|_, cx| ToastStack::init(cx)); + set_state(&screen, vcx, TorrentState::Live); + screen.update(vcx, |s, cx| s.select_row("3", cx)); + (screen, vcx, runtime, server) + } + + fn set_state( + screen: &Entity, + vcx: &mut gpui::VisualTestContext, + state: TorrentState, + ) { + screen.update(vcx, |s, _| { + let mut snapshot = s.snapshot.value().unwrap().clone(); + snapshot.torrents.torrents[0] + .stats + .get_or_insert_with(Default::default) + .state = Some(state); + s.snapshot = AsyncResource::ready(snapshot); + }); + } + + fn run_menu( + screen: &Entity, + vcx: &mut gpui::VisualTestContext, + command: MenuCommand, + ) { + screen.update(vcx, |s, cx| s.run_menu_command(command, cx)); + } + + #[gpui::test] + fn menu_stop_pauses_the_selection(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + run_menu(&screen, vcx, MenuCommand::Stop); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/pause"), 1); + } + + #[gpui::test] + fn menu_start_is_a_no_op_while_running(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + run_menu(&screen, vcx, MenuCommand::Start); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/start"), 0); + } + + #[gpui::test] + fn menu_start_resumes_a_paused_selection(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + set_state(&screen, vcx, TorrentState::Paused); + run_menu(&screen, vcx, MenuCommand::Start); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/start"), 1); + } + + #[gpui::test] + fn menu_recheck_posts_to_the_daemon(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + run_menu(&screen, vcx, MenuCommand::Recheck); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/recheck"), 1); + } + + #[gpui::test] + fn menu_set_category_sends_the_category(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + run_menu( + &screen, + vcx, + MenuCommand::SetCategory(Some("movies".to_owned())), + ); + drive_window(vcx, |cx| screen.read_with(cx, |s, _| s._action.is_some())); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/set_category"), 1); + let body: serde_json::Value = runtime + .block_on(server.received_requests()) + .unwrap() + .iter() + .find(|req| req.url.path() == "/torrents/3/set_category") + .map(|req| serde_json::from_slice(&req.body).unwrap()) + .unwrap(); + assert_eq!(body, serde_json::json!({ "category": "movies" })); + } + + #[gpui::test] + fn menu_copy_commands_fill_the_clipboard(cx: &mut TestAppContext) { + let (screen, vcx, _runtime, _server) = menu_screen(cx); + run_menu(&screen, vcx, MenuCommand::CopyName); + let copied = vcx.read_from_clipboard().and_then(|item| item.text()); + assert_eq!(copied.as_deref(), Some("example")); + run_menu(&screen, vcx, MenuCommand::CopyMagnet); + let copied = vcx.read_from_clipboard().and_then(|item| item.text()); + assert!(copied.unwrap().starts_with("magnet:?xt=urn:btih:")); + } + + #[gpui::test] + fn menu_delete_asks_before_removing(cx: &mut TestAppContext) { + let (screen, vcx, runtime, server) = menu_screen(cx); + run_menu(&screen, vcx, MenuCommand::Delete); + screen.read_with(vcx, |s, _| assert!(s.delete_prompt)); + assert_eq!(posts_to(&runtime, &server, "/torrents/3/delete"), 0); + } } diff --git a/crates/rtbit-fluent/src/screens/mod.rs b/crates/rtbit-fluent/src/screens/mod.rs index be1a5864..641fa7e0 100644 --- a/crates/rtbit-fluent/src/screens/mod.rs +++ b/crates/rtbit-fluent/src/screens/mod.rs @@ -1,10 +1,12 @@ //! Screens the shell routes to. Each owns its own form state and talks to the daemon only through the bridge. pub mod add_dialog; +pub mod bulk_edit_dialog; pub mod connect; pub mod detail; pub mod main_screen; pub mod rss; pub mod settings; +pub mod torrent_edit_dialog; pub use main_screen::MainScreen; diff --git a/crates/rtbit-fluent/src/screens/rss.rs b/crates/rtbit-fluent/src/screens/rss.rs index be10ab41..3a140909 100644 --- a/crates/rtbit-fluent/src/screens/rss.rs +++ b/crates/rtbit-fluent/src/screens/rss.rs @@ -9,8 +9,8 @@ use fluent_layout::{DataColumn, DataRow, DataTable, MessageBar, MessageIntent}; use fluent_primitives::{Button, ButtonAppearance, Label}; use fluent_primitives::{Checkbox, TextInput}; use gpui::{ - AsyncApp, ClickEvent, Context, Entity, IntoElement, Render, SharedString, Task, WeakEntity, - Window, div, prelude::*, + AsyncApp, ClickEvent, Context, Entity, FocusHandle, Focusable as _, IntoElement, KeyDownEvent, + Render, SharedString, Task, WeakEntity, Window, div, prelude::*, }; use rtbit_client::{Error, RssItem, RssRule, RssRuleBody, RssRuleEpisode, RssSnapshot}; use rtbit_ui_core::map_error; @@ -105,6 +105,9 @@ pub struct RssScreen { episodes: Vec, episodes_loading: bool, episodes_error: Option, + editor_focus: FocusHandle, + editor_autofocused: bool, + restore_focus: bool, _load: Option>, _download: Option>, _rule: Option>, @@ -130,6 +133,9 @@ impl RssScreen { episodes: Vec::new(), episodes_loading: false, episodes_error: None, + editor_focus: cx.focus_handle(), + editor_autofocused: false, + restore_focus: false, _load: None, _download: None, _rule: None, @@ -181,6 +187,7 @@ impl RssScreen { self.category_input .update(cx, |field, cx| field.set_value(category, cx)); self.editor = Some(draft); + self.editor_autofocused = false; self.error = None; self.episodes.clear(); self.episodes_loading = false; @@ -212,6 +219,8 @@ impl RssScreen { return; } self.editor = None; + self.editor_autofocused = false; + self.restore_focus = true; self.error = None; cx.notify(); } @@ -279,6 +288,14 @@ impl RssScreen { cx.notify(); } } + + /// Set the download priority in the open draft. + pub fn set_priority(&mut self, priority: i32, cx: &mut Context) { + if let Some(draft) = self.editor.as_mut() { + draft.priority = priority; + cx.notify(); + } + } } /// A boolean field of the rule draft that the editor shows as a checkbox. @@ -525,9 +542,42 @@ pub fn item_status(item: &RssItem) -> &'static str { } impl Render for RssScreen { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.editor.is_some() && !self.editor_autofocused { + self.editor_autofocused = true; + // The input only owns its focus handle once it has rendered, so focus it after + // this frame has drawn. + cx.defer_in(window, |screen, window, cx| { + let focus = screen.name_input.focus_handle(cx); + window.focus(&focus, cx); + }); + } else if self.restore_focus { + self.restore_focus = false; + window.focus(&self.editor_focus, cx); + } let me: Entity = cx.entity(); - let mut root = div().flex().flex_col().gap_3().child(Label::new("RSS")); + let mut root = div() + .flex() + .flex_col() + .gap_3() + .track_focus(&self.editor_focus) + .capture_key_down(cx.listener(|screen, event: &KeyDownEvent, _, cx| { + if screen.editor.is_none() { + return; + } + match event.keystroke.key.as_str() { + "escape" => { + screen.cancel_rule(cx); + cx.stop_propagation(); + } + "enter" => { + screen.save_rule(cx); + cx.stop_propagation(); + } + _ => {} + } + })) + .child(Label::new("RSS")); if let Some(message) = self.error.clone() { root = root.child(MessageBar::new(message).intent(MessageIntent::Error)); @@ -692,6 +742,23 @@ impl RssScreen { this.toggle_flag(DraftFlag::TrackEpisodes, cx); }); }); + let mut priorities = div().flex().flex_row().gap_2(); + for (priority, label) in [(0, "Low"), (1, "Normal"), (2, "High")] { + let priority_me = me.clone(); + priorities = priorities.child( + Button::new(("rss-rule-priority", priority as usize)) + .label(label) + .appearance(if draft.priority == priority { + ButtonAppearance::Accent + } else { + ButtonAppearance::Neutral + }) + .disabled(self.rule_busy) + .on_click(move |_, _, cx| { + priority_me.update(cx, |this, cx| this.set_priority(priority, cx)); + }), + ); + } section = section .child(Label::new(title)) .child(Label::new("Name")) @@ -701,10 +768,8 @@ impl RssScreen { .child(feeds) .child(Label::new("Category")) .child(self.category_input.clone()) - .child(Label::new(format!( - "Priority: {}", - priority_label(draft.priority) - ))) + .child(Label::new("Priority")) + .child(priorities) .child( div() .flex() @@ -776,7 +841,7 @@ impl RssScreen { mod tests { use super::*; use gpui::TestAppContext; - use wiremock::matchers::{method, path}; + use wiremock::matchers::{body_json, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; const FIXTURES: &str = concat!( @@ -996,11 +1061,13 @@ mod tests { fn validate_rule_trims_and_drops_an_empty_category() { let mut draft = draft_with(" Show ", " Show.*1080p ", &["Show"]); draft.category = " ".to_owned(); + draft.priority = 2; let body = validate_rule(&draft).expect("valid"); assert_eq!(body.name, "Show"); assert_eq!(body.match_regex, "Show.*1080p"); assert_eq!(body.feed_names, vec!["Show".to_owned()]); assert_eq!(body.category, None); + assert_eq!(body.priority, 2); assert!(body.enabled && body.track_episodes); } @@ -1052,7 +1119,17 @@ mod tests { runtime.block_on(async { Mock::given(method("POST")) .and(path("/rss/rules")) + .and(body_json(serde_json::json!({ + "name": "Two", + "feed_names": ["Show"], + "category": null, + "priority": 0, + "match_regex": "Two.*", + "enabled": true, + "track_episodes": true + }))) .respond_with(ResponseTemplate::new(200)) + .expect(1) .mount(&server) .await; }); @@ -1064,6 +1141,7 @@ mod tests { .regex_input .update(cx, |f, cx| f.set_value("Two.*", cx)); screen.editor.as_mut().unwrap().feeds = vec!["Show".to_owned()]; + screen.set_priority(0, cx); screen.save_rule(cx); }); drive(cx, |cx| { @@ -1072,6 +1150,94 @@ mod tests { screen.read_with(cx, |screen, _| { assert!(screen.error().is_none(), "save should succeed"); }); + runtime.block_on(server.verify()); + } + + #[gpui::test] + fn edit_initializes_and_updates_the_selected_priority(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx, ITEMS); + runtime.block_on(async { + Mock::given(method("PUT")) + .and(path("/rss/rules/r-1")) + .and(body_json(serde_json::json!({ + "name": "Show 1080p", + "feed_names": ["Show"], + "category": "tv", + "priority": 0, + "match_regex": "Show.*1080p", + "enabled": true, + "track_episodes": true + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + }); + let screen = loaded(cx); + screen.update(cx, |screen, cx| { + screen.open_edit_rule("r-1", cx); + assert_eq!(screen.editor().map(|draft| draft.priority), Some(2)); + screen.set_priority(0, cx); + screen.save_rule(cx); + }); + drive(cx, |cx| { + screen.read_with(cx, |screen, _| { + screen.editor().is_none() && !screen.rule_busy + }) + }); + runtime.block_on(server.verify()); + } + + #[gpui::test] + fn rule_editor_escape_cancels_and_enter_submits_only_valid_input(cx: &mut TestAppContext) { + let (runtime, server) = daemon(cx, ITEMS); + runtime.block_on(async { + Mock::given(method("POST")) + .and(path("/rss/rules")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + }); + cx.update(fluent_core::Theme::init); + let (screen, vcx) = cx.add_window_view(|_, cx| RssScreen::new(cx)); + screen.update(vcx, |screen, cx| screen.load(cx)); + for _ in 0..500 { + vcx.run_until_parked(); + if screen.read_with(vcx, |s, _| !s.snapshot.is_loading()) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + screen.update(vcx, |screen, cx| screen.open_new_rule(cx)); + vcx.run_until_parked(); + vcx.simulate_keystrokes("enter"); + screen.read_with(vcx, |screen, _| { + assert!(screen.editor().is_some()); + assert!(screen.error().is_some()); + }); + vcx.simulate_keystrokes("escape"); + screen.read_with(vcx, |screen, _| assert!(screen.editor().is_none())); + + screen.update(vcx, |screen, cx| { + screen.open_new_rule(cx); + screen.name_input.update(cx, |f, cx| f.set_value("Two", cx)); + screen + .regex_input + .update(cx, |f, cx| f.set_value("Two.*", cx)); + screen.editor.as_mut().unwrap().feeds = vec!["Show".to_owned()]; + }); + vcx.simulate_keystrokes("enter"); + for _ in 0..500 { + vcx.run_until_parked(); + if screen.read_with(vcx, |s, _| s.editor().is_none() && !s.rule_busy) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + screen.read_with(vcx, |screen, _| assert!(screen.editor().is_none())); + runtime.block_on(server.verify()); } #[gpui::test] @@ -1090,12 +1256,14 @@ mod tests { screen.name_input.update(cx, |f, cx| f.set_value("Bad", cx)); screen.regex_input.update(cx, |f, cx| f.set_value("(", cx)); screen.editor.as_mut().unwrap().feeds = vec!["Show".to_owned()]; + screen.set_priority(2, cx); screen.save_rule(cx); }); drive(cx, |cx| screen.read_with(cx, |s, _| !s.rule_busy)); screen.read_with(cx, |screen, _| { assert!(screen.editor().is_some(), "editor must stay open"); assert!(screen.error().is_some(), "refusal must show an error"); + assert_eq!(screen.editor().map(|draft| draft.priority), Some(2)); }); } diff --git a/crates/rtbit-fluent/src/screens/torrent_edit_dialog.rs b/crates/rtbit-fluent/src/screens/torrent_edit_dialog.rs new file mode 100644 index 00000000..a0ede6bb --- /dev/null +++ b/crates/rtbit-fluent/src/screens/torrent_edit_dialog.rs @@ -0,0 +1,185 @@ +//! Small text dialogs for changing one torrent's location or display name. + +use fluent_core::ThemeProvider as _; +use fluent_layout::{MessageBar, MessageIntent}; +use fluent_primitives::{Button, ButtonAppearance, Label, TextInput}; +use gpui::{ + AsyncApp, Context, Entity, EventEmitter, Focusable as _, IntoElement, KeyDownEvent, + PathPromptOptions, Render, Task, WeakEntity, Window, div, prelude::*, px, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TorrentEditKind { + Location, + Name, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TorrentEditDialogEvent { + Submit(String), + Cancel, +} + +pub struct TorrentEditDialog { + kind: TorrentEditKind, + input: Entity, + pub(crate) error: Option, + submitted: bool, + autofocused: bool, + _pick: Option>, +} + +impl EventEmitter for TorrentEditDialog {} + +impl TorrentEditDialog { + pub fn new(kind: TorrentEditKind, initial: String, cx: &mut Context) -> Self { + let placeholder = match kind { + TorrentEditKind::Location => "/downloads", + TorrentEditKind::Name => "Torrent name", + }; + Self { + kind, + input: cx.new(|_| TextInput::new().placeholder(placeholder).value(initial)), + error: None, + submitted: false, + autofocused: false, + _pick: None, + } + } + + pub fn set_error(&mut self, error: String, cx: &mut Context) { + self.error = Some(error); + self.submitted = false; + cx.notify(); + } + + fn submit(&mut self, cx: &mut Context) { + let value = self.input.read(cx).text().trim().to_owned(); + if value.is_empty() { + self.error = Some( + match self.kind { + TorrentEditKind::Location => "Choose or enter a location first.", + TorrentEditKind::Name => "Enter a name first.", + } + .to_owned(), + ); + cx.notify(); + return; + } + if !self.submitted { + self.submitted = true; + cx.emit(TorrentEditDialogEvent::Submit(value)); + } + } + + fn pick_directory(&mut self, cx: &mut Context) { + let picked = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: None, + }); + self._pick = Some( + cx.spawn(async move |view: WeakEntity, cx: &mut AsyncApp| { + let Ok(Ok(Some(paths))) = picked.await else { + return; + }; + let Some(path) = paths.into_iter().next() else { + return; + }; + let _ = view.update(cx, |dialog, cx| { + dialog + .input + .update(cx, |input, cx| input.set_value(path.to_string_lossy(), cx)); + dialog.error = None; + cx.notify(); + }); + }), + ); + } +} + +impl Render for TorrentEditDialog { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.autofocused { + window.focus(&self.input.focus_handle(cx), cx); + self.autofocused = true; + } + let title = match self.kind { + TorrentEditKind::Location => "Set location", + TorrentEditKind::Name => "Rename torrent", + }; + let submit_label = match self.kind { + TorrentEditKind::Location => "Move", + TorrentEditKind::Name => "Rename", + }; + let cancel = cx.entity(); + let submit = cx.entity(); + let pick = cx.entity(); + let mut card = div() + .w(px(480.0)) + .p_4() + .gap_3() + .flex() + .flex_col() + .rounded(px(6.0)) + .bg(cx.theme().colors.surface) + .child(Label::new(title)) + .child(self.input.clone()); + if self.kind == TorrentEditKind::Location { + card = card.child( + Button::new("choose-location") + .label("Choose folder…") + .on_click(move |_, _, app| { + pick.update(app, |dialog, cx| dialog.pick_directory(cx)) + }), + ); + } + if let Some(error) = &self.error { + card = card.child(MessageBar::new(error.clone()).intent(MessageIntent::Error)); + } + card = card.child( + div() + .flex() + .gap_2() + .justify_end() + .child(Button::new("torrent-edit-cancel").label("Cancel").on_click( + move |_, _, app| { + cancel.update(app, |_, cx| cx.emit(TorrentEditDialogEvent::Cancel)); + }, + )) + .child( + Button::new("torrent-edit-submit") + .label(submit_label) + .appearance(ButtonAppearance::Accent) + .on_click(move |_, _, app| { + submit.update(app, |dialog, cx| dialog.submit(cx)); + }), + ), + ); + div() + .absolute() + .top_0() + .left_0() + .size_full() + .flex() + .items_center() + .justify_center() + .occlude() + .bg(gpui::black().opacity(0.4)) + .capture_key_down(cx.listener(|dialog, event: &KeyDownEvent, _, cx| { + match event.keystroke.key.as_str() { + "escape" => { + cx.emit(TorrentEditDialogEvent::Cancel); + cx.stop_propagation(); + } + "enter" => { + dialog.submit(cx); + cx.stop_propagation(); + } + _ => {} + } + })) + .child(card) + } +} diff --git a/crates/rtbit-fluent/src/shell.rs b/crates/rtbit-fluent/src/shell.rs index 79caaaef..41ca2678 100644 --- a/crates/rtbit-fluent/src/shell.rs +++ b/crates/rtbit-fluent/src/shell.rs @@ -3,16 +3,41 @@ use fluent_app::WindowTitle; use fluent_layout::{SelectableList, SelectableListItem}; use gpui::{ - AnyElement, Context, Entity, IntoElement, Render, Subscription, Window, div, prelude::*, px, + AnyElement, ClipboardItem, Context, Entity, ExternalPaths, IntoElement, Render, Subscription, + Window, div, prelude::*, px, }; use rtbit_ui_core::{Connection, Screen, Snapshot, StatusFilter}; use crate::screens::MainScreen; use crate::screens::connect::{ConnectEvent, ConnectScreen}; +use crate::screens::main_screen::MainEvent; use crate::screens::rss::RssScreen; use crate::screens::settings::SettingsScreen; use crate::widgets::status_bar::StatusBar; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum DropKind { + TorrentFiles, + Magnet, + Other, +} + +pub(crate) fn classify_drop(paths: Option<&ExternalPaths>, text: Option<&str>) -> DropKind { + if paths.is_some_and(|paths| { + !paths.paths().is_empty() + && paths.paths().iter().all(|path| { + path.extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("torrent")) + }) + }) { + return DropKind::TorrentFiles; + } + if text.is_some_and(|text| text.trim_start().starts_with("magnet:?")) { + return DropKind::Magnet; + } + DropKind::Other +} + /// Screens reachable from the sidebar. Connect and Setup come before login and are not listed. const NAV: [(&str, &str, Screen); 3] = [ ("torrents", "Torrents", Screen::Main), @@ -29,7 +54,10 @@ pub struct Shell { rss: Entity, settings: Entity, main: Entity, + /// Seconds until the next retry while the daemon is lost; `None` when reachable. + lost_retry_secs: Option, _connect_events: Subscription, + _main_events: Subscription, } impl Shell { @@ -50,6 +78,12 @@ impl Shell { shell.on_connect_event(*event, cx); }, ); + let main_events = cx.subscribe( + &main, + |shell: &mut Self, _: Entity, event: &MainEvent, cx: &mut Context| { + shell.on_main_event(*event, cx); + }, + ); Self { screen: Screen::Connect, filter: StatusFilter::All, @@ -58,17 +92,48 @@ impl Shell { rss: cx.new(RssScreen::new), settings, main, + lost_retry_secs: None, _connect_events: connect_events, + _main_events: main_events, } } + /// Follow daemon reachability from the main screen's poll. + fn on_main_event(&mut self, event: MainEvent, cx: &mut Context) { + match event { + MainEvent::DaemonLost { retry_in_secs } => { + self.lost_retry_secs = Some(retry_in_secs); + self.set_connection(Connection::Error, cx); + } + MainEvent::DaemonRecovered => { + self.lost_retry_secs = None; + self.set_connection(Connection::Connected, cx); + } + MainEvent::Unauthorised => { + self.lost_retry_secs = None; + self.screen = Screen::Connect; + self.set_connection(Connection::Disconnected, cx); + } + } + cx.notify(); + } + + /// The lost-connection banner text, while the daemon is unreachable. + #[must_use] + pub fn lost_banner(&self) -> Option { + self.lost_retry_secs + .map(|secs| format!("Lost connection to daemon — retrying in {secs}s")) + } + /// Follow a sign-in attempt: badge and title always; the main screen on success. fn on_connect_event(&mut self, event: ConnectEvent, cx: &mut Context) { + self.lost_retry_secs = None; match event { ConnectEvent::Connecting => self.set_connection(Connection::Connecting, cx), ConnectEvent::Connected => { self.screen = Screen::Main; self.set_connection(Connection::Connected, cx); + self.status.update(cx, |bar, cx| bar.load_settings(cx)); } ConnectEvent::SetupRequired => { self.screen = Screen::Setup; @@ -199,6 +264,30 @@ impl Render for Shell { }); div() + .on_drop(cx.listener(|shell, item: &ExternalPaths, window, cx| { + if classify_drop(Some(item), None) == DropKind::TorrentFiles { + shell + .main + .update(cx, |main, cx| main.open_add_with_paths(item.paths(), cx)); + } else { + shell + .main + .update(cx, |main, cx| main.show_not_torrent_toast(cx)); + } + window.refresh(); + })) + .on_drop(cx.listener(|shell, item: &ClipboardItem, window, cx| { + let text = item.text(); + match classify_drop(None, text.as_deref()) { + DropKind::Magnet => shell.main.update(cx, |main, cx| { + main.open_add_with_magnet(text.as_deref().unwrap_or_default(), cx); + }), + DropKind::Other | DropKind::TorrentFiles => shell + .main + .update(cx, |main, cx| main.show_not_torrent_toast(cx)), + } + window.refresh(); + })) .flex() .size_full() .child( @@ -209,7 +298,20 @@ impl Render for Shell { .child(nav) .child(filters), ) - .child(self.body()) + .child( + div() + .flex() + .flex_col() + .flex_1() + .children(self.lost_banner().map(|text| { + div() + .id("daemon-lost-banner") + .px_3() + .py_1() + .child(fluent_primitives::Label::new(text)) + })) + .child(self.body()), + ) } } @@ -223,6 +325,21 @@ mod tests { use super::*; + #[test] + fn classifies_torrent_magnet_and_other_drops() { + let paths = ExternalPaths( + [std::path::PathBuf::from("one.TORRENT")] + .into_iter() + .collect(), + ); + assert_eq!(classify_drop(Some(&paths), None), DropKind::TorrentFiles); + assert_eq!( + classify_drop(None, Some(" magnet:?xt=urn:btih:abc")), + DropKind::Magnet + ); + assert_eq!(classify_drop(None, Some("hello")), DropKind::Other); + } + /// A daemon whose `/auth/login` answers `login`, with the snapshot routes the main screen polls. fn daemon(runtime: &tokio::runtime::Runtime, login: u16) -> MockServer { const FIXTURES: &str = concat!( @@ -291,6 +408,220 @@ mod tests { drive(cx, |cx| connect.read_with(cx, |s, _| !s.is_pending())); } + /// Sign in, then start the main screen's poll (render does this in the app). + fn signed_in( + cx: &mut TestAppContext, + runtime: &tokio::runtime::Runtime, + ) -> (Entity, Entity, MockServer) { + let server = daemon(runtime, 200); + let (shell, status) = shell(cx); + sign_in(&shell, cx, &server.uri(), "secret"); + let main = shell.read_with(cx, |s, _| s.main.clone()); + main.update(cx, |main, cx| main.start_polling_for_test(cx)); + (shell, status, server) + } + + /// Step the fake clock a second at a time until `done`. + fn tick_until(cx: &mut TestAppContext, mut done: impl FnMut(&mut TestAppContext) -> bool) { + for _ in 0..1500 { + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + if done(cx) { + return; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("condition never reached"); + } + + fn failing_routes(runtime: &tokio::runtime::Runtime, server: &MockServer, status: u16) { + runtime.block_on(async { + server.reset().await; + for route in ["/torrents", "/stats"] { + // A 401 triggers one refresh; the daemon refuses that too. + Mock::given(method("GET")) + .and(path(route)) + .respond_with(ResponseTemplate::new(status)) + .mount(server) + .await; + } + Mock::given(method("POST")) + .and(path("/auth/refresh")) + .respond_with(ResponseTemplate::new(401)) + .mount(server) + .await; + }); + } + + #[gpui::test] + fn lost_daemon_shows_banner_then_clears_on_recovery(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let (shell, status, server) = signed_in(cx, &runtime); + let main = shell.read_with(cx, |s, _| s.main.clone()); + tick_until(cx, |cx| main.read_with(cx, |m, _| m.torrent_count() > 0)); + assert_eq!(shell.read_with(cx, |s, _| s.lost_banner()), None); + + failing_routes(&runtime, &server, 503); + tick_until(cx, |cx| { + shell.read_with(cx, |s, _| s.lost_banner().is_some()) + }); + let banner = shell.read_with(cx, |s, _| s.lost_banner()).unwrap(); + assert!( + banner.starts_with("Lost connection to daemon — retrying in "), + "{banner}" + ); + assert!(main.read_with(cx, |m, _| m.daemon_lost())); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Main); + + // Actions that need the daemon are refused while it is lost. + let before = runtime.block_on(server.received_requests()).unwrap().len(); + main.update(cx, |m, cx| m.open_add(cx)); + assert!(!main.read_with(cx, |m, _| m.add_open())); + assert_eq!( + runtime.block_on(server.received_requests()).unwrap().len(), + before + ); + + // The first good poll clears the banner. + runtime.block_on(async { + server.reset().await; + for (route, file) in [("/torrents", "torrents.json"), ("/stats", "stats.json")] { + let body = std::fs::read_to_string(format!( + "{}/../rtbit-client/tests/fixtures/{file}", + env!("CARGO_MANIFEST_DIR") + )) + .unwrap(); + Mock::given(method("GET")) + .and(path(route)) + .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/json")) + .mount(&server) + .await; + } + }); + tick_until(cx, |cx| { + shell.read_with(cx, |s, _| s.lost_banner().is_none()) + }); + assert!(!main.read_with(cx, |m, _| m.daemon_lost())); + assert_eq!( + status.read_with(cx, |s, _| s.connection()), + Connection::Connected + ); + } + + #[gpui::test] + fn unauthorised_poll_routes_back_to_connect(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let runtime = tokio::runtime::Runtime::new().unwrap(); + let (shell, status, server) = signed_in(cx, &runtime); + let main = shell.read_with(cx, |s, _| s.main.clone()); + tick_until(cx, |cx| main.read_with(cx, |m, _| m.torrent_count() > 0)); + failing_routes(&runtime, &server, 401); + tick_until(cx, |cx| { + shell.read_with(cx, |s, _| s.screen() == Screen::Connect) + }); + assert_eq!(shell.read_with(cx, |s, _| s.lost_banner()), None); + assert_eq!( + status.read_with(cx, |s, _| s.connection()), + Connection::Disconnected + ); + } + + /// Screens that load on selection need a bridge; this one points nowhere. + fn install_idle_bridge(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + cx.update(|cx| { + crate::bridge::Bridge::new("http://127.0.0.1:1") + .unwrap() + .install(cx) + }); + } + + #[gpui::test] + fn no_banner_while_reachable(cx: &mut TestAppContext) { + let (shell, _status) = shell(cx); + assert_eq!(shell.read_with(cx, |s, _| s.lost_banner()), None); + } + + #[gpui::test] + fn daemon_lost_event_sets_banner_and_error_badge_then_recovers(cx: &mut TestAppContext) { + let (shell, status) = shell(cx); + shell.update(cx, |s, cx| { + s.on_main_event(MainEvent::DaemonLost { retry_in_secs: 8 }, cx) + }); + assert_eq!( + shell.read_with(cx, |s, _| s.lost_banner()).as_deref(), + Some("Lost connection to daemon — retrying in 8s") + ); + assert_eq!( + status.read_with(cx, |s, _| s.connection()), + Connection::Error + ); + shell.update(cx, |s, cx| s.on_main_event(MainEvent::DaemonRecovered, cx)); + assert_eq!(shell.read_with(cx, |s, _| s.lost_banner()), None); + assert_eq!( + status.read_with(cx, |s, _| s.connection()), + Connection::Connected + ); + } + + #[gpui::test] + fn unauthorised_event_returns_to_connect(cx: &mut TestAppContext) { + install_idle_bridge(cx); + let (shell, status) = shell(cx); + shell.update(cx, |s, cx| { + s.select_screen("settings", cx); + s.on_main_event(MainEvent::Unauthorised, cx); + }); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Connect); + assert_eq!( + status.read_with(cx, |s, _| s.connection()), + Connection::Disconnected + ); + } + + #[gpui::test] + fn connect_events_route_screens_and_badge(cx: &mut TestAppContext) { + let (shell, status) = shell(cx); + let badge = |cx: &mut TestAppContext| status.read_with(cx, |s, _| s.connection()); + shell.update(cx, |s, cx| s.on_connect_event(ConnectEvent::Connecting, cx)); + assert_eq!(badge(cx), Connection::Connecting); + shell.update(cx, |s, cx| { + s.on_connect_event(ConnectEvent::SetupRequired, cx) + }); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Setup); + assert_eq!(badge(cx), Connection::Disconnected); + shell.update(cx, |s, cx| s.on_connect_event(ConnectEvent::Connected, cx)); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Main); + shell.update(cx, |s, cx| s.on_connect_event(ConnectEvent::SignedOut, cx)); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Connect); + } + + #[gpui::test] + fn select_filter_keeps_screen_and_ignores_unknown_labels(cx: &mut TestAppContext) { + install_idle_bridge(cx); + let (shell, _status) = shell(cx); + shell.update(cx, |s, cx| s.select_screen("rss", cx)); + let target = StatusFilter::ALL[1]; + shell.update(cx, |s, cx| s.select_filter(target.label(), cx)); + assert_eq!(shell.read_with(cx, |s, _| s.filter), target); + shell.update(cx, |s, cx| s.select_filter("no such filter", cx)); + assert_eq!(shell.read_with(cx, |s, _| s.filter), target); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Rss); + } + + #[gpui::test] + fn nav_ids_are_unique_and_unknown_id_keeps_screen(cx: &mut TestAppContext) { + let ids: Vec<_> = NAV.iter().map(|(id, _, _)| *id).collect(); + let mut dedup = ids.clone(); + dedup.sort_unstable(); + dedup.dedup(); + assert_eq!(ids.len(), dedup.len()); + let (shell, _status) = shell(cx); + shell.update(cx, |s, cx| s.select_screen("nope", cx)); + assert_eq!(shell.read_with(cx, |s, _| s.screen()), Screen::Connect); + } + #[gpui::test] fn starts_on_connect_disconnected(cx: &mut TestAppContext) { let (shell, status) = shell(cx); diff --git a/crates/rtbit-fluent/src/table_prefs.rs b/crates/rtbit-fluent/src/table_prefs.rs new file mode 100644 index 00000000..706e04c6 --- /dev/null +++ b/crates/rtbit-fluent/src/table_prefs.rs @@ -0,0 +1,139 @@ +//! Torrent table preferences kept in a small local JSON file: the sort column +//! and direction, and any column widths that differ from the defaults. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +const FILE_NAME: &str = "table.json"; + +/// Smallest width a stored column may take, so a bad file cannot hide a column. +pub const MIN_COLUMN_WIDTH: f32 = 40.0; + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TablePrefs { + /// Sort column id and whether the order is ascending. + pub sort: Option<(String, bool)>, + pub widths: BTreeMap, + pub status: Option, + pub category: Option, + pub tag: Option, + pub tracker: Option, +} + +impl TablePrefs { + /// `/rtbit-fluent/table.json`, or `None` when no home is known. + #[must_use] + pub fn default_path() -> Option { + let base = if cfg!(windows) { + std::env::var_os("APPDATA").map(PathBuf::from) + } else { + std::env::var_os("XDG_CONFIG_HOME") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .or_else(|| { + std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")) + }) + }?; + Some(base.join("rtbit-fluent").join(FILE_NAME)) + } + + /// Read the file; a missing or unreadable file gives the defaults. + #[must_use] + pub fn load(path: &Path) -> Self { + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .map(|value| Self::from_json(&value)) + .unwrap_or_default() + } + + pub fn save(&self, path: &Path) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(path, serde_json::to_string_pretty(&self.to_json())?) + } + + fn to_json(&self) -> Value { + let sort = self + .sort + .as_ref() + .map(|(column, ascending)| json!({ "column": column, "ascending": ascending })); + json!({ "sort": sort, "widths": self.widths, "status": self.status, + "category": self.category, "tag": self.tag, "tracker": self.tracker }) + } + + fn from_json(value: &Value) -> Self { + let sort = value.get("sort").and_then(|sort| { + let column = sort.get("column")?.as_str()?.to_owned(); + let ascending = sort.get("ascending")?.as_bool()?; + Some((column, ascending)) + }); + let widths = value + .get("widths") + .and_then(Value::as_object) + .map(|map| { + map.iter() + .filter_map(|(id, width)| { + let width = width.as_f64()? as f32; + (width.is_finite() && width >= MIN_COLUMN_WIDTH) + .then(|| (id.clone(), width)) + }) + .collect() + }) + .unwrap_or_default(); + let string = |key| value.get(key).and_then(Value::as_str).map(str::to_owned); + Self { + sort, + widths, + status: string("status"), + category: string("category"), + tag: string("tag"), + tracker: string("tracker"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("rtbit-fluent-{name}-{}", std::process::id())) + } + + #[test] + fn round_trips_through_the_file() { + let dir = temp_path("roundtrip"); + let path = dir.join("nested").join(FILE_NAME); + let mut prefs = TablePrefs { + sort: Some(("speed".to_owned(), false)), + ..Default::default() + }; + prefs.widths.insert("name".to_owned(), 411.5); + prefs.save(&path).unwrap(); + assert_eq!(TablePrefs::load(&path), prefs); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_or_corrupt_files_give_defaults() { + let dir = temp_path("corrupt"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(FILE_NAME); + assert_eq!(TablePrefs::load(&path), TablePrefs::default()); + std::fs::write(&path, "not json").unwrap(); + assert_eq!(TablePrefs::load(&path), TablePrefs::default()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn implausible_widths_are_dropped() { + let value = json!({ "widths": { "name": 5, "size": 120, "eta": "wide" } }); + let prefs = TablePrefs::from_json(&value); + assert_eq!(prefs.widths.len(), 1); + assert_eq!(prefs.widths["size"], 120.0); + } +} diff --git a/crates/rtbit-fluent/src/widgets/status_bar.rs b/crates/rtbit-fluent/src/widgets/status_bar.rs index 24aa32e7..e95c0aed 100644 --- a/crates/rtbit-fluent/src/widgets/status_bar.rs +++ b/crates/rtbit-fluent/src/widgets/status_bar.rs @@ -1,15 +1,24 @@ //! Bottom status bar: connection badge, torrent total and live speeds. -use fluent_primitives::{ConnectionBadge, ConnectionBadgeState, Label, LabelSize}; -use gpui::{Context, IntoElement, Render, Window, div, prelude::*}; +use fluent_primitives::{Button, ButtonAppearance, ConnectionBadge, ConnectionBadgeState, Label, LabelSize, TextInput}; +use gpui::{Context, IntoElement, Render, Task, Window, div, prelude::*}; +use rtbit_client::{RateLimits, SettingsSnapshot}; use rtbit_ui_core::{Connection, Snapshot, format}; +use crate::bridge::Bridge; + /// Status bar state. The shell pushes values in; nothing here talks to the daemon. pub struct StatusBar { connection: Connection, torrents: u64, download: String, upload: String, + limits: RateLimits, + alt_enabled: bool, + popover: bool, + download_input: Option>, + upload_input: Option>, + _limit_request: Option>, } impl StatusBar { @@ -20,6 +29,12 @@ impl StatusBar { torrents: 0, download: format::speed(&Default::default()), upload: format::speed(&Default::default()), + limits: RateLimits::default(), + alt_enabled: false, + popover: false, + download_input: None, + upload_input: None, + _limit_request: None, } } @@ -41,6 +56,71 @@ impl StatusBar { self.upload = format::speed(&snapshot.stats.upload_speed); cx.notify(); } + + /// Update the limits displayed in the status bar after a settings poll. + pub fn apply_settings(&mut self, settings: &SettingsSnapshot, cx: &mut Context) { + self.limits = settings.limits.clone(); + self.alt_enabled = settings.alt.enabled; + cx.notify(); + } + + /// Fetch the current session limits when a daemon connection becomes ready. + pub fn load_settings(&mut self, cx: &mut Context) { + if !cx.has_global::() { + return; + } + let entity = cx.entity(); + self._limit_request = Some(cx.spawn(async move |_, cx| { + let reply = cx.update(|cx| cx.global::().call(|client| async move { + client.lock().await.settings().await + })); + if let Ok(Ok(settings)) = reply.await { + entity.update(cx, |bar, cx| bar.apply_settings(&settings, cx)); + } + })); + } + + fn toggle_alt(&mut self, cx: &mut Context) { + let previous = self.alt_enabled; + let next = !previous; + self.alt_enabled = next; + cx.notify(); + let entity = cx.entity(); + self._limit_request = Some(cx.spawn(async move |_, cx| { + let result = cx.update(|cx| cx.global::().call(move |client| async move { + client.lock().await.set_alt_enabled(next).await + })); + let ok = result.await.is_ok_and(|reply| reply.is_ok()); + if !ok { + entity.update(cx, |bar, cx| { + bar.alt_enabled = previous; + cx.notify(); + }); + } + })); + } + + fn apply_limits(&mut self, cx: &mut Context) { + let download_bps = self.download_input.as_ref().and_then(|input| input.read(cx).text().trim().parse::().ok()); + let upload_bps = self.upload_input.as_ref().and_then(|input| input.read(cx).text().trim().parse::().ok()); + let next = RateLimits { download_bps, upload_bps, ..self.limits.clone() }; + let previous = self.limits.clone(); + self.limits = next.clone(); + self.popover = false; + cx.notify(); + let entity = cx.entity(); + self._limit_request = Some(cx.spawn(async move |_, cx| { + let result = cx.update(|cx| cx.global::().call(move |client| async move { + client.lock().await.set_limits(&next).await + })); + if !result.await.is_ok_and(|reply| reply.is_ok()) { + entity.update(cx, |bar, cx| { + bar.limits = previous; + cx.notify(); + }); + } + })); + } } impl Default for StatusBar { @@ -59,7 +139,22 @@ fn badge_state(connection: Connection) -> ConnectionBadgeState { } impl Render for StatusBar { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.download_input.is_none() { + self.download_input = Some(cx.new(|_| TextInput::new().placeholder("Download B/s (blank = unlimited)"))); + self.upload_input = Some(cx.new(|_| TextInput::new().placeholder("Upload B/s (blank = unlimited)"))); + } + let me = cx.entity(); + let toggle = me.clone(); + let download = me.clone(); + let upload = me.clone(); + let apply = me.clone(); + let limits = if self.alt_enabled { + format!("{} / {}", format::bytes(self.limits.download_bps.unwrap_or(0) as u64), format::bytes(self.limits.upload_bps.unwrap_or(0) as u64)) + } else { + format!("{} / {}", self.limits.download_bps.map_or_else(|| "∞".to_owned(), |v| format::bytes(v as u64)), self.limits.upload_bps.map_or_else(|| "∞".to_owned(), |v| format::bytes(v as u64))) + }; + let popover = self.popover; div() .flex() .items_center() @@ -73,5 +168,109 @@ impl Render for StatusBar { .child(Label::new(format!("{} torrents", self.torrents)).size(LabelSize::Caption)) .child(Label::new(format!("↓ {}", self.download)).size(LabelSize::Caption)) .child(Label::new(format!("↑ {}", self.upload)).size(LabelSize::Caption)) + .child(Button::new("status-download-limit").label(format!("DL {}", limits.split_once(" / ").map_or("∞", |v| v.0))).appearance(ButtonAppearance::Subtle).on_click(move |_, _, cx| { + download.update(cx, |bar, cx| { bar.popover = true; cx.notify(); }); + })) + .child(Button::new("status-upload-limit").label(format!("UL {}", limits.split_once(" / ").map_or("∞", |v| v.1))).appearance(ButtonAppearance::Subtle).on_click(move |_, _, cx| { + upload.update(cx, |bar, cx| { bar.popover = true; cx.notify(); }); + })) + .child(Button::new("status-alt-speed").label(if self.alt_enabled { "Alt speed: on" } else { "Alt speed: off" }).appearance(if self.alt_enabled { ButtonAppearance::Accent } else { ButtonAppearance::Subtle }).on_click(move |_, _, cx| { + toggle.update(cx, |bar, cx| bar.toggle_alt(cx)); + })) + .when(popover, |element| element.child( + div().flex().gap_1() + .child(self.download_input.clone().expect("created above")) + .child(self.upload_input.clone().expect("created above")) + .child(Button::new("status-limit-apply").label("Apply").on_click(move |_, _, cx| { + apply.update(cx, |bar, cx| bar.apply_limits(cx)); + })), + )) + } +} + +#[cfg(test)] +mod tests { + use gpui::TestAppContext; + use rtbit_client::{SessionStats, TorrentList}; + + use super::*; + + const FIXTURES: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../rtbit-client/tests/fixtures" + ); + + fn snapshot() -> Snapshot { + let read = |name: &str| std::fs::read_to_string(format!("{FIXTURES}/{name}")).unwrap(); + Snapshot { + torrents: serde_json::from_str::(&read("torrents.json")).unwrap(), + stats: serde_json::from_str::(&read("stats.json")).unwrap(), + } + } + + #[test] + fn badge_state_maps_every_connection() { + assert_eq!( + badge_state(Connection::Disconnected), + ConnectionBadgeState::Disconnected + ); + assert_eq!( + badge_state(Connection::Connecting), + ConnectionBadgeState::Pairing + ); + assert_eq!( + badge_state(Connection::Connected), + ConnectionBadgeState::Paired + ); + assert_eq!(badge_state(Connection::Error), ConnectionBadgeState::Error); + } + + #[test] + fn new_bar_is_disconnected_with_zero_speeds() { + let bar = StatusBar::default(); + assert_eq!(bar.connection(), Connection::Disconnected); + assert_eq!(bar.torrents, 0); + assert_eq!(bar.download, "0 B/s"); + assert_eq!(bar.upload, "0 B/s"); + } + + #[gpui::test] + fn snapshot_sets_count_and_formatted_speeds(cx: &mut TestAppContext) { + let bar = cx.new(|_| StatusBar::new()); + let snap = snapshot(); + bar.update(cx, |bar, cx| bar.apply_snapshot(&snap, cx)); + bar.read_with(cx, |bar, _| { + assert_eq!(bar.torrents, 1); + assert_eq!(bar.download, format::speed(&snap.stats.download_speed)); + assert_eq!(bar.upload, format::speed(&snap.stats.upload_speed)); + assert_ne!(bar.download, "0 B/s"); + assert!(bar.download.ends_with("/s")); + }); + } + + #[gpui::test] + fn later_snapshot_replaces_earlier_values(cx: &mut TestAppContext) { + let bar = cx.new(|_| StatusBar::new()); + let mut snap = snapshot(); + bar.update(cx, |bar, cx| bar.apply_snapshot(&snap, cx)); + snap.torrents.total = 7; + snap.stats.download_speed.mbps = 0.0; + bar.update(cx, |bar, cx| bar.apply_snapshot(&snap, cx)); + bar.read_with(cx, |bar, _| { + assert_eq!(bar.torrents, 7); + assert_eq!(bar.download, "0 B/s"); + }); + } + + #[gpui::test] + fn set_connection_updates_state_and_keeps_numbers(cx: &mut TestAppContext) { + let bar = cx.new(|_| StatusBar::new()); + let snap = snapshot(); + bar.update(cx, |bar, cx| bar.apply_snapshot(&snap, cx)); + bar.update(cx, |bar, cx| bar.set_connection(Connection::Error, cx)); + bar.read_with(cx, |bar, _| { + assert_eq!(bar.connection(), Connection::Error); + assert_eq!(bar.torrents, 1); + }); } } diff --git a/crates/rtbit-ui-core/src/error.rs b/crates/rtbit-ui-core/src/error.rs index 03448cdf..eada7f64 100644 --- a/crates/rtbit-ui-core/src/error.rs +++ b/crates/rtbit-ui-core/src/error.rs @@ -1,6 +1,6 @@ //! Safe daemon error text. //! -//! The client paints it with `fluent2_egui::surfaces::MessageBar`. This +//! The client paints it with `fluent_layout::MessageBar`. This //! module only maps a client error onto a fixed sentence so a transport failure never //! shows a token, a body or a host detail. A 400 keeps the daemon's //! validation sentence, which is already safe. @@ -13,7 +13,7 @@ pub fn map_error(err: &rtbit_client::Error) -> String { rtbit_client::Error::Daemon { status, message: Some(message), - } if status.as_u16() == 400 && !message.is_empty() => message.clone(), + } if matches!(status.as_u16(), 400 | 409) && !message.is_empty() => message.clone(), rtbit_client::Error::Daemon { status, .. } => { format!("The daemon returned an error ({status}).") } diff --git a/crates/rtbit/src/main.rs b/crates/rtbit/src/main.rs index d2a53815..bd650a98 100644 --- a/crates/rtbit/src/main.rs +++ b/crates/rtbit/src/main.rs @@ -160,7 +160,7 @@ struct Opts { #[arg( long = "shutdown-timeout", value_parser = humantime::parse_duration, - default_value = "30s", + default_value = "20s", env = "RTBIT_SHUTDOWN_TIMEOUT" )] shutdown_timeout: Duration, @@ -589,6 +589,36 @@ enum SubCommand { Auth(AuthOpts), /// Inspect the effective configuration. Config(ConfigOpts), + /// Run read-only diagnostics against saved session state. + Diagnose(DiagnoseOpts), +} + +#[derive(Parser)] +struct DiagnoseOpts { + #[clap(subcommand)] + subcommand: DiagnoseSubcommand, +} + +#[derive(Parser)] +enum DiagnoseSubcommand { + /// Report persisted torrents whose expected payload files are missing or the wrong size. + Missing(DiagnoseMissingOpts), +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum DiagnoseOutput { + Table, + Json, +} + +#[derive(Parser)] +struct DiagnoseMissingOpts { + /// JSON persistence directory. Defaults to rtbit's normal session directory. + #[arg(long)] + persistence_location: Option, + /// Report output format. + #[arg(long, value_enum, default_value = "table")] + output: DiagnoseOutput, } #[derive(Parser)] @@ -717,6 +747,10 @@ fn main() -> anyhow::Result<()> { return run_config_command(&opts, config_opts); } + if let SubCommand::Diagnose(diagnose_opts) = &opts.subcommand { + return run_diagnose_command(diagnose_opts); + } + #[cfg(not(target_os = "windows"))] if let Some(umask) = opts.umask { unsafe { libc::umask(umask) }; @@ -792,6 +826,62 @@ fn main() -> anyhow::Result<()> { } } +fn run_diagnose_command(opts: &DiagnoseOpts) -> anyhow::Result<()> { + let DiagnoseSubcommand::Missing(opts) = &opts.subcommand; + let persistence_location = opts + .persistence_location + .clone() + .map(Ok) + .unwrap_or_else(SessionPersistenceConfig::default_json_persistence_folder)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build()?; + let reports = runtime.block_on(librtbit::report_missing_files(&persistence_location))?; + match opts.output { + DiagnoseOutput::Json => println!("{}", serde_json::to_string_pretty(&reports)?), + DiagnoseOutput::Table => print_missing_table(&reports), + } + Ok(()) +} + +fn print_missing_table(reports: &[librtbit::MissingTorrentReport]) { + if reports.is_empty() { + println!("No torrents have missing or wrong-sized payload files."); + return; + } + for report in reports { + println!( + "torrent {} {} ({})\nsave_path: {}\ndiagnosis: {:?}", + report.id, + report.name, + report.info_hash, + report.save_path.display(), + report.diagnosis + ); + println!("status\tparent\texpected\tactual\tpath"); + for file in &report.files { + let (status, actual) = match file.status { + librtbit::MissingFileStatus::Exists => ("exists", file.expected_size.to_string()), + librtbit::MissingFileStatus::WrongSize { actual_size } => { + ("wrong-size", actual_size.to_string()) + } + librtbit::MissingFileStatus::Absent => ("absent", "-".into()), + }; + println!( + "{status}\t{}\t{}\t{actual}\t{}", + if file.parent_dir_exists { + "exists" + } else { + "absent" + }, + file.expected_size, + file.path.display() + ); + } + println!(); + } +} + async fn parse_trackers_file(filename: &str) -> anyhow::Result> { let content = tokio::fs::read_to_string(filename) .await @@ -964,6 +1054,7 @@ async fn async_main(mut opts: Opts, cancel: CancellationToken) -> anyhow::Result token_store, credential_store: Some(credential_store), api_keys, + shutdown_token: Some(cancel.clone()), // We need to install prometheus recorder early before we registered any metrics. #[cfg(feature = "prometheus")] @@ -1046,8 +1137,18 @@ async fn async_main(mut opts: Opts, cancel: CancellationToken) -> anyhow::Result let result = http_api_fut.await; // The signal handler cancels the HTTP API, so this also runs on - // SIGINT and SIGTERM. Save torrent state before the process exits. - session.stop().await; + // SIGINT and SIGTERM. Save torrent state before the process exits, + // but never let a slow persistence backend outlive the shutdown + // deadline (the signal handler enforces the same hard bound). + if tokio::time::timeout(opts.shutdown_timeout, session.stop()) + .await + .is_err() + { + warn!( + timeout = ?opts.shutdown_timeout, + "graceful shutdown timed out before all torrent state was saved" + ); + } result } }, @@ -1266,6 +1367,7 @@ async fn async_main(mut opts: Opts, cancel: CancellationToken) -> anyhow::Result SubCommand::Completions(_) => unreachable!(), SubCommand::Auth(_) => unreachable!(), SubCommand::Config(_) => unreachable!(), + SubCommand::Diagnose(_) => unreachable!(), } } @@ -1678,7 +1780,7 @@ mod tests { #[test] fn shutdown_timeout_defaults_to_30s_and_is_configurable() { let opts = parse_server_opts(&[]); - assert_eq!(opts.shutdown_timeout, std::time::Duration::from_secs(30)); + assert_eq!(opts.shutdown_timeout, std::time::Duration::from_secs(20)); let opts = parse_server_opts(&["--shutdown-timeout", "2m"]); assert_eq!(opts.shutdown_timeout, std::time::Duration::from_secs(120)); diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..48015cfc --- /dev/null +++ b/deny.toml @@ -0,0 +1,70 @@ +# Dependency policy for the complete workspace. Keep CI's cargo-deny version in +# .github/workflows/policy.yml in sync with local verification. + +[graph] +all-features = true + +[advisories] +ignore = [ + # Accepted 2026-11-02: transitive dependency; upstream migration required. + { id = "RUSTSEC-2025-0141", reason = "Accepted 2026-11-02: transitive bincode dependency; replace through its upstream consumers." }, + { id = "RUSTSEC-2025-0060", reason = "Accepted 2026-11-02: transitive crypto-hash dependency; replace through its upstream consumer." }, + { id = "RUSTSEC-2024-0436", reason = "Accepted 2026-11-02: transitive paste dependency; replace through its upstream consumers." }, + { id = "RUSTSEC-2024-0370", reason = "Accepted 2026-11-02: transitive proc-macro-error dependency; replace through its upstream consumer." }, + { id = "RUSTSEC-2026-0206", reason = "Accepted 2026-11-02: rustybuzz is supplied by the pinned gpui dependency tree." }, + { id = "RUSTSEC-2026-0192", reason = "Accepted 2026-11-02: ttf-parser is supplied by the pinned gpui dependency tree." }, + { id = "RUSTSEC-2026-0370", reason = "Accepted 2026-11-02: zvariant is transitive through desktop integration crates." }, + { id = "RUSTSEC-2026-0371", reason = "Accepted 2026-11-02: zbus_names is transitive through desktop integration crates." }, + { id = "RUSTSEC-2026-0372", reason = "Accepted 2026-11-02: zvariant_derive is transitive through desktop integration crates." }, + # Accepted 2026-11-02: vulnerable APIs are not called by rustTorrent; update + # the pinned gpui/desktop dependency trees before removing these entries. + { id = "RUSTSEC-2026-0385", reason = "Accepted 2026-11-02: ctutils is transitive through gpui and rustTorrent does not use its mask APIs directly." }, + { id = "RUSTSEC-2026-0293", reason = "Accepted 2026-11-02: ringbuf is transitive through gpui and rustTorrent does not call the affected APIs directly." }, + { id = "RUSTSEC-2026-0367", reason = "Accepted 2026-11-02: zip is transitive and rustTorrent does not use ZipArchive extraction." }, + { id = "RUSTSEC-2026-0368", reason = "Accepted 2026-11-02: zip is transitive and rustTorrent does not use ZipArchive extraction." }, + { id = "RUSTSEC-2026-0369", reason = "Accepted 2026-11-02: zip is transitive and rustTorrent does not use ZipArchive extraction." }, + { id = "RUSTSEC-2026-0429", reason = "Accepted 2026-11-02: zip is transitive and rustTorrent does not use ZipArchive extraction." }, +] + +[licenses] +confidence-threshold = 0.93 +allow = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-1-Clause", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "CC0-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MIT-0", + "MPL-2.0", + "NCSA", + "Unicode-3.0", + "Unicode-DFS-2016", + "Unlicense", + "Zlib", +] +unused-allowed-license = "allow" + +[licenses.private] +ignore = true + +[bans] +multiple-versions = "allow" +wildcards = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [ + "https://github.com/gfx-rs/wgpu", + "https://github.com/thedancingdeveloper/fluent-gpui", + "https://github.com/zed-industries/font-kit", + "https://github.com/zed-industries/oo7", + "https://github.com/zed-industries/xim-rs.git", +] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index fbf0954d..542b72b1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ name = "rtbit-desktop" edition = "2024" version = "0.2.0-beta.4" description = "rtbit torrent client" -license = "" +license = "Apache-2.0" repository = "" [lints] diff --git a/docs/QBIT-API-PARITY.md b/docs/QBIT-API-PARITY.md index b7d29477..81f78239 100644 --- a/docs/QBIT-API-PARITY.md +++ b/docs/QBIT-API-PARITY.md @@ -8,9 +8,10 @@ and enforced, and where we currently stand. ## Current standing (upstream WebAPI 2.16.2, refreshed 2026-10-17) -**79 of 95 in-scope endpoints routed (~83%)** — 38 full, 41 partial — plus 2 -legacy aliases (`torrents/pause`, `torrents/resume`) that upstream removed in -WebAPI 2.11. 35 of the 130 upstream endpoints are descoped (since 2026-08-30; + +**86 of 95 in-scope endpoints routed (~91%)** — 42 full, 44 partial — plus 2 legacy aliases (`torrents/pause`, `torrents/resume`) that upstream removed in WebAPI 2.11. + +35 of the 130 upstream endpoints are descoped (since 2026-08-30; `clientdata/*` was brought back in scope for WI-17) (marked `out_of_scope`, enforced as unrouted): `search/*` (Indexarr covers discovery), `rss/*` (native `/rss` + Indexarr are the RSS story), SSL parameters, and qBittorrent-internal app misc (email, cookies, API keys, @@ -47,6 +48,11 @@ of H4, and tag persistence): - **Preferences**: `app/preferences` reports the seeding-limit fields (`max_ratio_enabled`, `max_ratio`, `max_ratio_act`, `max_seeding_time_enabled`, `max_seeding_time`) as disabled, using qBittorrent's default values. +- **Global share limits and discovery (WI-2884)**: `app/preferences` now reports + the session-wide seed limits and `dht`/`lsd`/`pex`, and `app/setPreferences` + applies `max_ratio*`, `max_seeding_time*`, `max_inactive_seeding_time*` + (global limits, used by torrents without their own) and `dht`/`lsd` (saved + for the next start; `pex` is always on). - **`app/setPreferences`**: unknown and unsupported fields are ignored, and `announce_port` is optional. Read-modify-write clients that post the whole preferences object now succeed. A present but invalid `announce_port` still @@ -82,6 +88,10 @@ of H4, and tag persistence): qBittorrent's default of 3. There is no combined `max_active_torrents` limit (reported as -1). A newly added torrent starts at once and is moved back to the queue by the next pass if the queue is full. +- **Per-file priorities**: `torrents/filePrio` accepts and persists qBittorrent's + `0` (skipped), `1` (normal), `6` (high), and `7` (maximum) levels. The values + round-trip through `torrents/files`; `0` retains the existing include/skip + behavior, while the picker requests pieces from higher-priority files first. - **Errors**: compat handlers return plain-text errors through one helper, with lowercase messages. Serde parse details are no longer echoed to clients. diff --git a/docs/ci.md b/docs/ci.md index a12ae33c..051610fd 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -30,6 +30,8 @@ See [TESTING.md](TESTING.md#webui-api-v2-compatibility-client-replay-and-differe | `.github/`, `ci/`, `.cargo/`, `Dockerfile*`, `.dockerignore`, `rust-toolchain.toml` | everything, as in the queue | | `crates/librtbit/` (outside the web UI) | `rust`, scoped to `swarmforge`; `desktop` | | `crates/rtbit/` | `rust`, scoped to `rtbit` | +| `crates/rtbit-client/`, `crates/rtbit-ui-core/` | `rust`, plus `rtbit-fluent` nextest (reverse dependencies) | +| `crates/librtbit/src/http_api_types.rs` | `rust`, plus `rtbit-fluent` nextest (API type consumer) | | `crates/librtbit/webui/` | `webui`, `gui-e2e` | | `desktop/src/` (the splash, tested by `e2e/desktop-splash.spec.ts`) | `webui`, `gui-e2e`, `desktop` | | `desktop/src-tauri/`, `packaging/scripts/check-desktop.sh` | `desktop` | @@ -54,6 +56,11 @@ toolchain, cargo config, workflows and task scripts) takes the full workspace. The scope is also widened when the classifier sees a Rust change it cannot attribute to a package, so it never silently runs nothing. +`rtbit-fluent` is a reverse dependency of `rtbit-client` and `rtbit-ui-core`, +and consumes `librtbit` API types from `http_api_types.rs`. Those paths set a +separate fluent flag so the `rust` job runs `cargo nextest -p rtbit-fluent` +without applying the daemon-only `default-tls` feature. + ### Cache and test runner The `rust` job restores a shared `Swatinem/rust-cache` cache keyed on the @@ -135,6 +142,11 @@ secrets. It runs: surfaces before a release; - on manual dispatch. +If a nightly or manually dispatched dry run fails, the workflow opens the +`[CI] Release dry run is failing` tracking issue, or comments with the latest +failed run when that issue is already open. Pull-request failures remain on the +pull request and do not create tracking issues. + It derives the tag from the `crates/rtbit/Cargo.toml` version (the release version `scripts/check-repo-hygiene.sh` enforces everywhere), builds the Linux and Windows binaries, the `.deb`, the `.rpm`, the service-files tarball, the diff --git a/docs/fluent-parity.md b/docs/fluent-parity.md new file mode 100644 index 00000000..6ab1a0a0 --- /dev/null +++ b/docs/fluent-parity.md @@ -0,0 +1,104 @@ +# Fluent client parity checklist + +This checklist compares the retired `rtbit-egui` client at the parent of +`b15018f` (removed by `b15018f`, WI-2770) with `rtbit-fluent` on graft line +`fluent-1`. “Done” means the Fluent client has the user-facing surface and +the corresponding bridge/client behavior; “missing” identifies follow-up +work; “dropped-on-purpose” records a deliberate boundary rather than a gap. + +Sources: [`rtbit-egui` in history](https://github.com/TheDancingDeveloper-org/rustTorrent/tree/b15018f%5E/crates/rtbit-egui), +[`rtbit-fluent`](../crates/rtbit-fluent), and the shared action/data contracts +in [`rtbit-ui-core`](../crates/rtbit-ui-core). + +## Screens and navigation + +| Retired egui feature | Fluent status | Evidence / follow-up | +| --- | --- | --- | +| Connect screen: daemon URL, username, password, Connect | done | `screens/connect.rs`; bridge login and connection events are wired. | +| First-run setup: setup token, username, password, confirmation, Create account | done | `screens/connect.rs`; setup validation and bridge setup are wired. | +| Session navigation: Settings, RSS, Sign out | done | `shell.rs`; sign-out clears the session and returns to Connect. | +| Connection status indicator and window status | done | `widgets/status_bar.rs` and `shell.rs`. | +| Torrents screen and live polling | done | `screens/main_screen.rs`; snapshot polling, loading, and error states are present. | +| Torrent detail pane | done | `screens/detail.rs`; selected torrent drives the pane. | +| RSS screen | done | `screens/rss.rs`; feeds, items, rules, download, and matched episodes are present. | +| Settings screen | done | `screens/settings.rs`; all six retired tabs remain available. | + +## Torrents screen + +| Retired egui feature | Fluent status | Evidence / follow-up | +| --- | --- | --- | +| Torrent table columns: name, state, size, down, up, category, tracker | done | `screens/main_screen.rs` data table and snapshot formatting. | +| Status filter: All, Live, Downloading, Paused, Error, Initializing | done | Shared `StatusFilter` list and Fluent selectable filter. | +| Add torrent dialog | done | `screens/add_dialog.rs`. | +| Add by magnet link | done | Magnet validation requires `xt=urn:btih:` and submits through the bridge. | +| Add by `.torrent` file | done | Native file picker path and file bytes are supported. | +| Save path and optional category | done | Add dialog fields and `AddRequest`. | +| Start paused | done | Add dialog switch is included in the request. | +| Start/resume selected torrent | done | Toolbar and context menu dispatch `RowAction::Start`. | +| Pause selected torrent | done | Toolbar and context menu dispatch `RowAction::Pause`. | +| Delete selected torrent, with confirmation | done | Toolbar/context menu and `ConfirmModal` dispatch `RowAction::Delete`. | +| Keyboard shortcuts: add, paste magnet, delete, pause, select all, filter focus, refresh, context menu | done | `screens/main_screen.rs` key context and actions. | +| Empty, loading, filtered-empty, and request-error states | done | Main table rendering and action error bar. | + +## Torrent detail + +| Retired egui feature | Fluent status | Evidence / follow-up | +| --- | --- | --- | +| General tab with name, state, size, progress, rates, and metadata | done | `screens/detail.rs` `general` renderer. | +| Files tab with nested folders and files | done | Fluent file tree handles component paths and legacy slash-separated names. | +| Include/exclude file or folder selection | done | Checkbox and folder aggregation post file selections. | +| File priority label: Normal / Do not download / Mixed | done | `priority_label` and tree controls. | +| Pieces tab and piece progress summary | done | Pieces tab and empty/loading states are present. | +| Peers tab with address, state, download, and upload columns | done | `peers_table`. | +| Trackers tab with tracker URL, status, seeders, and leechers | done | `trackers_table`. | +| Add tracker URL and Add action | done | `screens/detail.rs` validates and submits the URL, reloads trackers, and preserves the selection and input on refusal. | +| No-selection, loading, and empty detail states | done | `empty_state_message` and async snapshot handling. | + +## RSS + +| Retired egui feature | Fluent status | Evidence / follow-up | +| --- | --- | --- | +| Feed list and selected feed items | done | `screens/rss.rs`; feed and item tables are rendered. | +| Item status: Downloaded / Download / No link | done | `item_status` and download action. | +| Download selected item | done | Bridge download action and disabled-state check. | +| Rules list with name, feeds, regex, priority, and enabled state | done | `rule_line` and rule table. | +| Add rule dialog/editor | done | Name, match regex, category, feed selection, enabled, and tracking fields are present. | +| Edit rule and matched episode history | done | Existing rule loads its fields and matched episodes. | +| Delete rule | done | Rule delete action is wired. | +| Rule validation and in-dialog save/cancel/error behavior | done | Client validation and bridge refusal remain in the editor. | +| Rule priority selector: Low / Normal / High | missing | Raise and implement [#287](https://github.com/TheDancingDeveloper-org/rustTorrent/issues/287) on graft line `fluent-1`; the current editor only displays the priority. | + +## Settings fields + +The retired client exposed six tabs. The Fluent implementation retains the +same tab set and field ownership; all rows below are present in the Fluent +draft, rendered form, validation, and section-specific save path unless noted. + +| Tab | Fields and actions | Status | +| --- | --- | --- | +| General | Append `.part` to incomplete files; Save | done | +| Connection | Connect/read-write/keep-alive timeouts; upload slots; magnet timeout; listen IP and port; DHT; local discovery; UPnP; read-only peer exchange; proxy URL; clear proxy; IP filter; saved-vs-running network warning; Use startup flags; Save | done | +| Speed | Download/upload limits; peer limit; connection limit; alternative limits; alternative schedule enabled/start/end/days; Save | done | +| Folders | Download folder; completed folder; required-folder validation; Save | done | +| Seeding | Ratio limit; seed time; Save | done | +| Advanced | Maximum concurrent handshakes; fast-resume validation denominator; Save | done | +| Settings loading, validation errors, busy state, save notices, and restart notice | done | `SettingsScreen` async load/save state and validation messages. | + +## Dialogs and boundaries + +| Retired egui dialog or behavior | Fluent status | Evidence / follow-up | +| --- | --- | --- | +| Connect/setup forms and failure messages | done | Connect screen renders both modes and keeps errors visible. | +| Add-torrent modal with Cancel, file picker, validation, and submit | done | `AddDialog` and `validate_add`. | +| Delete-torrent confirmation | done | Main-screen `ConfirmModal`, Escape, and Enter handling. | +| RSS add/edit rule editor with Cancel, Save, feed selection, and matched episodes | done | RSS editor and episode section. | +| RSS delete-rule confirmation | done | Delete action is wired through the RSS screen. | +| OS keychain refresh-token persistence | done | Connect bridge uses `OsKeychain`; only refresh tokens are stored. | +| Native egui viewport/window controls and egui-specific theme installation | dropped-on-purpose | Fluent/GPUI owns the window shell and theme; these are toolkit implementation details, not product behavior. | +| Retired egui test harness and egui-only stand-ins | dropped-on-purpose | The Fluent crate has its own GPUI/unit/integration coverage; no compatibility shim is required. | + +## Gap register + +The only missing product features found in this audit are tracked by the two +new items above. No other egui screen, column, action, settings field, or +dialog is intentionally left without a status. diff --git a/scripts/refresh-qbit-parity-spec.py b/scripts/refresh-qbit-parity-spec.py index 945fd14f..d53a33ac 100755 --- a/scripts/refresh-qbit-parity-spec.py +++ b/scripts/refresh-qbit-parity-spec.py @@ -30,6 +30,7 @@ Path(__file__).resolve().parent.parent / "crates/librtbit/src/http_api/handlers/qbit_parity_spec.json" ) +DOC_PATH = Path(__file__).resolve().parent.parent / "docs/QBIT-API-PARITY.md" # controller header -> URL scope (must match registerAPIController names in # qBittorrent's webapplication.cpp). @@ -48,6 +49,19 @@ def main() -> int: + check = len(sys.argv) == 2 and sys.argv[1] == "--check" + if check: + spec = json.loads(SPEC_PATH.read_text()) + expected = parity_summary(spec) + document = DOC_PATH.read_text() + marker = "" + actual = document.split(marker, 1)[1].split("\n", 2)[1] if marker in document else "" + if actual != expected: + print("parity summary is stale; run scripts/refresh-qbit-parity-spec.py ", file=sys.stderr) + print(f"expected: {expected}", file=sys.stderr) + print(f"actual: {actual or ''}", file=sys.stderr) + return 1 + return 0 if len(sys.argv) != 2: print(__doc__, file=sys.stderr) return 2 @@ -114,6 +128,7 @@ def main() -> int: } spec["endpoints"] = rows SPEC_PATH.write_text(json.dumps(spec, indent=2) + "\n") + update_document(spec) counts = {} for row in rows: @@ -130,5 +145,28 @@ def main() -> int: return 0 +def parity_summary(spec: dict) -> str: + upstream = [row for row in spec["endpoints"] + if row.get("upstream", True) and row["status"] != "out_of_scope"] + counts = {status: sum(row["status"] == status for row in upstream) + for status in ("full", "partial", "missing", "out_of_scope")} + implemented = counts["full"] + counts["partial"] + percentage = implemented / len(upstream) * 100 + return (f"**{implemented} of {len(upstream)} in-scope endpoints routed " + f"(~{percentage:.0f}%)** — {counts['full']} full, " + f"{counts['partial']} partial — plus 2 legacy aliases " + "(`torrents/pause`, `torrents/resume`) that upstream removed in WebAPI 2.11.") + + +def update_document(spec: dict) -> None: + marker = "" + document = DOC_PATH.read_text() + pattern = re.compile(rf"{re.escape(marker)}\n.*?(?=\n\n|\Z)", re.DOTALL) + replacement = f"{marker}\n{parity_summary(spec)}" + if not pattern.search(document): + raise RuntimeError("parity summary marker is missing from QBIT-API-PARITY.md") + DOC_PATH.write_text(pattern.sub(replacement, document, count=1)) + + if __name__ == "__main__": sys.exit(main())