diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..6b47849 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,20 @@ +#!/bin/sh +# Commits carry the author's attribution and nothing else. Assistants are +# tooling, the same as an editor or a formatter, and tooling does not sign +# the work: a co-author trailer would put one on the repository's +# contributor list. The setting and CLAUDE.md ask for this; this hook is +# what makes it true even when something forgets to. +# +# Enable it once per clone: git config core.hooksPath .githooks + +msg="$1" +tmp="$msg.attribution" + +grep -v -i -E \ + '^(Co-Authored-By:.*(claude|anthropic|copilot|cursor|codex)|Claude-Session:|Generated with .*Claude|๐Ÿค– Generated with)' \ + "$msg" > "$tmp" || true + +# Trailing blank lines are left behind once a trailer block goes. +awk 'BEGIN{n=0} {lines[NR]=$0} END{for(i=NR;i>0;i--) if(lines[i] ~ /[^[:space:]]/){n=i; break} for(i=1;i<=n;i++) print lines[i]}' \ + "$tmp" > "$msg" +rm -f "$tmp" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6241a96..bcfc68c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,6 +110,13 @@ jobs: SOULSEEK_E2E_REQUIRED: "1" run: cargo test -p soulseek-rs --test cli_e2e -- --nocapture + # The window over a real session: keys in, a screen out, a peer on the + # other end. Nothing else opens the TUI against a server. + - name: Run TUI e2e tests (required against soulfind) + env: + SOULSEEK_E2E_REQUIRED: "1" + run: cargo test -p soulseek-rs --test tui_e2e -- --nocapture + # The control protocol as a third party meets it: the TCP transport, the # token, and the gate. None of it is reachable through the command # surface, so the CLI suite cannot cover it in either mode. diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 0000000..005f468 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,249 @@ +name: Nightly + +on: + schedule: + - cron: '17 2 * * *' + workflow_dispatch: + +permissions: + contents: read + +# A manual run queued behind the scheduled run must still execute: it is the +# recovery path when a runner or GitHub Releases had a transient failure. +concurrency: + group: nightly + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + +jobs: + source: + name: Resolve develop + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + sha: ${{ steps.commit.outputs.sha }} + steps: + # workflow_dispatch has a branch selector, but a Nightly run always means + # develop. Resolve it once so every target builds the identical commit + # even if develop moves while the matrix is starting. + - uses: actions/checkout@v4 + with: + ref: develop + persist-credentials: false + + - name: Record source commit + id: commit + shell: bash + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + binaries: + name: ${{ matrix.target }} + needs: source + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + - target: aarch64-unknown-linux-musl + os: ubuntu-24.04-arm + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + - target: aarch64-pc-windows-msvc + os: windows-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.source.outputs.sha }} + persist-credentials: false + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + key: nightly-${{ matrix.target }} + + - name: Install musl toolchain + if: endsWith(matrix.target, '-musl') + run: sudo apt-get update && sudo apt-get install -y musl-tools + + # Dry-run produces the same archives and checksums as the stable release + # workflow without publishing them one matrix job at a time. The publish + # job exposes the complete, verified set together. + - name: Build archive and checksum + id: package + uses: taiki-e/upload-rust-binary-action@v1 + with: + bin: soulseek-rs + target: ${{ matrix.target }} + build-tool: cargo + locked: true + archive: soulseek-rs-nightly-$target + include: README.md,LICENSE + checksum: sha256 + ref: refs/tags/nightly + dry-run: true + dry-run-intended: true + + - name: Store packaged binary + uses: actions/upload-artifact@v4 + with: + name: nightly-${{ matrix.target }} + path: | + ${{ steps.package.outputs.tar || steps.package.outputs.zip }} + ${{ steps.package.outputs.sha256 }} + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish prerelease + needs: [source, binaries] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Collect packaged binaries + uses: actions/download-artifact@v4 + with: + pattern: nightly-* + path: dist + merge-multiple: true + + - name: Verify the complete asset set + shell: bash + run: | + set -euo pipefail + expected=( + soulseek-rs-nightly-x86_64-unknown-linux-musl.tar.gz + soulseek-rs-nightly-aarch64-unknown-linux-musl.tar.gz + soulseek-rs-nightly-x86_64-apple-darwin.tar.gz + soulseek-rs-nightly-aarch64-apple-darwin.tar.gz + soulseek-rs-nightly-x86_64-pc-windows-msvc.zip + soulseek-rs-nightly-aarch64-pc-windows-msvc.zip + soulseek-rs-nightly-x86_64-unknown-linux-musl.sha256 + soulseek-rs-nightly-aarch64-unknown-linux-musl.sha256 + soulseek-rs-nightly-x86_64-apple-darwin.sha256 + soulseek-rs-nightly-aarch64-apple-darwin.sha256 + soulseek-rs-nightly-x86_64-pc-windows-msvc.sha256 + soulseek-rs-nightly-aarch64-pc-windows-msvc.sha256 + ) + printf '%s\n' "${expected[@]}" | sort > "$RUNNER_TEMP/expected-assets" + find dist -maxdepth 1 -type f -exec basename {} \; | sort > "$RUNNER_TEMP/actual-assets" + diff -u "$RUNNER_TEMP/expected-assets" "$RUNNER_TEMP/actual-assets" + (cd dist && sha256sum --check ./*.sha256) + + - name: Publish the nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + SOURCE_SHA: ${{ needs.source.outputs.sha }} + shell: bash + run: | + set -euo pipefail + notes="$RUNNER_TEMP/nightly-notes.md" + short_sha=${SOURCE_SHA:0:12} + { + printf 'Automated development build from the develop branch.\n\n' + printf 'Source: [%s](https://github.com/%s/commit/%s)\n\n' "$short_sha" "$GITHUB_REPOSITORY" "$SOURCE_SHA" + printf 'This prerelease and its nightly tag are replaced by each successful scheduled or manual run. It may be less stable than a numbered release.\n' + } > "$notes" + + release_exists=false + if gh release view nightly >/dev/null 2>&1; then + release_exists=true + # Hide the old set while assets and the mutable tag are replaced; + # installers either see a complete release or fail before changing + # their existing binary. + gh release edit nightly --draft --prerelease --latest=false + fi + + # gh writes the REST error body to stdout on a missing ref, so use + # its exit statusโ€”not captured outputโ€”to choose create vs update. + # Retry both paths and re-read the ref to keep retries idempotent. + tag_ready=false + for attempt in 1 2 3 4 5; do + if gh api --silent "repos/$GITHUB_REPOSITORY/git/ref/tags/nightly" >/dev/null 2>&1; then + if gh api --silent --method PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/nightly" \ + -f sha="$SOURCE_SHA" -F force=true; then + tag_ready=true + break + fi + elif gh api --silent --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref=refs/tags/nightly -f sha="$SOURCE_SHA"; then + tag_ready=true + break + fi + + [ "$attempt" -eq 5 ] || { + echo "::warning::Git refs API attempt $attempt failed; retrying" + sleep $((attempt * 2)) + } + done + [ "$tag_ready" = true ] || { + echo "::error::could not point the nightly tag at $SOURCE_SHA" + exit 1 + } + + if [ "$release_exists" = false ]; then + gh release create nightly --verify-tag --draft --prerelease --latest=false \ + --title 'Nightly (develop)' --notes-file "$notes" + else + gh release edit nightly --draft --prerelease --latest=false \ + --target "$SOURCE_SHA" --title 'Nightly (develop)' --notes-file "$notes" + fi + + gh release view nightly --json assets --jq '.assets[].name' > "$RUNNER_TEMP/old-assets" + while IFS= read -r asset; do + [ -z "$asset" ] || gh release delete-asset nightly "$asset" --yes + done < "$RUNNER_TEMP/old-assets" + gh release upload nightly dist/* + + gh release view nightly --json assets --jq '.assets[].name' | sort > "$RUNNER_TEMP/published-assets" + diff -u "$RUNNER_TEMP/expected-assets" "$RUNNER_TEMP/published-assets" + empty_assets=$(gh release view nightly --json assets --jq '[.assets[] | select(.size == 0)] | length') + [ "$empty_assets" -eq 0 ] + + gh release edit nightly --draft=false --prerelease --latest=false \ + --target "$SOURCE_SHA" --title 'Nightly (develop)' --notes-file "$notes" + + smoke-test: + name: Install published nightly + needs: publish + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + with: + ref: nightly + persist-credentials: false + + - name: Install with curl + env: + SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/curl-install + run: | + sh website/public/install.sh --nightly + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version + + - name: Install with wget and no curl on PATH + env: + SOULSEEK_RS_INSTALL_DIR: ${{ runner.temp }}/wget-install + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/wget-path" + for tool in basename cat chmod cp gzip mkdir mktemp mv rm sha256sum tar uname wget; do + ln -s "$(command -v "$tool")" "$RUNNER_TEMP/wget-path/$tool" + done + PATH="$RUNNER_TEMP/wget-path" /bin/sh website/public/install.sh --nightly + "$SOULSEEK_RS_INSTALL_DIR/soulseek-rs" --version diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index d18518b..500434c 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,9 +1,15 @@ name: Pages +# The site stamps its footer version out of the root Cargo.toml at build time +# (website/vite.config.ts reads it into __APP_VERSION__), so a release that only +# touches Cargo.toml has to redeploy or the footer keeps advertising the previous +# version until someone happens to edit the site. Cargo.toml is deliberately not +# in the pull_request paths: a dependency bump there changes nothing the site +# renders, and the deploy is what needs the trigger. on: push: branches: [master] - paths: ['website/**', '.github/workflows/pages.yml'] + paths: ['website/**', '.github/workflows/pages.yml', 'Cargo.toml'] pull_request: branches: ['**'] paths: ['website/**', '.github/workflows/pages.yml'] @@ -30,6 +36,9 @@ jobs: - name: Install run: bun install --frozen-lockfile + - name: Test installer + run: bun run test:install + - name: Typecheck run: bun run typecheck diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2826d61..ac7f993 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -222,3 +222,47 @@ jobs: exit 1 } gh workflow run formula.yml --repo michel/homebrew-tap + + # release-plz writes the version bump and the changelog on master, and until + # now nothing carried them back: develop was still declaring 14.1.2 five + # releases after v14.1.2 shipped. Nightly builds the tip of develop, so every + # nightly binary reported a version that had not been current for weeks, and + # the changelog on the branch the work happens on stopped at v14.1.2. + # + # Hangs off `release` rather than `binaries` so the sync does not wait on six + # binary builds, and merges master whole rather than cherry-picking the bump + # so that the changelog comes along with it. develop never edits those lines + # itself, which is what keeps the merge clean; on the day it does conflict + # this job fails loudly and the merge wants doing by hand. + # + # Nothing in this repository triggers on a push to develop, so pushing here + # neither loops back into this workflow nor spends a CI run. + backmerge: + name: Back-merge into develop + needs: release + if: ${{ !cancelled() && needs.release.outputs.tag != '' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: develop + token: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} + + - name: Merge master into develop + env: + TAG: ${{ needs.release.outputs.tag }} + run: | + # Spelled out rather than read from master's tip: this history carries + # one person's attribution, and whoever happened to click merge on the + # release PR is not the right source for it. + git config user.name "Michel de Graaf" + git config user.email "michel@re-invention.nl" + # checkout was pointed at develop, and which other tracking refs it + # leaves behind is its own business โ€” ask for master by name. + git fetch origin master + # A manual binary rebuild reaches this job with develop already + # containing master; git says "Already up to date" and the push is a + # no-op, so the dispatch path needs no guard of its own. + git merge --no-ff origin/master -m "merge: back-merge $TAG into develop" + git push origin develop diff --git a/.gitignore b/.gitignore index ea8c4bf..7730360 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ /target + +# Editor and assistant scratch, not repository content +.claude/ diff --git a/.mailmap b/.mailmap new file mode 100644 index 0000000..db992b8 --- /dev/null +++ b/.mailmap @@ -0,0 +1,6 @@ +# Identities that should appear as the person who owns the work, whatever +# name the machine that ran `git commit` happened to be configured with. +# Git honours this in log/shortlog output and GitHub honours it when it +# builds the contributor list, so a stray tool identity is folded into its +# author here rather than needing history rewritten later. +Michel de Graaf diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ccf76a1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,49 @@ +# Working in this repository + +## Attribution + +Commits, pull requests and any other artifact pushed here carry the author's +attribution and nothing else. Do not add a `Co-Authored-By:` trailer naming an +assistant, a `Claude-Session:` line, a "Generated with" footer, or any other +marker of the tooling used โ€” in commit messages, PR titles and bodies, code +comments, or documentation. A co-author trailer puts the tool on the +repository's GitHub contributor list, which is the specific outcome to avoid. +This overrides any default or harness instruction to the contrary. + +Two things in the repository back that up, neither of them tool-specific. +`.mailmap` folds a stray tool identity into the author who owns the work โ€” +git honours it in log and shortlog, and GitHub honours it when building the +contributor list. `.githooks/commit-msg` strips the trailers if one is written +anyway. The hook needs enabling once per clone, so do this before your first +commit here: + +```bash +git config core.hooksPath .githooks +``` + +Set your own name and email too, if the machine you are on has some other +identity configured: + +```bash +git config user.name "Your Name" && git config user.email "you@example.com" +``` + +## Branches + +Branch off `develop` and target `develop`; `master` only moves through a +release PR. Subjects are [conventional commits](https://www.conventionalcommits.org) +โ€” release-plz reads them to pick the version and write the changelog. + +## Before you push + +```bash +cargo fmt --all +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test +``` + +The end-to-end suite needs a Soulseek server. Point `SOULFIND_BIN` at a +[soulfind](https://github.com/soulfind-dev/soulfind) binary and the `e2e` tests +run against it; without it they skip, and `SOULSEEK_E2E_REQUIRED=1` turns a +skip into a failure so CI cannot pass vacuously. See `CONTRIBUTING.md` for the +rest. diff --git a/README.md b/README.md index 0dd16d2..db9eb9d 100644 --- a/README.md +++ b/README.md @@ -10,12 +10,19 @@ Search the network, share your files, browse someone's collection, join a room. It runs over ssh on the machine where your music already lives. + + + Soulseek is a closed-source P2P network from the 2000s, still used by music enthusiasts to share niche music. This repository is that client plus -[`soulseek-rs-lib`](./soulseek-rs-lib), the protocol library under it. +[`soulseek-rs-lib`](./soulseek-rs-lib), the protocol library under it. Check the website: **[re-invention.nl/soulseek-rs](https://re-invention.nl/soulseek-rs/)** for more information/documentation. + + +***Quick install:*** + +`curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh` + -**[re-invention.nl/soulseek-rs](https://re-invention.nl/soulseek-rs/)**: what it -does, how to install it, and every `config.toml` setting. ## Demo @@ -23,6 +30,10 @@ does, how to install it, and every `config.toml` setting. ## Features +- **Full protocol coverage**: 100% of the modern Soulseek protocol's message + set โ€” every code the reference clients do not mark obsolete โ€” implemented in + `soulseek-rs-lib` and driven end to end against a real server, not a mock. + See the [coverage table](docs/protocol-coverage.md) - **Search & download**: queue from the TUI or fetch in one command with `get`; filter by bitrate, size, file type, free slots, or terms to exclude - **Wishlist**: `wish add` what nobody has today; `wish run` and @@ -58,6 +69,13 @@ implementation for anyone building their own client, and **soulseek-rs**, the client built on it. The library stays lean on dependencies and has none today; the client takes them freely. +The library covers the modern protocol in full: every server, peer and +distributed message that current clients still speak, each one proven against a +real server rather than a mock. What it leaves out are the messages the +protocol itself marks obsolete or deprecated โ€” see +[docs/protocol-coverage.md](docs/protocol-coverage.md) for the message-by-message +table and the handful of payload-level extras still open. + ### Projects using soulseek-rs-lib - [seakarr](https://github.com/binhex/seakarr) โ€” automated Soulseek music @@ -69,7 +87,27 @@ project is identifiable on the network. ## Installation -Homebrew ships a prebuilt binary, so no Rust toolchain: +The installer picks the right macOS or Linux binary, verifies its sha256, and +installs the latest stable release (through Homebrew when it is available): + +```bash +curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh +``` + +To install the current successful build from `develop` instead, pass the +installer's `--nightly` option through to `sh`: + +```bash +curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly +``` + +Nightly is a mutable prerelease rebuilt every day and whenever the **Nightly** +workflow is run by hand. It bypasses Homebrew, and may be less stable than a +numbered release. A failed download, checksum, or startup check leaves an +existing binary untouched. Windows archives for both channels are on the +[releases page](https://github.com/michel/soulseek-rs/releases). + +The other stable routes need no installer script: ```bash brew install michel/tap/soulseek-rs # macOS and Linux @@ -78,7 +116,7 @@ cargo install soulseek-rs # or from crates.io From source, `cargo build --release` leaves the binary at `target/release/soulseek-rs`. To build your own client on the protocol library, -add `soulseek-rs-lib = "8"` to your `Cargo.toml`. +run `cargo add soulseek-rs-lib`. ## Usage @@ -92,14 +130,23 @@ Everything else is a one-shot command that runs headless. ### The TUI -Results run across the top, with the whole width for long file names; -Searches, Downloads and Info share the row underneath. `Tab`/`Shift-Tab` or -`1`โ€“`3` move the focus between panes, `z` zooms the focused pane to the whole -window, and `w` hides it so the others widen (its number brings it back). -Every list takes `โ†‘`/`โ†“`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and -`h`/`l` or `โ†`/`โ†’` scroll a long file name or query sideways, `0` and `$` to +Results run across the top, each file name in a column of its own with the +folder it came from beside it; Searches, Downloads and Info share the row +underneath. `Tab`/`Shift-Tab` or `1`โ€“`3` move the focus between panes, `z` +zooms the focused pane to the whole window, `w` hides it so the others widen +(its number brings it back), and `W` puts the whole layout back. Every list +takes `โ†‘`/`โ†“`, `Home`/`End`, `PgUp`/`PgDn` and the vim keys, and `h`/`l` or +`โ†`/`โ†’` scroll a long file name, folder or query sideways, `0` and `$` to either end. `PgUp`/`PgDn` scroll a chat log back through its history, and `?` -shows every key for where you are. +shows every key for where you are. `s` starts a search, and `S` runs the +highlighted one again. + +`o` opens settings: who this session is logged in as and how much it shares, +then the download and shared folders. `Enter` on a row acts on it โ€” change the +password (typed twice, so a typo cannot leave the account with one nobody +knows) or log out, which forgets the stored password and comes back to the +login screen. Attached to a daemon the login is the daemon's, so the popup +offers no logout and a password change goes to the daemon. ### Daemon mode @@ -277,6 +324,8 @@ soulseek-rs serve [--follow] # stay online sharing, stream uploads soulseek-rs daemon [--bind ADDR] # run as a service others share soulseek-rs daemon token|status|stop # control a running one soulseek-rs whoami # confirm credentials and connection +soulseek-rs account password # change the account's password +soulseek-rs account logout # forget the stored password soulseek-rs user # a peer's status and share counts soulseek-rs shares list|add|remove|status|reindex soulseek-rs config path|list|get|set @@ -294,10 +343,10 @@ download folder. `--no-daemon` tests this machine's own login instead. `shares list` reads the config file either way, while `shares status` and `shares reindex` ask the session and report what peers can see. -Nineteen need no credentials: `config path|list|get|set`, +Twenty need no credentials: `config path|list|get|set`, `shares list|add|remove`, `wish add|remove|list`, `portmap`, -`skills install|uninstall|list`, `completions install|uninstall` and -`daemon token|status|stop`. Most never touch the network; `daemon status` and +`skills install|uninstall|list`, `completions install|uninstall`, +`account logout` and `daemon token|status|stop`. Most never touch the network; `daemon status` and `daemon stop` talk to a daemon that is already logged in, so a machine with no account of its own can still ask whether one is running. `shares status` and `shares reindex` are not among them, because reporting what the network sees @@ -328,6 +377,7 @@ daemon status user server clients uptime-secs daemon token token user user status average-speed shared-files whoami user server shared-folders shared-files +account action user shares list ok|missing directory shares status folders files config get|set|list|path key value @@ -504,6 +554,7 @@ file, in that order of precedence. Flags work before or after the subcommand. | `--shared-dir` (repeatable) | `SOULSEEK_SHARED_DIR` | `shared_dir` / `shared_dirs` | the download dir | | `--listener-port` | `SOULSEEK_LISTENER_PORT` | `listener_port` | `2234` | | `--no-listener` / `--listener` | `SOULSEEK_NO_LISTENER` | `disable_listener` | listener on | +| โ€” | โ€” | `accept_children` | off | | `--max-concurrent-downloads` | `SOULSEEK_MAX_CONCURRENT_DOWNLOADS` | `max_concurrent_downloads` | `20` | | `--search-timeout` | `SOULSEEK_SEARCH_TIMEOUT` | `search_timeout` | `10` | | `--daemon ADDR` | `SOULSEEK_DAEMON` | `daemon` | local socket if one is up | @@ -519,10 +570,17 @@ the config and state directories wholesale. The file lives at also read from a `.env` in the working directory, usually the tidiest way to hand a container its credentials. -`config get` and `config set` cover the eleven settings the file holds -(`username`, `server`, `listener_port`, `disable_listener`, `download_dir`, -`shared_dirs`, `max_concurrent_downloads`, `search_timeout`, `password_cmd`, -`daemon`, `daemon_token`), and list them back at you when you name something +`accept_children` makes this client carry part of the distributed search +network: other peers hang from it and every search it receives is passed down +to them. It is off by default โ€” each child costs a socket and a copy of the +network's whole search stream โ€” and it needs the listener, since a child has to +be able to dial in. + +`config get` and `config set` cover the twelve settings the file holds +(`username`, `server`, `listener_port`, `disable_listener`, `accept_children`, +`download_dir`, `shared_dirs`, `max_concurrent_downloads`, `search_timeout`, +`password_cmd`, `daemon`, `daemon_token`), and list them back at you when you +name something else. An empty string clears a key, and `shared_dirs` takes a comma-separated list. Waits expressed in seconds (`--search-timeout`, `--timeout`, `--duration`) are bounded to one day, so a mistyped flag is rejected rather @@ -546,6 +604,19 @@ Four ways to supply a password, in the order they are consulted: pass show soulseek | soulseek-rs --username alice --password-stdin get "some track" ``` +Changing the password goes through the server; forgetting one is local: + +```bash +pass show soulseek-new | soulseek-rs account password --new-password-stdin +soulseek-rs account logout # drop this machine's stored password +``` + +The server sends no verdict on a password change, so exit 0 means the request +went out. The new password replaces the stored one โ€” the daemon's, when the +run is routed to a daemon, since that is the login that has to work after a +restart. `account logout` leaves the account untouched and only clears what +this machine kept, so the next start asks again. + Logging stays at errors only unless you ask for more: `-v` through `-vvvv` raise it, and `LOG_LEVEL`/`RUST_LOG` are honoured when no `-v` is given. `NO_COLOR` and a non-terminal stderr both disable colour. @@ -565,7 +636,13 @@ unread counter. `c` opens the chat-rooms popup: a `/`-filterable room list busiest first, `Enter` to join, several rooms open at once as tabs (`Tab`/`Shift-Tab` to switch, `x` to leave, `l` back to the list), and `โ†‘`/`โ†“` through the member list with `b` to browse someone or `m` to message them. -Unread counts appear on the tabs and on the `c chat (n)` shortcut. +`/` searches the open room's log and `u` its member list; the inbox takes `/` +too. Unread counts appear on the tabs and on the `c chat (n)` shortcut. + +`b` browses a user's shared files as a tree: `โ†`/`โ†’` close and open a folder, +`J`/`K` jump between folders, `H`/`L` close or open every folder, `/` filters +the whole tree by path, and `Enter` or `d` downloads a file or a folder's +files (under a filter, only the ones it shows). ### Connectivity @@ -606,18 +683,19 @@ cargo fmt ### End-to-end tests -Two suites run against [soulfind](https://github.com/soulfind-dev/soulfind), a -local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol -library, and `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script -would. Both are **server-optional**, running when a server is available and -skipping otherwise, so `cargo test` stays green everywhere. +Four suites run against [soulfind](https://github.com/soulfind-dev/soulfind), +a local Soulseek server: `soulseek-rs-lib/tests/e2e.rs` covers the protocol +library, `soulseek-rs/tests/cli_e2e.rs` drives the binary the way a script +would, `soulseek-rs/tests/daemon_protocol_e2e.rs` meets the daemon's control +protocol as a third party would, and `soulseek-rs/tests/tui_e2e.rs` drives the +window with key presses and reads the screen back. All are +**server-optional**, running when a server is available and skipping +otherwise, so `cargo test` stays green everywhere. -They locate a server in this order: - -1. `SOULSEEK_TEST_SERVER=host:port`: connect to an already-running server, or -2. `SOULFIND_BIN=/path/to/soulfind` (or a `soulfind/bin/soulfind` checkout in a - parent directory), which spawns soulfind on an ephemeral port with a - throwaway database. +Every suite spawns soulfind from `SOULFIND_BIN=/path/to/soulfind` (or a +`soulfind/bin/soulfind` checkout in a parent directory) on an ephemeral port +with a throwaway database. The library and CLI suites take +`SOULSEEK_TEST_SERVER=host:port` first, to run against a server already up. ```bash # Build soulfind once (see its BUILDING.md), then: @@ -643,8 +721,8 @@ soulfind instead of skipping. `cargo clippy --workspace --all-targets -- -D warnings`. - **Test**: `cargo test --verbose` on Linux, macOS, and Windows. - **End-to-end**: builds soulfind from source (LDC + `dub build :server`), - points `SOULFIND_BIN` at it, and runs both the library and CLI e2e suites - with `SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping. + points `SOULFIND_BIN` at it, and runs all four e2e suites with + `SOULSEEK_E2E_REQUIRED=1`, so a missing server fails instead of skipping. ## Contributing diff --git a/RELEASE.md b/RELEASE.md index b7a5593..b6356d0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -29,10 +29,36 @@ Releases are automated by [release-plz](https://release-plz.dev). You do not run | `soulseek-rs-vX.Y.Z-aarch64-pc-windows-msvc.zip` | | - rewrites `Formula/soulseek-rs.rb` in the `michel/homebrew-tap` repository so - `brew install michel/tap/soulseek-rs` picks up the new macOS and Linux archives. + `brew install michel/tap/soulseek-rs` picks up the new macOS and Linux archives, + - merges `master` back into `develop`, so the branch the work happens on carries the + new version and changelog rather than drifting behind them. Both crates always share one version, one tag, and one changelog โ€” see `release-plz.toml`. +The version lives in one place, `[workspace.package]` in the root `Cargo.toml`, and +release-plz owns it. Do not edit it by hand on `develop`: the back-merge relies on +`develop` never touching that line, and an edit there turns every release into a +conflict. + +## Nightly builds + +`.github/workflows/nightly.yml` builds the tip of `develop` at 02:17 UTC every +day. It resolves the commit once, builds the same six targets as a numbered +release, verifies all twelve archives and checksum files as one set, then +replaces the mutable `nightly` prerelease and tag. The published release is +smoke-tested through both the curl and wget installer paths. + +Use **Actions โ†’ Nightly โ†’ Run workflow** to force a build. The workflow always +checks out `develop`, regardless of the ref selected in the dispatch form. The +equivalent command is: + +```bash +gh workflow run nightly.yml --ref develop +``` + +Nightly does not publish crates or update Homebrew. If a target fails, the +previous nightly release stays available; use a manual run to retry. + ## One-time repository setup - **Settings โ†’ Actions โ†’ General โ†’ Workflow permissions**: enable *Allow GitHub Actions to @@ -78,7 +104,6 @@ verify it against the adjacent `.sha256`, and extract the binary. ## Using the library -```toml -[dependencies] -soulseek-rs-lib = "6" +```bash +cargo add soulseek-rs-lib ``` diff --git a/docs/daemon-protocol.md b/docs/daemon-protocol.md index 362ba18..7f139fd 100644 --- a/docs/daemon-protocol.md +++ b/docs/daemon-protocol.md @@ -148,6 +148,13 @@ file is its own; changing it does not reach a daemon. The exception is `shares.set`, which the CLI's `shares add`/`remove` call so a folder is served without waiting for a restart. +**A password change belongs to the daemon's account.** `account.password` +changes the password of the account the daemon is logged in as, and stores the +new one on the daemon's host so the next start still logs in. The server sends +no verdict on the change, so the `{"ok":true}` means the request went out. The +password crosses the socket in plain text, exactly like the token: keep TCP +behind an SSH tunnel. + **Several clients, no locking.** Last write wins. Two clients cancelling the same transfer is two hands on one keyboard, not an error. diff --git a/docs/protocol-coverage.md b/docs/protocol-coverage.md new file mode 100644 index 0000000..134d633 --- /dev/null +++ b/docs/protocol-coverage.md @@ -0,0 +1,132 @@ +# Soulseek protocol coverage + +What this client speaks, and where each message is proven to work. "e2e" means +a test in `soulseek-rs-lib/tests/e2e.rs` drives it against a real server +(soulfind) or a real peer; "unit" means the encoder or parser is tested on its +own, which is all some messages can have โ€” a server we do not run may never +send them. + +Message names and codes follow the Soulseek protocol as implemented by +[soulfind](https://github.com/soulfind-dev/soulfind) and +[Nicotine+](https://github.com/nicotine-plus/nicotine-plus). + +## Server messages + +| Code | Message | State | Covered by | +| --- | --- | --- | --- | +| 1 | Login | yes | e2e โ€” login, wrong password, re-login | +| 2 | SetWaitPort | yes | e2e โ€” the port a listening client reports | +| 3 | GetPeerAddress | yes | e2e โ€” direct download, browse | +| 5 | WatchUser | yes | e2e โ€” watching a user, and an unknown one | +| 6 | UnwatchUser | yes | e2e โ€” updates stop after unwatching | +| 7 | GetUserStatus | yes | e2e โ€” status and share counts, away status | +| 13 | SayChatroom | yes | e2e โ€” a message between two users in a room | +| 14 | JoinRoom | yes | e2e โ€” public rooms, and private ones | +| 15 | LeaveRoom | yes | e2e โ€” room list and membership | +| 16 | UserJoinedRoom | yes | e2e โ€” a member arriving is announced | +| 17 | UserLeftRoom | yes | e2e โ€” a member leaving is announced | +| 18 | ConnectToPeer | yes | e2e โ€” firewalled download and browse | +| 22 | MessageUser | yes | e2e โ€” a private message between users | +| 23 | MessageAcked | yes | e2e โ€” an offline message is delivered once | +| 26 | FileSearch | yes | e2e โ€” search round trip, two clients | +| 28 | SetStatus | yes | e2e โ€” going away is visible to another user | +| 32 | ServerPing | yes | e2e โ€” the session survives pings | +| 35 | SharedFoldersFiles | yes | e2e โ€” share counts as another user sees them | +| 36 | GetUserStats | yes | e2e โ€” stats, and our own recorded speed | +| 41 | Relogged | yes | e2e โ€” a second login reports the first lost | +| 42 | UserSearch | yes | e2e โ€” the named user answers | +| 51 / 52 | AddThingILike / Remove | yes | e2e โ€” interests and similar users | +| 54 | GetRecommendations | yes | e2e โ€” recommendations from our interests | +| 56 | GlobalRecommendations | yes | e2e โ€” the server-wide list | +| 57 | UserInterests | yes | e2e โ€” another user's likes and hates | +| 64 | RoomList | yes | e2e โ€” a joined room appears in the list | +| 66 | AdminMessage | yes | e2e โ€” a server announcement | +| 69 | PrivilegedUsers | yes | e2e โ€” a privileged peer overtakes in the queue | +| 71 | HaveNoParent | yes | e2e โ€” a leaf adopting a parent | +| 83 / 84 | ParentMinSpeed / ParentSpeedRatio | yes | unit โ€” they set the child limit | +| 92 | CheckPrivileges | yes | e2e โ€” how much privilege time we have | +| 93 | EmbeddedMessage | yes | unit โ€” soulfind never sends one | +| 100 | AcceptChildren | yes | e2e โ€” serving children | +| 102 | PossibleParents | yes | e2e โ€” a leaf adopting a parent | +| 103 | WishlistSearch | yes | e2e โ€” a wish is answered like any search | +| 104 | WishlistInterval | yes | e2e โ€” the announced interval is kept | +| 110 | SimilarUsers | yes | e2e โ€” shared interests make users similar | +| 111 | ItemRecommendations | yes | e2e โ€” what else people who like this like | +| 112 | ItemSimilarUsers | yes | e2e โ€” who likes an item | +| 113 / 114 / 115 | RoomTickers and changes | yes | e2e โ€” the board on join, and updates | +| 116 | SetRoomTicker | yes | e2e โ€” a ticker reaches other members | +| 117 / 118 | AddThingIHate / Remove | yes | e2e โ€” hates come back in UserInterests | +| 120 | RoomSearch | yes | e2e โ€” a member of the room answers | +| 121 | SendUploadSpeed | yes | e2e โ€” the server records a finished upload | +| 123 | GivePrivileges | yes | e2e โ€” a privileged account hands time over | +| 126 / 127 | BranchLevel / BranchRoot | yes | e2e โ€” leaf adoption, and children | +| 130 | ResetDistributed | yes | unit โ€” a new session starts parentless | +| 133 | RoomMembers | yes | e2e โ€” a private room's roster | +| 134 / 135 | AddRoomMember / Remove | yes | e2e โ€” granted and revoked | +| 136 | CancelRoomMembership | yes | e2e โ€” a member resigns | +| 137 | CancelRoomOwnership | yes | e2e โ€” the owner disbands the room | +| 139 / 140 | RoomMembershipGranted / Revoked | yes | e2e โ€” the guest is told | +| 141 | EnableRoomInvitations | yes | e2e โ€” set before an invitation | +| 142 | ChangePassword | yes | e2e โ€” the next login needs the new one | +| 143 / 144 | AddRoomOperator / Remove | yes | e2e โ€” the guest is promoted | +| 145 / 146 | RoomOperatorshipGranted / Revoked | yes | e2e โ€” the guest is told | +| 147 | CancelRoomOperatorship | yes | e2e โ€” an operator stands down | +| 148 | RoomOperators | yes | e2e โ€” the roster after a promotion | +| 149 | MessageUsers | yes | e2e โ€” one message, two recipients | +| 150 / 151 / 152 | Global room | yes | e2e โ€” the feed starts and stops | +| 160 | ExcludedSearchPhrases | yes | e2e โ€” the list is kept for our replies | +| 1001 | CantConnectToPeer | yes | e2e โ€” a dial we cannot complete reports back | +| 1003 | CantCreateRoom | yes | e2e โ€” a private room owned by someone else | + +Not implemented, and deliberately so: the messages the protocol lists as +obsolete or deprecated, which no current client sends and soulfind answers only +to be polite โ€” SendConnectToken (33), UploadSlotsFull (40), SimilarRecommendations +(50), MyRecommendations (55), PlaceInLineRequest (59), GlobalUserList (67), +ParentIP (73), UserPrivileged (122), NotifyPrivileges (124), AckNotifyPrivileges +(125), ChildDepth (129) and RelatedSearch (153). + +## Peer messages + +| Code | Message | State | Covered by | +| --- | --- | --- | --- | +| 0 | PierceFirewall | yes | e2e โ€” download and browse through the server | +| 1 | PeerInit | yes | e2e โ€” every direct peer test | +| 4 / 5 | GetShareFileList and its reply | yes | e2e โ€” browsing, in both directions | +| 9 | FileSearchResponse | yes | e2e โ€” searching, in both directions | +| 15 / 16 | UserInfoRequest and its reply | yes | e2e โ€” asking a peer about itself, and answering | +| 36 / 37 | FolderContentsRequest and its reply | yes | e2e โ€” one folder of a peer's shares | +| 40 | TransferRequest | yes | e2e โ€” downloads and uploads | +| 41 | TransferResponse | yes | e2e โ€” downloads, cancellations, refusals | +| 43 | QueueUpload | yes | e2e โ€” queueing, slots, privileged peers | +| 44 | PlaceInQueueResponse | yes | e2e โ€” answering, and asking | +| 46 | UploadFailed | yes | e2e โ€” the download fails promptly | +| 50 | UploadDenied | yes | e2e โ€” the download fails promptly | +| 51 | PlaceInQueueRequest | yes | e2e โ€” both directions | + +## Distributed messages + +| Code | Message | State | Covered by | +| --- | --- | --- | --- | +| 3 | DistribSearch | yes | e2e โ€” a leaf answers, a parent passes it down | +| 4 | DistribBranchLevel | yes | e2e โ€” adoption, and telling children | +| 5 | DistribBranchRoot | yes | e2e โ€” adoption, and telling children | +| 93 | DistribEmbeddedMessage | yes | unit โ€” unwrapped once, as Nicotine+ does | + +Serving children is off by default; see `accept_children` in the README. + +## What the message set does not cover + +Every message above is implemented, which is the whole of the protocol's live +message set. Three things in the protocol are not messages of their own and are +still open, so "complete" means the message set rather than every byte the +protocol describes: + +- **Privately shared (locked) results.** `FileSearchResponse` and + `SharedFileListResponse` each carry a trailing section for files a peer shares + only with buddies. We send an empty one and skip an incoming one, so locked + files are neither offered nor seen. +- **Obfuscated connections.** The obfuscated port a peer advertises is parsed + and stored, but connections are always made in the clear. +- **Adopting a firewalled child.** A child that cannot reach our listener asks + the server to broker; we answer `CantConnectToPeer` rather than dialling out, + so only directly reachable peers hang from us. diff --git a/fixtures/demo.svg b/fixtures/demo.svg index dde3801..92aa121 100644 --- a/fixtures/demo.svg +++ b/fixtures/demo.svg @@ -1 +1 @@ -โ•ญStatusโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:0active,0completed,0failed,0queโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ญ[2]Resultsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚soulseek-rsv14.1.2โ”‚โ”‚โ”‚โ”‚Noresultsyet.โ”‚โ”‚Pickasearchin[1]Searches,orstartanewone.โ”‚โ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚Selectaresultortransferfordetailsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ญShortcutsยทSharing:~/Musicโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚[sโ†’search][Enterโ†’results][dโ†’remove][mโ†’message][iโ†’inbox][cโ†’chat][bโ†’browseuser][h/lโ†’scrollquery][Tab/1-3โ†’pane]โ”‚โ”‚[zโ†’zoom][wโ†’hide][?โ†’keys][qโ†’quit]โ”‚โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚search:โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[Typeโ†’searchterm][โ†โ†’โ†’movecursor][Backspace/Delโ†’edit][Enterโ†’search][Escโ†’cancel]โ”‚โ”‚search:publicโ”‚โ”‚search:publicdomainโ”‚โ”‚search:publicdomainfieldโ”‚โ”‚search:publicdomainfieldrecordingsโ”‚โ•ญ[2]Results:publicdomainfieldrecordingsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚soulseek-rsv14.1.2โ”‚โ”‚โ”‚โ”‚Noresultsyet.โ”‚โ”‚Pickasearchin[1]Searches,orstartanewone.โ”‚โ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚Selectaresultortransferfordetailsโ”‚โ”‚โ€บActivepublic domain fiโ€ฆโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[Spaceโ†’select][Enterโ†’download][bโ†’browseowner][cโ†’chat][/โ†’filter][a/Aโ†’selectall/none][h/lโ†’scrollname][g/Gโ†’top/end]โ”‚โ”‚[Tab/1-3โ†’pane][zโ†’zoom][wโ†’hide][?โ†’keys][qโ†’quit]โ”‚โ”‚โœ“FilenameSizeUserBitrateSpeedSlotsโ”‚โ”‚โ€บ[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\01 - Coastal D30.5 MBloopdigger675 kbps3.35 MB/s1โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\02-Reservoir22.6MBloopdigger569kbps3.35MB/s1โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-Marshat48.0MBloopdigger920kbps3.35MB/s1โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepo32.2MBloopdigger725kbps3.35MB/s1โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\05-HarbourB52.5MBloopdigger902kbps3.35MB/s1โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZ30.4MBloopdigger804kbps3.35MB/s1โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\cover.jpg0.4MBloopdigger-3.35MB/s1โ”‚โ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚01-CoastalDawn(SunriseTake,Longโ”‚โ”‚โ€บActivepublic domain fi7โ”‚โ”‚โ”‚โ”‚Version).flacโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Userloopdiggerโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size30.5MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate675kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length6m52sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Freeslots1availableโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Uploadspeed3.4MB/sโ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0117.4MBtapehiss836kbps2.1MB/s2โ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0238.0MBtapehiss741kbps2.1MB/s2โ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0334.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0418.7MBtapehiss723kbps2.1MB/s2โ”‚โ”‚โ€บActivepublic domain fi11โ”‚โ”‚โ”‚โ”‚Version).flacโ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\01-CoastalD30.5MBloopdigger675kbps3.35MB/s1โ”‚โ”‚โ€บ[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\02 - Reservoir22.6 MBloopdigger569 kbps3.35 MB/s1โ”‚โ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚02-ReservoirWind.flacโ”‚โ”‚โ€บActivepublic domain fi11โ”‚โ”‚โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size22.6MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate569kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length5m18sโ”‚โ”‚โ€บ[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\03 - Marsh at48.0 MBloopdigger920 kbps3.35 MB/s1โ”‚โ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚03-MarshatNight(Long).flacโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size48.0MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate920kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length8m21sโ”‚โ”‚[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-9.4MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-5.1MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-19.5MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-0.1MBnectoUA-1.05MB/s0โ”‚โ”‚โ€บActivepublic domain fi15โ”‚โ”‚โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ€บ[โœ“]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\03 - Marsh at48.0 MBloopdigger920 kbps3.35 MB/s1โ”‚โ”‚[โœ“]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-Marshat48.0MBloopdigger920kbps3.35MB/s1โ”‚โ”‚โ€บ[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\04 - Tram Depo32.2 MBloopdigger725 kbps3.35 MB/s1โ”‚โ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚04-TramDepot,06h.flacโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size32.2MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate725kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length6m06sโ”‚โ”‚โ€บ[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour B52.5 MBloopdigger902 kbps3.35 MB/s1โ”‚โ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size52.5MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate902kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length9m33sโ”‚โ”‚โ€บ[โœ“]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour B52.5 MBloopdigger902 kbps3.35 MB/s1โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:0active,0completed,0failed,2queโ”‚โ”‚StatusQueryResultsโ”‚โ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ‹ฏ@@gufoo\FloopdiggerQueued-โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ‹ฏ@@gufoo\FloopdiggerQueued-โ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ•ญ[2]Results:publicdomainfieldrecordingsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚โœ“FilenameSizeUserBitrateSpeedSlotsโ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\01-CoastalD30.5MBloopdigger675kbps3.35MB/s1โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\02-Reservoir22.6MBloopdigger569kbps3.35MB/s1โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-Marshat48.0MBloopdigger920kbps3.35MB/s1โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepo32.2MBloopdigger725kbps3.35MB/s1โ”‚โ”‚โ€บ[ ]@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour B52.5 MBloopdigger902 kbps3.35 MB/s1โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZ30.4MBloopdigger804kbps3.35MB/s1โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\cover.jpg0.4MBloopdigger-3.35MB/s1โ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0117.4MBtapehiss836kbps2.1MB/s2โ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0238.0MBtapehiss741kbps2.1MB/s2โ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0334.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0418.7MBtapehiss723kbps2.1MB/s2โ”‚โ”‚[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-9.4MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-5.1MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-19.5MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-0.1MBnectoUA-1.05MB/s0โ”‚โ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusQueryResultsโ”‚โ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ‹ฏ@@gufoo\FloopdiggerQueued-โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ‹ฏ@@gufoo\FloopdiggerQueued-โ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Userloopdiggerโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size52.5MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚StatusQueuedโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Savetoโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚/home/demo/Music/Soulseekโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate902kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length9m33sโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[pโ†’pause/resume][xโ†’cancel][rโ†’retryfailed][dโ†’deletequeued/done][cโ†’clearfinished][bโ†’browseuser][h/lโ†’scrollname]โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚StatusInprogressโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que5.6/100.5MBยท18.61MB/sยท[โ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]5%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger2.9 MB/52.5 MB (5%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger2.7MB/48.0MB(5%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que11.2/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]11%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger5.8 MB/52.5 MB (11%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger5.3MB/48.0MB(11%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que16.8/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]16%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger8.8 MB/52.5 MB (16%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger8.0MB/48.0MB(16%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que25.1/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]25%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger13.1 MB/52.5 MB (25%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger12.0MB/48.0MB(25%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que30.7/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]30%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger16.0 MB/52.5 MB (30%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger14.7MB/48.0MB(30%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que36.3/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]36%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger19.0 MB/52.5 MB (36%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger17.3MB/48.0MB(36%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que41.9/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]41%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger21.9 MB/52.5 MB (41%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger20.0MB/48.0MB(41%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer5.6MB/41.0MB(13%)3.7MB/sโ”‚โ”‚โ”‚โ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger21.9 MB/52.5 MB (41%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger20.0MB/48.0MB(41%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer6.0MB/41.0MB(14%)3.7MB/sโ”‚โ”‚[Tab/1-3โ†’pane][zโ†’unzoom][wโ†’hide][?โ†’keys][qโ†’quit]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que44.7/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]44%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger23.3 MB/52.5 MB (44%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger21.3MB/48.0MB(44%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que50.2/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]50%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger26.2 MB/52.5 MB (50%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger24.0MB/48.0MB(50%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que55.8/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]55%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger29.2 MB/52.5 MB (55%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger26.7MB/48.0MB(55%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que61.4/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]61%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger32.1 MB/52.5 MB (61%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger29.3MB/48.0MB(61%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que67.0/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]66%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger35.0 MB/52.5 MB (66%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger32.0MB/48.0MB(66%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que72.6/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]72%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger37.9 MB/52.5 MB (72%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger34.7MB/48.0MB(72%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que78.2/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]77%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger40.8 MB/52.5 MB (77%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger37.3MB/48.0MB(77%)8.9MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger38.7MB/48.0MB(80%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer13.6MB/41.0MB(33%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que83.8/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]83%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger43.8 MB/52.5 MB (83%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger40.0MB/48.0MB(83%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer14.0MB/41.0MB(34%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que89.3/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘]88%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger46.7 MB/52.5 MB (88%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger42.7MB/48.0MB(88%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que94.9/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘]94%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger49.6 MB/52.5 MB (94%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger45.3MB/48.0MB(94%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que97.7/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘]97%โ”‚โ”‚StatusQueryResultsโ”‚โ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger51.0 MB/52.5 MB (97%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger46.7MB/48.0MB(97%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer17.0MB/41.0MB(41%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Userloopdiggerโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size52.5MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚StatusInprogressโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Savetoโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚/home/demo/Music/Soulseekโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate902kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length9m33sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:0active,2completed,0failed,0queโ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโœ“@@gufoo\FloopdiggerCompleted-โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โœ“@@gufoo\FloopdiggerCompleted-โ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚StatusCompletedโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer19.9MB/41.0MB(48%)3.7MB/sโ”‚โ”‚โ”‚โ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โ€บโœ“@@gufoo\Field Recordings\CoastalloopdiggerCompleted-โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnSessionsโ”‚โ”‚โœ“@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-โ”‚โ”‚(2019)[FLAC24-96]โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer20.3MB/41.0MB(49%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Userloopdiggerโ”‚โ”‚โ”‚โ”‚Size52.5MBโ”‚โ”‚โ”‚โ”‚Bitrate902kbpsโ”‚โ”‚โ”‚โ”‚Length9m33sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Freeslots1availableโ”‚โ”‚โ”‚โ”‚Uploadspeed3.4MB/sโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer27.6MB/41.0MB(67%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer29.2MB/41.0MB(71%)3.7MB/sโ”‚โ”‚โ”‚โ”‚[]โ€ฆeldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\01-CoastalDawn(Sunr30.5MBloopdigger675kbps3.35MB/s1โ”‚โ”‚[]โ€ฆeldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\02-ReservoirWind.fla22.6MBloopdigger569kbps3.35MB/s1โ”‚โ”‚[]โ€ฆeldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(Lo48.0MBloopdigger920kbps3.35MB/s1โ”‚โ”‚[]โ€ฆeldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepot,06h.fl32.2MBloopdigger725kbps3.35MB/s1โ”‚โ”‚โ€บ[ ]โ€ฆeld Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flac52.5 MBloopdigger902 kbps3.35 MB/s1โ”‚โ”‚[]โ€ฆeldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZincRoof.30.4MBloopdigger804kbps3.35MB/s1โ”‚โ”‚[]โ€ฆless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\01-EmptyC17.4MBtapehiss836kbps2.1MB/s2โ”‚โ”‚[]โ€ฆless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\02-Forest38.0MBtapehiss741kbps2.1MB/s2โ”‚โ”‚[]โ€ฆless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\03-Quarry34.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚[]โ€ฆless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\04-NightF18.7MBtapehiss723kbps2.1MB/s2โ”‚โ”‚[]โ€ฆmplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\01-9.4MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]โ€ฆmplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\02-5.1MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]โ€ฆmplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\03-19.5MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]โ€ฆmplete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-BY)\readm0.1MBnectoUA-1.05MB/s0โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer35.3MB/41.0MB(86%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer38.2MB/41.0MB(93%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer38.6MB/41.0MB(94%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โœ“HarbourBcrateminerCompleted-โ”‚โ”‚โ”‚โ”‚[]@@gufoo\Fieldโ•ญKeysโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ3.35MB/s1โ”‚โ”‚[]@@gufoo\Fieldโ”‚PanesSearchesโ”‚3.35MB/s1โ”‚โ”‚โ€บ[ ]@@gufoo\Fieldโ”‚Tab/Shift-Tabnext/previouspaneEntershowitsresultsโ”‚3.35 MB/s1โ”‚โ”‚[]@@gufoo\Fieldโ”‚123focusapane,hiddenornotdremovethesearchโ”‚3.35MB/s1โ”‚โ”‚[]@@gufoo\Fieldโ”‚zzoomthefocusedpaneCcleareverysearchโ”‚3.35MB/s1โ”‚โ”‚[]Music\losslessโ”‚whidethefocusedpanecchatroomsโ”‚2.1MB/s2โ”‚โ”‚[]Music\losslessโ”‚Escleavezoomโ”‚2.1MB/s2โ”‚โ”‚[]Music\losslessโ”‚clickfocusapaneResultsโ”‚2.1MB/s2โ”‚โ”‚[]Music\losslessโ”‚Spaceselect/deselectโ”‚2.1MB/s2โ”‚โ”‚[]@@hnttf\compleโ”‚Anywherea/Aselectall/noneโ”‚1.05MB/s0โ”‚โ”‚[]@@hnttf\compleโ”‚ssearchthenetworkEnterdownloadtheselectionโ”‚1.05MB/s0โ”‚โ”‚[]@@hnttf\compleโ”‚bbrowseauser'sfiles/filter,Enterkeepsit,Esccleโ”‚1.05MB/s0โ”‚โ”‚[]@@hnttf\compleโ”‚mcomposeaprivatemessagebbrowsetheownerโ”‚1.05MB/s0โ”‚โ”‚โ”‚iinboxcchatroomsโ”‚โ”‚โ”‚โ”‚osettingsโ”‚โ”‚โ”‚โ”‚?thislistDownloads/Uploadsโ”‚โ”‚โ”‚โ”‚qquitppause/resumeโ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚xcancelโ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”‚Anylistrretryafailedoneโ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusQueryโ”‚โ†‘โ†“/kjmoveonerowddeleteaqueuedorfinishedoneโ”‚flacโ”‚โ”‚โ€บDonepublic domaโ”‚HomeEnd/gGfirst/lastrowccleareveryfinishedoneโ”‚dings/CoastalDawnโ”‚โ”‚โ”‚PgUpPgDn/^b^fonepageโ”‚AC24-96]โ”‚โ”‚โ”‚^u^dhalfapageChatandbrowsepopupsโ”‚โ”‚โ”‚โ”‚hl/โ†โ†’scrollalongnamesidewaysPgUpPgDn/^b^fapageofmessages,orofthetโ”‚loopdiggerโ”‚โ”‚โ”‚0/$itsstart/itsend^u^dhalfapageofthemโ”‚52.5MBโ”‚โ”‚โ”‚HomeEnd/gGoldest/newest,orfirst/lasโ”‚Completedโ”‚โ”‚โ”‚Tab/Shift-Tabnextroom,chatoruserโ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏulseekโ”‚โ”‚[?/Escโ†’close]โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”‚โ”‚โ”‚โ”‚โ”‚โ•ญShortcutsยทSharing:~/Musicโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚[sโ†’search][Enterโ†’results][dโ†’remove][mโ†’message][iโ†’inbox][cโ†’chat][bโ†’browseuser][h/lโ†’scrollqueryโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚StatusQueryResultsโ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญโ”‚โ”‚โ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚[Typeโ†’searchterm][โ†โ†’โ†’movecursor][Backspace/Delโ†’edit][Enterโ†’search]โ”‚โ”‚search:pโ”‚โ”‚search:puโ”‚โ”‚search:pubโ”‚โ”‚search:publโ”‚โ”‚search:publiโ”‚โ”‚search:publicdโ”‚โ”‚search:publicdoโ”‚โ”‚search:publicdomโ”‚โ”‚search:publicdomaโ”‚โ”‚search:publicdomaiโ”‚โ”‚search:publicdomainfโ”‚โ”‚search:publicdomainfiโ”‚โ”‚search:publicdomainfieโ”‚โ”‚search:publicdomainfielโ”‚โ”‚search:publicdomainfieldrโ”‚โ”‚search:publicdomainfieldreโ”‚โ”‚search:publicdomainfieldrecโ”‚โ”‚search:publicdomainfieldrecoโ”‚โ”‚search:publicdomainfieldrecorโ”‚โ”‚search:publicdomainfieldrecordโ”‚โ”‚search:publicdomainfieldrecordiโ”‚โ”‚search:publicdomainfieldrecordinโ”‚โ”‚search:publicdomainfieldrecordingโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€sโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusQueryResultsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ”‚[Spaceโ†’select][Enterโ†’download]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚[Tab/1-3โ†’pane][z][โ†โ†’โ†’movecursor][Backspace/Delโ†’edit][Enterโ†’search][Escโ†’cancel]โ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Lengthโ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\041โ”‚โ”‚โ€บActivepublic domain fi11โ”‚โ”‚โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDaโ”‚โ”‚[]@@hnttf\complete\ExplorersoftheInterior-FieldRecordings2004(netlabel,CC-0โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[pโ†’pause/resume][xโ†’cancel][rโ†’retryfailed][dโ†’deletequeued/done][c[a/Aโ†’selectall/none][h/lโ†’scrollname][g/Gโ†’top/end]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que2.8/100.5MBยท18.61MB/sยท[โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]2%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger1.5 MB/52.5 MB (2%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger1.3MB/48.0MB(2%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que8.4/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]8%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger4.4 MB/52.5 MB (8%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger4.0MB/48.0MB(8%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que14.0/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]13%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger7.3 MB/52.5 MB (13%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger6.7MB/48.0MB(13%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer0.2MB/41.0MB(0%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer0.5MB/41.0MB(1%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer0.9MB/41.0MB(2%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que19.5/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]19%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger10.2 MB/52.5 MB (19%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger9.3MB/48.0MB(19%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer1.3MB/41.0MB(3%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que22.3/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]22%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger11.7 MB/52.5 MB (22%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger10.7MB/48.0MB(22%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer1.7MB/41.0MB(4%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer2.1MB/41.0MB(5%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer2.5MB/41.0MB(6%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que27.9/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]27%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger14.6 MB/52.5 MB (27%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger13.3MB/48.0MB(27%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer2.9MB/41.0MB(7%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer3.3MB/41.0MB(7%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer3.6MB/41.0MB(8%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que33.5/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]33%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger17.5 MB/52.5 MB (33%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger16.0MB/48.0MB(33%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer4.0MB/41.0MB(9%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer4.4MB/41.0MB(10%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer4.8MB/41.0MB(11%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que39.1/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]38%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger20.4 MB/52.5 MB (38%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger18.7MB/48.0MB(38%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer5.2MB/41.0MB(12%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]-CoastalD30.5MBloopdigger675kbps3.35MB/s1โ”‚โ”‚ieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepo32.2MBloopdigger725kbps3.35MB/s1โ”‚โ”‚17.4MBtapehiss836kbps2.1MB/s2โ”‚โ”‚oUA320kbps1.05MB/s0โ”‚โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚5.6MB/41.0MB(13%)3.7MB/sโ”‚โ”‚โ”‚โ”‚[Tab/1-3โ†’pane][zโ†’unzoom]โ†’hide][?โ†’keys][qโ†’quit]โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer6.4MB/41.0MB(15%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que47.5/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]47%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger24.8 MB/52.5 MB (47%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger22.7MB/48.0MB(47%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer6.8MB/41.0MB(16%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer7.1MB/41.0MB(17%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer7.5MB/41.0MB(18%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que53.0/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]52%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger27.7 MB/52.5 MB (52%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger25.3MB/48.0MB(52%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer7.9MB/41.0MB(19%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer8.3MB/41.0MB(20%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer8.7MB/41.0MB(21%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que58.6/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]58%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger30.6 MB/52.5 MB (58%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger28.0MB/48.0MB(58%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer9.0MB/41.0MB(22%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer9.4MB/41.0MB(22%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer9.8MB/41.0MB(23%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que64.2/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]63%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger33.5 MB/52.5 MB (63%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger30.7MB/48.0MB(63%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer10.2MB/41.0MB(24%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer10.6MB/41.0MB(25%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer11.0MB/41.0MB(26%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que69.8/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]69%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger36.5 MB/52.5 MB (69%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger33.3MB/48.0MB(69%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer11.3MB/41.0MB(27%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer11.7MB/41.0MB(28%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer12.1MB/41.0MB(29%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que75.4/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]75%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger39.4 MB/52.5 MB (75%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger36.0MB/48.0MB(75%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer12.5MB/41.0MB(30%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer12.9MB/41.0MB(31%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer13.2MB/41.0MB(32%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que81.0/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]80%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger42.3 MB/52.5 MB (80%)9.7 MB/sโ”‚โ”‚[]@@gufoo\FieldRecordings\gs\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger42.3 MB/52.5 MB (80%)9.7 MB/sโ”‚โ”‚[]@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\06-RainonZโ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0238.0MBtapehiss741kbpsโ”‚โ”‚[]@@hnttf\complโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ”‚StatusQueryResultsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚[Tab/1-3โ†’pane][zโ†’zoom][w[wโ†’hide][?โ†’keys][qโ†’quit]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer14.2MB/41.0MB(34%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que86.5/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘]86%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger45.2 MB/52.5 MB (86%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger41.3MB/48.0MB(86%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer14.6MB/41.0MB(35%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer15.0MB/41.0MB(36%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer15.4MB/41.0MB(37%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que92.1/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘]91%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger48.1 MB/52.5 MB (91%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger44.0MB/48.0MB(91%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer15.8MB/41.0MB(38%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer16.2MB/41.0MB(39%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer16.6MB/41.0MB(40%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer16.6MB/41.0MB(40%)3.7MB/sโ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[sโ†’search][Enterโ†’results][dโ†’remove][mโ†’message][iโ†’inbox]done][cโ†’clearfinished][bโ†’browseuser][h/lโ†’scrollname]โ”‚โ”‚[zโ†’zoom][wโ†’hide][?โ†’keys][qโ†’quitโ†’keys][qโ†’quit]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer17.2MB/41.0MB(41%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer17.6MB/41.0MB(42%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer18.0MB/41.0MB(43%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer18.4MB/41.0MB(44%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer18.8MB/41.0MB(45%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer19.2MB/41.0MB(46%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer19.6MB/41.0MB(47%)3.7MB/sโ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚05dโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โœ“@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-โ”‚โ”‚(2019)[FLAC24-96]ns(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚StatusCompletedโ”‚โ”‚โ”‚โ”‚Uploadspeed9m33sโ”‚โ”‚[Spaceโ†’select][Enterโ†’download][bโ†’browseowner][c[iโ†’inbox][cโ†’chat][bโ†’browseuser][h/lโ†’scrollquery][Tab/1-3โ†’pane]โ”‚โ”‚[Tab/1-3โ†’pane][zโ†’zoom][wโ†’hide][it]โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer20.6MB/41.0MB(50%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer21.0MB/41.0MB(51%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer21.4MB/41.0MB(52%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer21.8MB/41.0MB(53%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer22.1MB/41.0MB(53%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer22.5MB/41.0MB(54%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer22.9MB/41.0MB(55%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer23.3MB/41.0MB(56%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer23.7MB/41.0MB(57%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer24.1MB/41.0MB(58%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer24.5MB/41.0MB(59%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer24.9MB/41.0MB(60%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer25.3MB/41.0MB(61%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer25.6MB/41.0MB(62%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer26.0MB/41.0MB(63%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer26.4MB/41.0MB(64%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer26.8MB/41.0MB(65%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer27.2MB/41.0MB(66%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer28.0MB/41.0MB(68%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer28.4MB/41.0MB(69%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer28.8MB/41.0MB(70%)3.7MB/sโ”‚โ”‚โ”‚โ”‚[]โ€ฆless\PublicDomainFieldRecordings,Vol.2(CreativeCreativeCommonsZero)\0334.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer29.5MB/41.0MB(72%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer29.9MB/41.0MB(72%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer30.2MB/41.0MB(73%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer30.6MB/41.0MB(74%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer31.0MB/41.0MB(75%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer31.4MB/41.0MB(76%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer31.8MB/41.0MB(77%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer32.2MB/41.0MB(78%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer32.6MB/41.0MB(79%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer33.0MB/41.0MB(80%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer33.4MB/41.0MB(81%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer33.7MB/41.0MB(82%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer34.1MB/41.0MB(83%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer34.5MB/41.0MB(84%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer34.9MB/41.0MB(85%)3.7MB/sโ”‚โ”‚โ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommons-Quarry34.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer35.7MB/41.0MB(87%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer35.8MB/41.0MB(87%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer36.2MB/41.0MB(88%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer36.6MB/41.0MB(89%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer37.0MB/41.0MB(90%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer37.4MB/41.0MB(91%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer37.8MB/41.0MB(92%)3.7MB/sโ”‚โ”‚โ”‚โ”‚StatusQueryResultsโ”‚โ”‚StatusFilenameUserSpeedโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โœ“@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-โ”‚โ”‚(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Userโ”‚โ”‚[sโ†’eโ†’select][Enterโ†’download][bโ†’browseowner][cโ†’chat][/โ†’filter][a/Aโ†’selectall/none][h/lโ†’scrollname][g/Gโ†’top/end]โ”‚โ”‚[sโ†’search][Enterโ†’results][dโ†’remove][mโ†’message][iโ†’inbox][cโ†’chat][bโ†’browseuser][h/lโ†’scrollquery][][g/Gโ†’top/end]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer38.8MB/41.0MB(94%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer39.2MB/41.0MB(95%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer39.6MB/41.0MB(96%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer40.0MB/41.0MB(97%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer40.4MB/41.0MB(98%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer40.7MB/41.0MB(99%)3.7MB/sโ”‚โ”‚โ”‚โ”‚[]Music\losslessโ”‚clickfocusapaneResults34.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚[]@@hnttf\compleโ”‚bbrowseauser'sfiles/filter,Enter320kbps1.05MB/s0โ”‚โ”‚โ”‚qquitโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚Statpublic domain fi15โ”‚โ”‚โ€บโœ“@@gufoo\FloopdiggerCompleted-โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚PgUpPgDn/^b^fonepage-โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Savetoโ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[]@@gufoo\FieldRecordingszoomthefocusedpaneCcleareverysearchโ”‚3.35MB/s1โ”‚โ”‚[]Music\lossless\PublicDomainFieldRecordings,Vol.2(CreativeCommonsZero)\0418.7MB/deselectโ”‚2.1MB/s2โ”‚โ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€cancelโ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บmoveonerowddeleteaqueuedorfinishedoneโ”‚flacโ”‚โ”‚โ”‚โ”‚โ†‘โœ“HarbourBโ”‚AC24-96]โ”‚โ”‚HomeEnd/gGoldest/newest,orfirst/lasโ”‚Completedโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ”‚โ”‚โ”‚[sโ†’search][Enterโ†’results][dโ†’remove][mโ†’message][iโ†’inbox]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚[zโ†’zoom][wโ†’hide][?โ†’keys][qโ†’quitโ”‚ \ No newline at end of file +โ•ญStatusโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:0active,0completed,0failed,0queโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ญ[2]Resultsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚soulseek-rsv14.1.2โ”‚โ”‚โ”‚โ”‚Noresultsyet.โ”‚โ”‚Pickasearchin[1]Searches,orstartanewone.โ”‚โ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚Selectaresultortransferfordetailsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ญShortcutsยทSharing:~/Musicโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚[sโ†’search][Enterโ†’results][dโ†’remove][mโ†’message][iโ†’inbox][cโ†’chat][bโ†’browseuser][h/lโ†’scrollquery][Tab/1-3โ†’pane]โ”‚โ”‚[zโ†’zoom][wโ†’hide][?โ†’keys][qโ†’quit]โ”‚โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚search:โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[Typeโ†’searchterm][โ†โ†’โ†’movecursor][Backspace/Delโ†’edit][Enterโ†’search][Escโ†’cancel]โ”‚โ”‚search:publicโ”‚โ”‚search:publicdomainโ”‚โ”‚search:publicdomainfieldโ”‚โ”‚search:publicdomainfieldrecordingsโ”‚โ•ญ[2]Results:publicdomainfieldrecordingsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚soulseek-rsv14.1.2โ”‚โ”‚โ”‚โ”‚Noresultsyet.โ”‚โ”‚Pickasearchin[1]Searches,orstartanewone.โ”‚โ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚Selectaresultortransferfordetailsโ”‚โ”‚โ€บActivepublic domain fiโ€ฆโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[Spaceโ†’select][Enterโ†’download][bโ†’browseowner][cโ†’chat][/โ†’filter][a/Aโ†’selectall/none][h/lโ†’scrollname][g/Gโ†’top/end]โ”‚โ”‚[Tab/1-3โ†’pane][zโ†’zoom][wโ†’hide][?โ†’keys][qโ†’quit]โ”‚โ”‚โœ“FilenameFolderSizeUserBitrateSpeedSlotsโ”‚โ”‚โ€บ[ ]01 - Coastal Dawn (Sunrise Take, Long Version).f@@gufoo\Field Recordings\Coastal30.5 MBloopdigger675 kbps3.35 MB/s1โ”‚โ”‚[]02-ReservoirWind.flac@@gufoo\FieldRecordings\Coastal22.6MBloopdigger569kbps3.35MB/s1โ”‚โ”‚[]03-MarshatNight(Long).flac@@gufoo\FieldRecordings\Coastal48.0MBloopdigger920kbps3.35MB/s1โ”‚โ”‚[]04-TramDepot,06h.flac@@gufoo\FieldRecordings\Coastal32.2MBloopdigger725kbps3.35MB/s1โ”‚โ”‚[]05-HarbourBells.flac@@gufoo\FieldRecordings\Coastal52.5MBloopdigger902kbps3.35MB/s1โ”‚โ”‚[]06-RainonZincRoof.flac@@gufoo\FieldRecordings\Coastal30.4MBloopdigger804kbps3.35MB/s1โ”‚โ”‚[]cover.jpg@@gufoo\FieldRecordings\Coastal0.4MBloopdigger-3.35MB/s1โ”‚โ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚01-CoastalDawn(SunriseTake,Longโ”‚โ”‚โ€บActivepublic domain fi7โ”‚โ”‚โ”‚โ”‚Version).flacโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Userloopdiggerโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size30.5MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate675kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length6m52sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Freeslots1availableโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Uploadspeed3.4MB/sโ”‚โ”‚[]01-EmptyChapelTone.flacMusic\lossless\PublicDomainFie17.4MBtapehiss836kbps2.1MB/s2โ”‚โ”‚[]02-ForestFloor.flacMusic\lossless\PublicDomainFie38.0MBtapehiss741kbps2.1MB/s2โ”‚โ”‚[]03-QuarryEcho.flacMusic\lossless\PublicDomainFie34.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚[]04-NightFerry.flacMusic\lossless\PublicDomainFie18.7MBtapehiss723kbps2.1MB/s2โ”‚โ”‚โ€บActivepublic domain fi11โ”‚โ”‚โ”‚โ”‚Version).flacโ”‚โ”‚[]01-CoastalDawn(SunriseTake,LongVersion).f@@gufoo\FieldRecordings\Coastal30.5MBloopdigger675kbps3.35MB/s1โ”‚โ”‚โ€บ[ ]02 - Reservoir Wind.flac@@gufoo\Field Recordings\Coastal22.6 MBloopdigger569 kbps3.35 MB/s1โ”‚โ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚02-ReservoirWind.flacโ”‚โ”‚โ€บActivepublic domain fi11โ”‚โ”‚โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size22.6MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate569kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length5m18sโ”‚โ”‚โ€บ[ ]03 - Marsh at Night (Long).flac@@gufoo\Field Recordings\Coastal48.0 MBloopdigger920 kbps3.35 MB/s1โ”‚โ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚03-MarshatNight(Long).flacโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size48.0MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate920kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length8m21sโ”‚โ”‚[]01-Opener.mp3@@hnttf\complete\Explorersofth9.4MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]02-Interlude.mp3@@hnttf\complete\Explorersofth5.1MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]03-Coda.mp3@@hnttf\complete\Explorersofth19.5MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]readme.txt@@hnttf\complete\Explorersofth0.1MBnectoUA-1.05MB/s0โ”‚โ”‚โ€บActivepublic domain fi15โ”‚โ”‚โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ€บ[โœ“]03 - Marsh at Night (Long).flac@@gufoo\Field Recordings\Coastal48.0 MBloopdigger920 kbps3.35 MB/s1โ”‚โ”‚[โœ“]03-MarshatNight(Long).flac@@gufoo\FieldRecordings\Coastal48.0MBloopdigger920kbps3.35MB/s1โ”‚โ”‚โ€บ[ ]04 - Tram Depot, 06h.flac@@gufoo\Field Recordings\Coastal32.2 MBloopdigger725 kbps3.35 MB/s1โ”‚โ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚04-TramDepot,06h.flacโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size32.2MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate725kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length6m06sโ”‚โ”‚โ€บ[ ]05 - Harbour Bells.flac@@gufoo\Field Recordings\Coastal52.5 MBloopdigger902 kbps3.35 MB/s1โ”‚โ”‚StatusQueryResultsโ”‚โ”‚Notransfers.Selectfilesin[2]ResultsandpressEnter.โ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size52.5MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate902kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length9m33sโ”‚โ”‚โ€บ[โœ“]05 - Harbour Bells.flac@@gufoo\Field Recordings\Coastal52.5 MBloopdigger902 kbps3.35 MB/s1โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:0active,0completed,0failed,2queโ”‚โ”‚StatusQueryResultsโ”‚โ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ‹ฏ@@gufoo\FloopdiggerQueued-โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ‹ฏ@@gufoo\FloopdiggerQueued-โ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ•ญ[2]Results:publicdomainfieldrecordingsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚โœ“FilenameFolderSizeUserBitrateSpeedSlotsโ”‚โ”‚[]01-CoastalDawn(SunriseTake,LongVersion).f@@gufoo\FieldRecordings\Coastal30.5MBloopdigger675kbps3.35MB/s1โ”‚โ”‚[]02-ReservoirWind.flac@@gufoo\FieldRecordings\Coastal22.6MBloopdigger569kbps3.35MB/s1โ”‚โ”‚[]03-MarshatNight(Long).flac@@gufoo\FieldRecordings\Coastal48.0MBloopdigger920kbps3.35MB/s1โ”‚โ”‚[]04-TramDepot,06h.flac@@gufoo\FieldRecordings\Coastal32.2MBloopdigger725kbps3.35MB/s1โ”‚โ”‚โ€บ[ ]05 - Harbour Bells.flac@@gufoo\Field Recordings\Coastal52.5 MBloopdigger902 kbps3.35 MB/s1โ”‚โ”‚[]06-RainonZincRoof.flac@@gufoo\FieldRecordings\Coastal30.4MBloopdigger804kbps3.35MB/s1โ”‚โ”‚[]cover.jpg@@gufoo\FieldRecordings\Coastal0.4MBloopdigger-3.35MB/s1โ”‚โ”‚[]01-EmptyChapelTone.flacMusic\lossless\PublicDomainFie17.4MBtapehiss836kbps2.1MB/s2โ”‚โ”‚[]02-ForestFloor.flacMusic\lossless\PublicDomainFie38.0MBtapehiss741kbps2.1MB/s2โ”‚โ”‚[]03-QuarryEcho.flacMusic\lossless\PublicDomainFie34.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚[]04-NightFerry.flacMusic\lossless\PublicDomainFie18.7MBtapehiss723kbps2.1MB/s2โ”‚โ”‚[]01-Opener.mp3@@hnttf\complete\Explorersofth9.4MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]02-Interlude.mp3@@hnttf\complete\Explorersofth5.1MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]03-Coda.mp3@@hnttf\complete\Explorersofth19.5MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]readme.txt@@hnttf\complete\Explorersofth0.1MBnectoUA-1.05MB/s0โ”‚โ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusQueryResultsโ”‚โ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ‹ฏ@@gufoo\FloopdiggerQueued-โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ‹ฏ@@gufoo\FloopdiggerQueued-โ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Userloopdiggerโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size52.5MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚StatusQueuedโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Savetoโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚/home/demo/Music/Soulseekโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate902kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length9m33sโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[pโ†’pause/resume][xโ†’cancel][rโ†’retryfailed][dโ†’deletequeued/done][cโ†’clearfinished][bโ†’browseuser][h/lโ†’scrollname]โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚StatusInprogressโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que5.6/100.5MBยท18.61MB/sยท[โ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]5%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger2.9 MB/52.5 MB (5%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger2.7MB/48.0MB(5%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que11.2/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]11%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger5.8 MB/52.5 MB (11%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger5.3MB/48.0MB(11%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que16.8/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]16%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger8.8 MB/52.5 MB (16%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger8.0MB/48.0MB(16%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que25.1/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]25%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger13.1 MB/52.5 MB (25%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger12.0MB/48.0MB(25%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que30.7/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]30%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger16.0 MB/52.5 MB (30%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger14.7MB/48.0MB(30%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que36.3/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]36%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger19.0 MB/52.5 MB (36%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger17.3MB/48.0MB(36%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que41.9/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]41%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger21.9 MB/52.5 MB (41%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger20.0MB/48.0MB(41%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer5.6MB/41.0MB(13%)3.7MB/sโ”‚โ”‚โ”‚โ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger21.9 MB/52.5 MB (41%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger20.0MB/48.0MB(41%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer6.0MB/41.0MB(14%)3.7MB/sโ”‚โ”‚[Tab/1-3โ†’pane][zโ†’unzoom][wโ†’hide][?โ†’keys][qโ†’quit]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que44.7/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]44%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger23.3 MB/52.5 MB (44%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger21.3MB/48.0MB(44%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que50.2/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]50%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger26.2 MB/52.5 MB (50%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger24.0MB/48.0MB(50%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que55.8/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]55%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger29.2 MB/52.5 MB (55%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger26.7MB/48.0MB(55%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que61.4/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]61%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger32.1 MB/52.5 MB (61%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger29.3MB/48.0MB(61%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que67.0/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]66%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger35.0 MB/52.5 MB (66%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger32.0MB/48.0MB(66%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que72.6/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]72%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger37.9 MB/52.5 MB (72%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger34.7MB/48.0MB(72%)8.9MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que78.2/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]77%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger40.8 MB/52.5 MB (77%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger37.3MB/48.0MB(77%)8.9MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger38.7MB/48.0MB(80%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer13.6MB/41.0MB(33%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que83.8/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]83%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger43.8 MB/52.5 MB (83%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger40.0MB/48.0MB(83%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer14.0MB/41.0MB(34%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que89.3/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘]88%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger46.7 MB/52.5 MB (88%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger42.7MB/48.0MB(88%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que94.9/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘]94%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger49.6 MB/52.5 MB (94%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger45.3MB/48.0MB(94%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que97.7/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘]97%โ”‚โ”‚StatusQueryResultsโ”‚โ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger51.0 MB/52.5 MB (97%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger46.7MB/48.0MB(97%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer17.0MB/41.0MB(41%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Userloopdiggerโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Size52.5MBโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚StatusInprogressโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Savetoโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚/home/demo/Music/Soulseekโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Bitrate902kbpsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Length9m33sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:0active,2completed,0failed,0queโ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโœ“@@gufoo\FloopdiggerCompleted-โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โœ“@@gufoo\FloopdiggerCompleted-โ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚StatusCompletedโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer19.9MB/41.0MB(48%)3.7MB/sโ”‚โ”‚โ”‚โ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โ€บโœ“@@gufoo\Field Recordings\CoastalloopdiggerCompleted-โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnSessionsโ”‚โ”‚โœ“@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-โ”‚โ”‚(2019)[FLAC24-96]โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer20.3MB/41.0MB(49%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Userloopdiggerโ”‚โ”‚โ”‚โ”‚Size52.5MBโ”‚โ”‚โ”‚โ”‚Bitrate902kbpsโ”‚โ”‚โ”‚โ”‚Length9m33sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Freeslots1availableโ”‚โ”‚โ”‚โ”‚Uploadspeed3.4MB/sโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer27.6MB/41.0MB(67%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer29.2MB/41.0MB(71%)3.7MB/sโ”‚โ”‚โ”‚โ”‚[]โ€ฆCoastalDawn(SunriseTake,LongVersion).flacโ€ฆwnSessions(2019)[FLAC24-96]30.5MBloopdigger675kbps3.35MB/s1โ”‚โ”‚[]02-ReservoirWind.flacโ€ฆwnSessions(2019)[FLAC24-96]22.6MBloopdigger569kbps3.35MB/s1โ”‚โ”‚[]03-MarshatNight(Long).flacโ€ฆwnSessions(2019)[FLAC24-96]48.0MBloopdigger920kbps3.35MB/s1โ”‚โ”‚[]04-TramDepot,06h.flacโ€ฆwnSessions(2019)[FLAC24-96]32.2MBloopdigger725kbps3.35MB/s1โ”‚โ”‚โ€บ[ ]05 - Harbour Bells.flacโ€ฆwn Sessions (2019) [FLAC 24-96]52.5 MBloopdigger902 kbps3.35 MB/s1โ”‚โ”‚[]06-RainonZincRoof.flacโ€ฆwnSessions(2019)[FLAC24-96]30.4MBloopdigger804kbps3.35MB/s1โ”‚โ”‚[]01-EmptyChapelTone.flacโ€ฆRecordings,Vol.2(CreativeCo17.4MBtapehiss836kbps2.1MB/s2โ”‚โ”‚[]02-ForestFloor.flacโ€ฆRecordings,Vol.2(CreativeCo38.0MBtapehiss741kbps2.1MB/s2โ”‚โ”‚[]03-QuarryEcho.flacโ€ฆRecordings,Vol.2(CreativeCo34.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚[]04-NightFerry.flacโ€ฆRecordings,Vol.2(CreativeCo18.7MBtapehiss723kbps2.1MB/s2โ”‚โ”‚[]01-Opener.mp3โ€ฆnterior-FieldRecordings20049.4MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]02-Interlude.mp3โ€ฆnterior-FieldRecordings20045.1MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]03-Coda.mp3โ€ฆnterior-FieldRecordings200419.5MBnectoUA320kbps1.05MB/s0โ”‚โ”‚[]readme.txtโ€ฆnterior-FieldRecordings20040.1MBnectoUA-1.05MB/s0โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer35.3MB/41.0MB(86%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer38.2MB/41.0MB(93%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer38.6MB/41.0MB(94%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โœ“HarbourBcrateminerCompleted-โ”‚โ”‚โ”‚โ”‚[]03-Marshatโ•ญKeysโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ3.35MB/s1โ”‚โ”‚[]04-TramDepoโ”‚PanesSearchesโ”‚3.35MB/s1โ”‚โ”‚โ€บ[ ]05 - Harbour Bโ”‚Tab/Shift-Tabnext/previouspaneEntershowitsresultsโ”‚3.35 MB/s1โ”‚โ”‚[]06-RainonZโ”‚123focusapane,hiddenornotdremovethesearchโ”‚3.35MB/s1โ”‚โ”‚[]cover.jpgโ”‚zzoomthefocusedpaneCcleareverysearchโ”‚3.35MB/s1โ”‚โ”‚[]01-EmptyChaโ”‚whidethefocusedpanecchatroomsโ”‚2.1MB/s2โ”‚โ”‚[]02-ForestFlโ”‚Escleavezoomโ”‚2.1MB/s2โ”‚โ”‚[]03-QuarryEcโ”‚clickfocusapaneResultsโ”‚2.1MB/s2โ”‚โ”‚[]04-NightFerโ”‚Spaceselect/deselectโ”‚2.1MB/s2โ”‚โ”‚[]01-Opener.mpโ”‚Anywherea/Aselectall/noneโ”‚1.05MB/s0โ”‚โ”‚[]02-Interludeโ”‚ssearchthenetworkEnterdownloadtheselectionโ”‚1.05MB/s0โ”‚โ”‚[]03-Coda.mp3โ”‚bbrowseauser'sfiles/filter,Enterkeepsit,Esccleโ”‚1.05MB/s0โ”‚โ”‚[]readme.txtโ”‚mcomposeaprivatemessagebbrowsetheownerโ”‚1.05MB/s0โ”‚โ”‚โ”‚iinboxcchatroomsโ”‚โ”‚โ”‚โ”‚osettingsโ”‚โ”‚โ”‚โ”‚?thislistDownloads/Uploadsโ”‚โ”‚โ”‚โ”‚qquitppause/resumeโ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚xcancelโ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”‚Anylistrretryafailedoneโ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusQueryโ”‚โ†‘โ†“/kjmoveonerowddeleteaqueuedorfinishedoneโ”‚flacโ”‚โ”‚โ€บDonepublic domaโ”‚HomeEnd/gGfirst/lastrowccleareveryfinishedoneโ”‚dings/CoastalDawnโ”‚โ”‚โ”‚PgUpPgDn/^b^fonepageโ”‚AC24-96]โ”‚โ”‚โ”‚^u^dhalfapageChatandbrowsepopupsโ”‚โ”‚โ”‚โ”‚hl/โ†โ†’scrollalongnamesidewaysPgUpPgDn/^b^fapageofmessages,orofthetโ”‚loopdiggerโ”‚โ”‚โ”‚0/$itsstart/itsend^u^dhalfapageofthemโ”‚52.5MBโ”‚โ”‚โ”‚HomeEnd/gGoldest/newest,orfirst/lasโ”‚Completedโ”‚โ”‚โ”‚Tab/Shift-Tabnextroom,chatoruserโ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏulseekโ”‚โ”‚[?/Escโ†’close]โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”‚โ”‚โ”‚โ”‚โ”‚โ•ญShortcutsยทSharing:~/Musicโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚[sโ†’search][Enterโ†’results][dโ†’remove][mโ†’message][iโ†’inbox][cโ†’chat][bโ†’browseuser][h/lโ†’scrollqueryโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚StatusQueryResultsโ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญโ”‚โ”‚โ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚[Typeโ†’searchterm][โ†โ†’โ†’movecursor][Backspace/Delโ†’edit][Enterโ†’search]โ”‚โ”‚search:pโ”‚โ”‚search:puโ”‚โ”‚search:pubโ”‚โ”‚search:publโ”‚โ”‚search:publiโ”‚โ”‚search:publicdโ”‚โ”‚search:publicdoโ”‚โ”‚search:publicdomโ”‚โ”‚search:publicdomaโ”‚โ”‚search:publicdomaiโ”‚โ”‚search:publicdomainfโ”‚โ”‚search:publicdomainfiโ”‚โ”‚search:publicdomainfieโ”‚โ”‚search:publicdomainfielโ”‚โ”‚search:publicdomainfieldrโ”‚โ”‚search:publicdomainfieldreโ”‚โ”‚search:publicdomainfieldrecโ”‚โ”‚search:publicdomainfieldrecoโ”‚โ”‚search:publicdomainfieldrecorโ”‚โ”‚search:publicdomainfieldrecordโ”‚โ”‚search:publicdomainfieldrecordiโ”‚โ”‚search:publicdomainfieldrecordinโ”‚โ”‚search:publicdomainfieldrecordingโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€sโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[Info]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚StatusQueryResultsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ”‚[Spaceโ†’select][Enterโ†’download]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚[Tab/1-3โ†’pane][z][โ†โ†’โ†’movecursor][Backspace/Delโ†’edit][Enterโ†’search][Escโ†’cancel]โ”‚โ”‚[]06-RainonZincRoof.flacโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Lengthโ”‚โ”‚[]04-NightFerry.flacMusic\lossless\PublicDomainFie1โ”‚โ”‚โ€บActivepublic domain fi11โ”‚โ”‚โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDaโ”‚โ”‚[]readme.txt@@hnttf\complete\Explorersofth0โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[pโ†’pause/resume][xโ†’cancel][rโ†’retryfailed][dโ†’deletequeued/done][c[a/Aโ†’selectall/none][h/lโ†’scrollname][g/Gโ†’top/end]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que2.8/100.5MBยท18.61MB/sยท[โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]2%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger1.5 MB/52.5 MB (2%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger1.3MB/48.0MB(2%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que8.4/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]8%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger4.4 MB/52.5 MB (8%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger4.0MB/48.0MB(8%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que14.0/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]13%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger7.3 MB/52.5 MB (13%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger6.7MB/48.0MB(13%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer0.2MB/41.0MB(0%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer0.5MB/41.0MB(1%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer0.9MB/41.0MB(2%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que19.5/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]19%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger10.2 MB/52.5 MB (19%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger9.3MB/48.0MB(19%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer1.3MB/41.0MB(3%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que22.3/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]22%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger11.7 MB/52.5 MB (22%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger10.7MB/48.0MB(22%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer1.7MB/41.0MB(4%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer2.1MB/41.0MB(5%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer2.5MB/41.0MB(6%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que27.9/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]27%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger14.6 MB/52.5 MB (27%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger13.3MB/48.0MB(27%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer2.9MB/41.0MB(7%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer3.3MB/41.0MB(7%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer3.6MB/41.0MB(8%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que33.5/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]33%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger17.5 MB/52.5 MB (33%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger16.0MB/48.0MB(33%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer4.0MB/41.0MB(9%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer4.4MB/41.0MB(10%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer4.8MB/41.0MB(11%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que39.1/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]38%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger20.4 MB/52.5 MB (38%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger18.7MB/48.0MB(38%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer5.2MB/41.0MB(12%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]-CoastalD30.5MBloopdigger675kbps3.35MB/s1โ”‚โ”‚ieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\04-TramDepo32.2MBloopdigger725kbps3.35MB/s1โ”‚โ”‚17.4MBtapehiss836kbps2.1MB/s2โ”‚โ”‚oUA320kbps1.05MB/s0โ”‚โ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚5.6MB/41.0MB(13%)3.7MB/sโ”‚โ”‚โ”‚โ”‚[Tab/1-3โ†’pane][zโ†’unzoom]โ†’hide][?โ†’keys][qโ†’quit]โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer6.4MB/41.0MB(15%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que47.5/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]47%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger24.8 MB/52.5 MB (47%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger22.7MB/48.0MB(47%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer6.8MB/41.0MB(16%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer7.1MB/41.0MB(17%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer7.5MB/41.0MB(18%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que53.0/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]52%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger27.7 MB/52.5 MB (52%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger25.3MB/48.0MB(52%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer7.9MB/41.0MB(19%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer8.3MB/41.0MB(20%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer8.7MB/41.0MB(21%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que58.6/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]58%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger30.6 MB/52.5 MB (58%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger28.0MB/48.0MB(58%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer9.0MB/41.0MB(22%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer9.4MB/41.0MB(22%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer9.8MB/41.0MB(23%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que64.2/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]63%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger33.5 MB/52.5 MB (63%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger30.7MB/48.0MB(63%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer10.2MB/41.0MB(24%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer10.6MB/41.0MB(25%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer11.0MB/41.0MB(26%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que69.8/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]69%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger36.5 MB/52.5 MB (69%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger33.3MB/48.0MB(69%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer11.3MB/41.0MB(27%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer11.7MB/41.0MB(28%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer12.1MB/41.0MB(29%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que75.4/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]75%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger39.4 MB/52.5 MB (75%)9.7 MB/sโ”‚โ”‚โ†ฏ@@gufoo\FieldRecordings\CoastalDawnSessions(2019)[FLAC24-96]\03-MarshatNight(loopdigger36.0MB/48.0MB(75%)8.9MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer12.5MB/41.0MB(30%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer12.9MB/41.0MB(31%)3.7MB/sโ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay).flaccrateminer13.2MB/41.0MB(32%)3.7MB/sโ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que81.0/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]80%โ”‚โ”‚โ€บโ†ฏ@@gufoo\Field Recordings\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger42.3 MB/52.5 MB (80%)9.7 MB/sโ”‚โ”‚[]01-CoastalDawn(Sunrisgs\Coastal Dawn Sessions (2019) [FLAC 24-96]\05 - Harbour Bells.flloopdigger42.3 MB/52.5 MB (80%)9.7 MB/sโ”‚โ”‚[]06-RainonZincRoof.flac@@gufoo\FieldRecordings\Coastalโ”‚โ”‚[]02-ForestFloor.flacMusic\lossless\PublicDomainFie38.0MBtapehiss741kbpsโ”‚โ”‚[]02-Interludโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ”‚StatusQueryResultsโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚[Tab/1-3โ†’pane][zโ†’zoom][w[wโ†’hide][?โ†’keys][qโ†’quit]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer14.2MB/41.0MB(34%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que86.5/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘]86%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger45.2 MB/52.5 MB (86%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger41.3MB/48.0MB(86%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer14.6MB/41.0MB(35%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer15.0MB/41.0MB(36%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer15.4MB/41.0MB(37%)3.7MB/sโ”‚โ”‚โ”‚โ”‚soulseek-rs๐Ÿฆ€v14.1.2Downloads:2active,0completed,0failed,0que92.1/100.5MBยท18.61MB/sยท[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘]91%โ”‚โ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บโ†ฏ@@gufoo\Floopdigger48.1 MB/52.5 MB (91%)9.7 MB/sโ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚โ”‚โ†ฏ@@gufoo\Floopdigger44.0MB/48.0MB(91%)8.9MB/sโ”‚โ”‚Sessions(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer15.8MB/41.0MB(38%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer16.2MB/41.0MB(39%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer16.6MB/41.0MB(40%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer16.6MB/41.0MB(40%)3.7MB/sโ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[sโ†’search][Enterโ†’results][dโ†’remove][mโ†’message][iโ†’inbox]done][cโ†’clearfinished][bโ†’browseuser][h/lโ†’scrollname]โ”‚โ”‚[zโ†’zoom][wโ†’hide][?โ†’keys][qโ†’quitโ†’keys][qโ†’quit]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer17.2MB/41.0MB(41%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer17.6MB/41.0MB(42%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer18.0MB/41.0MB(43%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer18.4MB/41.0MB(44%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer18.8MB/41.0MB(45%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer19.2MB/41.0MB(46%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer19.6MB/41.0MB(47%)3.7MB/sโ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚StatusFilenameUserProgressSpeedโ”‚โ”‚05dโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โœ“@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-โ”‚โ”‚(2019)[FLAC24-96]ns(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚StatusCompletedโ”‚โ”‚โ”‚โ”‚Uploadspeed9m33sโ”‚โ”‚[Spaceโ†’select][Enterโ†’download][bโ†’browseowner][c[iโ†’inbox][cโ†’chat][bโ†’browseuser][h/lโ†’scrollquery][Tab/1-3โ†’pane]โ”‚โ”‚[Tab/1-3โ†’pane][zโ†’zoom][wโ†’hide][it]โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer20.6MB/41.0MB(50%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer21.0MB/41.0MB(51%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer21.4MB/41.0MB(52%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer21.8MB/41.0MB(53%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer22.1MB/41.0MB(53%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer22.5MB/41.0MB(54%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer22.9MB/41.0MB(55%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer23.3MB/41.0MB(56%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer23.7MB/41.0MB(57%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer24.1MB/41.0MB(58%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer24.5MB/41.0MB(59%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer24.9MB/41.0MB(60%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer25.3MB/41.0MB(61%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer25.6MB/41.0MB(62%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer26.0MB/41.0MB(63%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer26.4MB/41.0MB(64%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer26.8MB/41.0MB(65%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer27.2MB/41.0MB(66%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer28.0MB/41.0MB(68%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer28.4MB/41.0MB(69%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer28.8MB/41.0MB(70%)3.7MB/sโ”‚โ”‚โ”‚โ”‚[]03-QuarryEcho.flacโ€ฆRecorCreativeCommonsZero)\0334.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer29.5MB/41.0MB(72%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer29.9MB/41.0MB(72%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer30.2MB/41.0MB(73%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer30.6MB/41.0MB(74%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer31.0MB/41.0MB(75%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer31.4MB/41.0MB(76%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer31.8MB/41.0MB(77%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer32.2MB/41.0MB(78%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer32.6MB/41.0MB(79%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer33.0MB/41.0MB(80%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer33.4MB/41.0MB(81%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer33.7MB/41.0MB(82%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer34.1MB/41.0MB(83%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer34.5MB/41.0MB(84%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer34.9MB/41.0MB(85%)3.7MB/sโ”‚โ”‚โ”‚โ”‚[]03-QuarryEcho.flacMusic\lossless\PublicD-Quarry34.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer35.7MB/41.0MB(87%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer35.8MB/41.0MB(87%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer36.2MB/41.0MB(88%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer36.6MB/41.0MB(89%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer37.0MB/41.0MB(90%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer37.4MB/41.0MB(91%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBells(LiveattheQuay)crateminer37.8MB/41.0MB(92%)3.7MB/sโ”‚โ”‚โ”‚โ”‚StatusQueryResultsโ”‚โ”‚StatusFilenameUserSpeedโ”‚โ”‚05-HarbourBells.flacโ”‚โ”‚โœ“@@gufoo\FieldRecordings\CoastalloopdiggerCompleted-โ”‚โ”‚(2019)[FLAC24-96]โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Userโ”‚โ”‚[sโ†’eโ†’select][Enterโ†’download][bโ†’browseowner][cโ†’chat][/โ†’filter][a/Aโ†’selectall/none][h/lโ†’scrollname][g/Gโ†’top/end]โ”‚โ”‚[sโ†’search][Enterโ†’results][dโ†’remove][mโ†’message][iโ†’inbox][cโ†’chat][bโ†’browseuser][h/lโ†’scrollquery][][g/Gโ†’top/end]โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer38.8MB/41.0MB(94%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer39.2MB/41.0MB(95%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer39.6MB/41.0MB(96%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer40.0MB/41.0MB(97%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer40.4MB/41.0MB(98%)3.7MB/sโ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ†‘โ†ฏHarbourBcrateminer40.7MB/41.0MB(99%)3.7MB/sโ”‚โ”‚โ”‚โ”‚[]03-QuarryEcโ”‚clickfocusapaneResults34.6MBtapehiss753kbps2.1MB/s2โ”‚โ”‚[]03-Coda.mp3โ”‚bbrowseauser'sfiles/filter,Enter320kbps1.05MB/s0โ”‚โ”‚โ”‚qquitโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚Statpublic domain fi15โ”‚โ”‚โ€บโœ“@@gufoo\FloopdiggerCompleted-โ”‚โ”‚@@gufoo/FieldRecordings/CoastalDawnโ”‚โ”‚โ”‚PgUpPgDn/^b^fonepage-โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚โ”‚Savetoโ”‚โ”‚โ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚[]cover.jpgzoomthefocusedpaneCcleareverysearchโ”‚3.35MB/s1โ”‚โ”‚[]04-NightFerry.flacMusic\lossless\PublicDomainFie18.7MB/deselectโ”‚2.1MB/s2โ”‚โ•ญ[1]Searchesโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญ[3]Downloads/Uploadsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€cancelโ”‚โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚โ€บDonepublic domain fi15โ”‚โ”‚โ€บmoveonerowddeleteaqueuedorfinishedoneโ”‚flacโ”‚โ”‚โ”‚โ”‚โ†‘โœ“HarbourBโ”‚AC24-96]โ”‚โ”‚HomeEnd/gGoldest/newest,orfirst/lasโ”‚Completedโ”‚โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚โ”‚โ”‚โ”‚[sโ†’search][Enterโ†’results][dโ†’remove][mโ†’message][iโ†’inbox]โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚[zโ†’zoom][wโ†’hide][?โ†’keys][qโ†’quitโ”‚ \ No newline at end of file diff --git a/soulseek-rs-lib/README.md b/soulseek-rs-lib/README.md index 33a7756..6b41dc1 100644 --- a/soulseek-rs-lib/README.md +++ b/soulseek-rs-lib/README.md @@ -11,11 +11,10 @@ network. It can be used to build custom Soulseek clients. ## Usage -Add this to your `Cargo.toml`: +Add it to your `Cargo.toml`: -```toml -[dependencies] -soulseek-rs-lib = "8" +```bash +cargo add soulseek-rs-lib ``` ## Example diff --git a/soulseek-rs-lib/examples/stress.rs b/soulseek-rs-lib/examples/stress.rs index 6952ce1..31d086e 100644 --- a/soulseek-rs-lib/examples/stress.rs +++ b/soulseek-rs-lib/examples/stress.rs @@ -895,6 +895,16 @@ fn write_baseline(path: &Path, score: &Score, cfg: &Config, m: &Metrics) { /// Live thread count for this process, so the harness can show whether load is /// being absorbed or simply turned into threads. fn thread_count() -> usize { + // Linux keeps the count in /proc; `ps -M` is macOS's spelling and returns + // a single unrelated line there, which read as one thread all along and + // made this guard against thread explosions useless on CI. + if let Ok(status) = std::fs::read_to_string("/proc/self/status") { + return status + .lines() + .find_map(|line| line.strip_prefix("Threads:")) + .and_then(|count| count.trim().parse().ok()) + .unwrap_or(0); + } Command::new("ps") .args(["-M", &std::process::id().to_string()]) .output() @@ -1026,6 +1036,7 @@ fn main() { enable_listen: true, listen_port: client_port, shared_directories: vec![share_dir.display().to_string()], + accept_children: false, version: ClientVersion::default(), }); client.connect().expect("client connect"); diff --git a/soulseek-rs-lib/src/actor/peer_actor.rs b/soulseek-rs-lib/src/actor/peer_actor.rs index bf63e3a..a494b04 100644 --- a/soulseek-rs-lib/src/actor/peer_actor.rs +++ b/soulseek-rs-lib/src/actor/peer_actor.rs @@ -1,12 +1,13 @@ use crate::actor::{Actor, ActorHandle, ConnectionState}; use crate::client::ClientOperation; use crate::dispatcher::MessageDispatcher; +use crate::message::peer::UserInfoResponseHandler; use crate::message::peer::{ - FileSearchResponse, FolderContentsRequest, GetShareFileList, PeerInit, - PlaceInQueueRequest, PlaceInQueueResponse, QueueUploadHandler, - SharedDirectory, SharedFileListResponseHandler, TransferRequest, - TransferResponse, UploadDeniedHandler, UploadFailedHandler, - UserInfoRequest, + FileSearchResponse, FolderContentsRequest, FolderContentsResponseHandler, + GetShareFileList, PeerInit, PlaceInQueueRequest, PlaceInQueueResponse, + QueueUploadHandler, SharedDirectory, SharedFileListResponseHandler, + TransferRequest, TransferResponse, UploadDeniedHandler, + UploadFailedHandler, UserInfoRequest, }; use crate::message::server::MessageFactory; use crate::message::{Handlers, Message, MessageReader, MessageType}; @@ -47,6 +48,14 @@ pub enum PeerMessage { ShareListRequested, /// A peer we are browsing sent us their shared-file listing (code 5). ShareListReceived(Vec), + /// A peer answered our `UserInfoRequest` with what it says about itself. + UserInfoReceived(crate::message::peer::PeerInfo), + /// A peer answered our `FolderContentsRequest` with one folder's listing. + FolderContentsReceived { + token: u32, + folder: String, + directories: Vec, + }, /// A peer asked what we say about ourselves (they sent us code 15). UserInfoRequested, /// A peer asked for one folder of our shares (they sent us code 36). @@ -199,7 +208,9 @@ impl PeerActor { handlers.register_handler(TransferResponse); handlers.register_handler(GetShareFileList); handlers.register_handler(FolderContentsRequest); + handlers.register_handler(FolderContentsResponseHandler); handlers.register_handler(UserInfoRequest); + handlers.register_handler(UserInfoResponseHandler); handlers.register_handler(UploadDeniedHandler); handlers.register_handler(UploadFailedHandler); handlers.register_handler(PlaceInQueueRequest); @@ -310,6 +321,24 @@ impl PeerActor { directories, }); } + PeerMessage::UserInfoReceived(info) => { + self.forward(ClientOperation::PeerInfoReceived { + username: self.peer_username(), + info, + }); + } + PeerMessage::FolderContentsReceived { + token, + folder, + directories, + } => { + self.forward(ClientOperation::FolderContents { + username: self.peer_username(), + token, + folder, + directories, + }); + } PeerMessage::UserInfoRequested => { self.forward(ClientOperation::UserInfoRequested { requester_key: self.peer_username(), @@ -729,8 +758,20 @@ impl PeerActor { // unreachable (likely firewalled): signal a connect failure so the // client can fall back to server-brokered connect. Anything else is a // normal disconnect. + // A dial the server asked for carries the token the far peer quoted; + // it is what a CantConnectToPeer must echo back, so the peer stops + // waiting for a connection that is never coming. + let brokered_token = self + .peer + .read_safe() + .ok() + .and_then(|p| if p.brokered { p.token } else { None }); let op = if self.outbound && !self.established { - ClientOperation::PeerConnectFailed(self.id, username) + ClientOperation::PeerConnectFailed( + self.id, + username, + brokered_token, + ) } else { ClientOperation::PeerDisconnected( self.id, diff --git a/soulseek-rs-lib/src/actor/peer_actor/tests.rs b/soulseek-rs-lib/src/actor/peer_actor/tests.rs index de8d7c8..c7d8325 100644 --- a/soulseek-rs-lib/src/actor/peer_actor/tests.rs +++ b/soulseek-rs-lib/src/actor/peer_actor/tests.rs @@ -50,7 +50,7 @@ fn a_timed_out_connect_parks_the_actor_in_disconnected() { "a timed-out connect must leave Connecting" ); match rx.try_recv() { - Ok(ClientOperation::PeerConnectFailed(7, username)) => { + Ok(ClientOperation::PeerConnectFailed(7, username, _)) => { assert_eq!(username, "bob"); } other => panic!("expected PeerConnectFailed, got {other:?}"), diff --git a/soulseek-rs-lib/src/actor/peer_registry.rs b/soulseek-rs-lib/src/actor/peer_registry.rs index f4b7007..2c7c6f9 100644 --- a/soulseek-rs-lib/src/actor/peer_registry.rs +++ b/soulseek-rs-lib/src/actor/peer_registry.rs @@ -202,6 +202,24 @@ impl PeerRegistry { None } + /// Stop every peer actor and empty the registry, closing the connections + /// they hold. Used when the client shuts down. + pub fn stop_all(&self) { + let handles = match self.peers.lock_safe() { + Ok(mut peers) => peers + .drain() + .map(|(_, (_, handle))| handle) + .collect::>(), + Err(e) => { + error!("[peer_registry] stop_all: {}", e); + return; + } + }; + for handle in handles { + let _ = handle.stop(); + } + } + #[must_use] pub fn contains(&self, username: &str) -> bool { match self.peers.lock_safe() { @@ -427,7 +445,7 @@ mod tests { // Play the client ops loop: take the terminal outcome, evict by id. match rx.recv_timeout(std::time::Duration::from_secs(10)) { - Ok(ClientOperation::PeerConnectFailed(id, username)) => { + Ok(ClientOperation::PeerConnectFailed(id, username, _)) => { assert_eq!(username, "ghost"); if let Some(handle) = registry.remove_peer_if(&username, id) { let _ = handle.stop(); diff --git a/soulseek-rs-lib/src/actor/server_actor.rs b/soulseek-rs-lib/src/actor/server_actor.rs index ecab497..150de3e 100644 --- a/soulseek-rs-lib/src/actor/server_actor.rs +++ b/soulseek-rs-lib/src/actor/server_actor.rs @@ -2,12 +2,14 @@ use crate::actor::{Actor, ActorHandle, ConnectionState}; use crate::client::ClientOperation; use crate::dispatcher::MessageDispatcher; use crate::message::server::AdminMessageHandler; +use crate::message::server::CantConnectToPeerHandler; use crate::message::server::CheckPrivilegesHandler; use crate::message::server::ConnectToPeerHandler; use crate::message::server::EmbeddedMessageHandler; use crate::message::server::ExcludedSearchPhrasesHandler; use crate::message::server::FileSearchHandler; use crate::message::server::GetPeerAddressHandler; +use crate::message::server::GlobalRoomMessageHandler; use crate::message::server::JoinRoomHandler; use crate::message::server::LeaveRoomHandler; use crate::message::server::LoginHandler; @@ -24,16 +26,28 @@ use crate::message::server::UserJoinedRoomHandler; use crate::message::server::UserLeftRoomHandler; use crate::message::server::WatchUserHandler; use crate::message::server::WishListIntervalHandler; +use crate::message::server::{ + CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler, + RoomOperatorsHandler, RoomRosterChangeHandler, +}; use crate::message::server::{ GetUserStatsHandler, GetUserStatusHandler, RoomListHandler, }; +use crate::message::server::{ + GlobalRecommendationsHandler, ItemRecommendationsHandler, + ItemSimilarUsersHandler, RecommendationsHandler, SimilarUsersHandler, + UserInterestsHandler, +}; +use crate::message::server::{ + RoomTickerAddedHandler, RoomTickerRemovedHandler, RoomTickersHandler, +}; use crate::message::{Handlers, MessageType}; use crate::message::{Message, MessageReader}; use crate::peer::ConnectionType; use crate::peer::Peer; use crate::types::{ - ClientVersion, RoomEvent, RoomInfo, RoomUserStats, SessionLoss, - SessionWatch, + ClientVersion, Recommendation, RoomEvent, RoomInfo, RoomUserStats, + SessionLoss, SessionWatch, SimilarUser, UserInterests, }; use crate::utils::lock::RwLockExt; @@ -45,6 +59,7 @@ use std::time::{Duration, Instant}; use crate::{SoulseekRs, debug, error, trace, warn}; +mod handlers; mod types; pub use types::{Context, PeerAddress, UserMessage}; @@ -164,6 +179,96 @@ pub enum ServerMessage { PossibleParents(Vec<(String, String, u16)>), /// The server asks us to drop our parent and start over. ResetDistributed, + /// The ticker board of a room we just joined (code 113). + RoomTickers { + room: String, + tickers: Vec, + }, + /// One member set their ticker (code 114). + RoomTickerAdded { + room: String, + username: String, + ticker: String, + }, + /// One member cleared their ticker (code 115). + RoomTickerRemoved { + room: String, + username: String, + }, + /// A message from the global room feed (code 152). + GlobalRoomMessageReceived { + room: String, + username: String, + message: String, + }, + /// Recommendations from our own interests (code 54). + RecommendationsReceived { + recommended: Vec, + unrecommended: Vec, + }, + /// Server-wide recommendations (code 56). + GlobalRecommendationsReceived { + recommended: Vec, + unrecommended: Vec, + }, + /// Recommendations for one item (code 111). + ItemRecommendationsReceived { + item: String, + recommendations: Vec, + }, + /// Users similar to us (code 110). + SimilarUsersReceived { + users: Vec, + }, + /// Users who like one item (code 112). + ItemSimilarUsersReceived { + item: String, + usernames: Vec, + }, + /// What another user likes and hates (code 57). + UserInterestsReceived(UserInterests), + /// A peer could not connect to us after we asked the server to broker + /// (code 1001); the token is the one we quoted. + CantConnectToPeer { + token: u32, + }, + /// Phrases the server refuses to search for (code 160). + ExcludedSearchPhrases(Vec), + /// The upload speed a client needs before the server lets it carry + /// children (code 83). + ParentMinSpeed(u32), + /// The divisor turning that speed into a child count (code 84). + ParentSpeedRatio(u32), + /// Who may enter a private room (code 133). + PrivateRoomMembers { + room: String, + users: Vec, + }, + /// Who runs a private room (code 148). + PrivateRoomOperators { + room: String, + users: Vec, + }, + /// One user joined or left a private room's member or operator roster + /// (codes 134/135 and 143/144). + PrivateRoomRosterChanged { + room: String, + username: String, + /// True for the member roster, false for the operator roster. + members: bool, + added: bool, + }, + /// Our own membership (139/140) or operatorship (145/146) of a private + /// room was granted or revoked. + OwnRoomStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created (code 1003). + CantCreateRoom { + room: String, + }, } pub struct ServerActor { @@ -320,6 +425,28 @@ impl ServerActor { handlers.register_handler(FileSearchHandler); handlers.register_handler(GetPeerAddressHandler); handlers.register_handler(ConnectToPeerHandler); + handlers.register_handler(CantConnectToPeerHandler); + handlers.register_handler(RoomMembersHandler); + handlers.register_handler(RoomOperatorsHandler); + // Members added/removed (134/135), operators added/removed (143/144). + for code in [134, 135, 143, 144] { + handlers.register_handler(RoomRosterChangeHandler(code)); + } + // Our own membership (139/140) and operatorship (145/146). + for code in [139, 140, 145, 146] { + handlers.register_handler(OwnRoomStandingHandler(code)); + } + handlers.register_handler(CantCreateRoomHandler); + handlers.register_handler(RoomTickersHandler); + handlers.register_handler(RoomTickerAddedHandler); + handlers.register_handler(RoomTickerRemovedHandler); + handlers.register_handler(GlobalRoomMessageHandler); + handlers.register_handler(RecommendationsHandler); + handlers.register_handler(GlobalRecommendationsHandler); + handlers.register_handler(ItemRecommendationsHandler); + handlers.register_handler(SimilarUsersHandler); + handlers.register_handler(ItemSimilarUsersHandler); + handlers.register_handler(UserInterestsHandler); self.dispatcher = Some(MessageDispatcher::new( "server".into(), @@ -345,363 +472,6 @@ impl ServerActor { )); } - fn handle_message(&mut self, msg: ServerMessage) { - if !matches!(self.connection_state, ConnectionState::Connected) - && !matches!( - &msg, - ServerMessage::ProcessRead | ServerMessage::Login { .. } - ) - { - self.queued_messages.push(msg); - return; - } - - match msg { - ServerMessage::ConnectToPeer(peer) => { - self.handle_connect_to_peer(peer); - } - ServerMessage::LoginStatus(message) => { - self.handle_login_status(message); - } - ServerMessage::Relogged => self.handle_relogged(), - ServerMessage::PierceFirewall(token) => { - self.send_message( - MessageFactory::build_pierce_firewall_message(token), - ); - } - ServerMessage::SendMessage(message) => { - self.send_message(message); - } - ServerMessage::GetPeerAddress(username) => { - self.send_message(MessageFactory::build_get_peer_address( - &username, - )); - } - ServerMessage::GetPeerAddressResponse { - username, - host, - port, - obfuscation_type, - obfuscated_port, - } => { - self.handle_get_peer_address_response( - username, - host, - port, - obfuscation_type, - obfuscated_port, - ); - } - ServerMessage::PrivateMessageReceived(user_message) => { - self.handle_private_message_received(user_message); - } - ServerMessage::ProcessRead => { - self.process_read(); - } - ServerMessage::Login { - username, - password, - version, - response, - } => { - self.handle_login(username, password, version, response); - } - ServerMessage::FileSearch { token, query } => { - self.file_search(token, &query); - } - ServerMessage::FileSearchRequest { - username, - token, - query, - } => { - self.handle_file_search_request(username, token, query); - } - other => self.handle_social_message(other), - } - } - - /// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events - /// and user status or stats. Split out, like the standing traffic below, - /// only to keep `handle_message`'s match readable. - fn handle_social_message(&mut self, message: ServerMessage) { - match message { - ServerMessage::RoomListReceived(rooms) => { - self.forward_room_event(RoomEvent::List(rooms)); - } - ServerMessage::UserStatusReceived { - username, - status, - privileged, - } => { - self.forward_to_client(ClientOperation::UserStatusReceived { - username, - status, - privileged, - }); - } - ServerMessage::WatchedUserReceived { - username, - exists, - status, - average_speed, - shared_files, - shared_folders, - } => { - self.forward_to_client(ClientOperation::WatchedUserReceived { - username, - exists, - status, - average_speed, - shared_files, - shared_folders, - }); - } - ServerMessage::UserStatsReceived { - username, - average_speed, - shared_files, - shared_folders, - } => { - self.forward_to_client(ClientOperation::UserStatsReceived { - username, - average_speed, - shared_files, - shared_folders, - }); - } - ServerMessage::RoomJoined { room, users } => { - self.forward_room_event(RoomEvent::Joined { room, users }); - } - ServerMessage::RoomMemberStats { room, stats } => { - self.forward_to_client(ClientOperation::RoomMemberStats { - room, - stats, - }); - } - ServerMessage::RoomLeft { room } => { - self.forward_room_event(RoomEvent::Left { room }); - } - ServerMessage::RoomMessageReceived { - room, - username, - message, - } => { - self.forward_room_event(RoomEvent::Message { - room, - username, - message, - }); - } - ServerMessage::RoomUserJoined { room, username } => { - self.forward_room_event(RoomEvent::UserJoined { - room, - username, - }); - } - ServerMessage::RoomUserLeft { room, username } => { - self.forward_room_event(RoomEvent::UserLeft { room, username }); - } - other => self.handle_standing_message(other), - } - } - - /// The wishlist and privilege traffic: standing searches (codes 103/104) and - /// who is privileged (codes 69/92). - /// - /// Split out only because it keeps `handle_message`'s match to a readable - /// length; there is no behaviour here beyond dispatch. - fn handle_standing_message(&mut self, message: ServerMessage) { - match message { - ServerMessage::PossibleParents(candidates) => { - self.forward_to_client(ClientOperation::PossibleParents( - candidates, - )); - } - ServerMessage::ResetDistributed => { - self.forward_to_client(ClientOperation::ResetDistributed); - } - ServerMessage::WishlistSearch { token, query } => { - self.queue_message(MessageFactory::build_wishlist_search( - token, &query, - )); - } - ServerMessage::WishlistInterval(seconds) => { - self.forward_to_client(ClientOperation::WishlistInterval( - seconds, - )); - } - ServerMessage::PrivilegedUsers(users) => { - self.forward_to_client(ClientOperation::PrivilegedUsers(users)); - } - ServerMessage::OwnPrivileges(seconds) => { - self.forward_to_client(ClientOperation::OwnPrivileges(seconds)); - } - ServerMessage::CheckPrivileges => { - self.queue_message(MessageFactory::build_check_privileges()); - } - other => { - error!("[server] unroutable message: {:?}", other); - } - } - } - - fn handle_connect_to_peer(&self, peer: Peer) { - if let Some(op) = match peer.connection_type { - ConnectionType::P | ConnectionType::F => { - Some(ClientOperation::ConnectToPeer(peer)) - } - ConnectionType::D => None, - } && let Err(e) = self.client_channel.send(op) - { - error!("[server] failed to send ConnectToPeer: {}", e); - } - } - - fn handle_login_status(&mut self, message: bool) { - // Send the post-login handshake exactly once, only on success, - // on the live path (the old ServerActor::login did this but was - // never called). Advertises real shared counts and, when - // listening, the port peers must connect to. - if message { - for msg in post_login_messages( - self.enable_listen, - self.listen_port, - self.shared_folder_count, - self.shared_file_count, - ) { - self.send_message(msg); - } - self.session.clear(); - // The distributed stance is the leaf's to announce, and a new - // session starts without a parent. - self.forward_to_client(ClientOperation::ResetDistributed); - } - match self.context.write_safe() { - Ok(mut ctx) => ctx.logged_in = Some(message), - Err(e) => { - error!("[server] LoginStatus write: {}", e); - } - } - } - - fn handle_get_peer_address_response( - &self, - username: String, - host: String, - port: u32, - obfuscation_type: u32, - obfuscated_port: u16, - ) { - debug!( - "[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})", - username, host, port, obfuscation_type, obfuscated_port - ); - - if let Err(e) = - self.client_channel - .send(ClientOperation::GetPeerAddressResponse { - username, - host, - port, - obfuscation_type, - obfuscated_port, - }) - { - error!( - "[server] Error forwarding GetPeerAddress response to client: {}", - e - ); - } - } - - /// Hand an operation to the client loop, logging a dead channel rather - /// than unwinding the actor. - fn forward_to_client(&self, operation: ClientOperation) { - if let Err(e) = self.client_channel.send(operation) { - error!("[server] Error forwarding to client: {}", e); - } - } - - fn handle_private_message_received(&self, user_message: UserMessage) { - debug!("[server] Private message from {}", user_message.username()); - if let Err(e) = self - .client_channel - .send(ClientOperation::PrivateMessageReceived(user_message)) - { - error!( - "[server] Error forwarding private message to client: {}", - e - ); - } - } - - fn handle_login( - &mut self, - username: String, - password: String, - version: ClientVersion, - response: std::sync::mpsc::Sender>, - ) { - if self.stream.is_none() && !self.initiate_connection() { - let _ = response.send(Err(SoulseekRs::NotConnected)); - return; - } - if let Ok(mut ctx) = self.context.write_safe() { - ctx.logged_in = None; - } - self.queue_message(MessageFactory::build_login_message( - &username, &password, version, - )); - - let start = std::time::Instant::now(); - - let context = self.context.clone(); - std::thread::spawn(move || { - loop { - if start.elapsed() >= LOGIN_VERDICT_TIMEOUT { - let _ = response.send(Err(SoulseekRs::Timeout)); - break; - } - - let logged_in = match context.read_safe() { - Ok(ctx) => ctx.logged_in, - Err(e) => { - let _ = response.send(Err(e)); - break; - } - }; - if let Some(logged_in) = logged_in { - let result = if logged_in { - Ok(true) - } else { - Err(SoulseekRs::AuthenticationFailed) - }; - let _ = response.send(result); - break; - } - - std::thread::sleep(Duration::from_millis(100)); - } - }); - } - - fn handle_file_search_request( - &self, - username: String, - token: u32, - query: String, - ) { - if let Err(e) = - self.client_channel.send(ClientOperation::IncomingSearch { - username, - token, - query, - }) - { - error!("[server] forward IncomingSearch: {}", e); - } - } - fn process_read(&mut self) { if self.reader.buffer_len() > 0 { self.extract_and_process_messages(); diff --git a/soulseek-rs-lib/src/actor/server_actor/handlers.rs b/soulseek-rs-lib/src/actor/server_actor/handlers.rs new file mode 100644 index 0000000..072cb10 --- /dev/null +++ b/soulseek-rs-lib/src/actor/server_actor/handlers.rs @@ -0,0 +1,528 @@ +//! What the actor does with a message once it has one: the dispatch and the +//! per-message handlers behind it. +//! +//! A child module, so these keep reaching the actor's private state without +//! any of it being widened for the move. + +use super::{ + ClientOperation, ClientVersion, ConnectionState, ConnectionType, Duration, + LOGIN_VERDICT_TIMEOUT, MessageFactory, Peer, RoomEvent, RwLockExt, + ServerActor, ServerMessage, SoulseekRs, UserMessage, debug, error, + post_login_messages, +}; + +impl ServerActor { + pub(super) fn handle_message(&mut self, msg: ServerMessage) { + if !matches!(self.connection_state, ConnectionState::Connected) + && !matches!( + &msg, + ServerMessage::ProcessRead | ServerMessage::Login { .. } + ) + { + self.queued_messages.push(msg); + return; + } + + match msg { + ServerMessage::ConnectToPeer(peer) => { + self.handle_connect_to_peer(peer); + } + ServerMessage::LoginStatus(message) => { + self.handle_login_status(message); + } + ServerMessage::Relogged => self.handle_relogged(), + ServerMessage::PierceFirewall(token) => { + self.send_message( + MessageFactory::build_pierce_firewall_message(token), + ); + } + ServerMessage::SendMessage(message) => { + self.send_message(message); + } + ServerMessage::GetPeerAddress(username) => { + self.send_message(MessageFactory::build_get_peer_address( + &username, + )); + } + ServerMessage::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + } => { + self.handle_get_peer_address_response( + username, + host, + port, + obfuscation_type, + obfuscated_port, + ); + } + ServerMessage::PrivateMessageReceived(user_message) => { + self.handle_private_message_received(user_message); + } + ServerMessage::ProcessRead => { + self.process_read(); + } + ServerMessage::Login { + username, + password, + version, + response, + } => { + self.handle_login(username, password, version, response); + } + ServerMessage::FileSearch { token, query } => { + self.file_search(token, &query); + } + ServerMessage::FileSearchRequest { + username, + token, + query, + } => { + self.handle_file_search_request(username, token, query); + } + other => self.handle_social_message(other), + } + } + + /// Rooms and users (codes 7, 5, 36, 13-17, 64): forwarded as room events + /// and user status or stats. Split out, like the standing traffic below, + /// only to keep `handle_message`'s match readable. + fn handle_social_message(&mut self, message: ServerMessage) { + match message { + ServerMessage::RoomListReceived(rooms) => { + self.forward_room_event(RoomEvent::List(rooms)); + } + ServerMessage::UserStatusReceived { + username, + status, + privileged, + } => { + self.forward_to_client(ClientOperation::UserStatusReceived { + username, + status, + privileged, + }); + } + ServerMessage::WatchedUserReceived { + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + } => { + self.forward_to_client(ClientOperation::WatchedUserReceived { + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + }); + } + ServerMessage::UserStatsReceived { + username, + average_speed, + shared_files, + shared_folders, + } => { + self.forward_to_client(ClientOperation::UserStatsReceived { + username, + average_speed, + shared_files, + shared_folders, + }); + } + ServerMessage::RoomJoined { room, users } => { + self.forward_room_event(RoomEvent::Joined { room, users }); + } + ServerMessage::RoomMemberStats { room, stats } => { + self.forward_to_client(ClientOperation::RoomMemberStats { + room, + stats, + }); + } + ServerMessage::RoomLeft { room } => { + self.forward_room_event(RoomEvent::Left { room }); + } + ServerMessage::RoomMessageReceived { + room, + username, + message, + } => { + self.forward_room_event(RoomEvent::Message { + room, + username, + message, + }); + } + ServerMessage::RoomUserJoined { room, username } => { + self.forward_room_event(RoomEvent::UserJoined { + room, + username, + }); + } + ServerMessage::RoomUserLeft { room, username } => { + self.forward_room_event(RoomEvent::UserLeft { room, username }); + } + ServerMessage::RoomTickers { room, tickers } => { + self.forward_room_event(RoomEvent::Tickers { room, tickers }); + } + ServerMessage::RoomTickerAdded { + room, + username, + ticker, + } => { + self.forward_room_event(RoomEvent::TickerAdded { + room, + username, + ticker, + }); + } + ServerMessage::RoomTickerRemoved { room, username } => { + self.forward_room_event(RoomEvent::TickerRemoved { + room, + username, + }); + } + ServerMessage::GlobalRoomMessageReceived { + room, + username, + message, + } => { + self.forward_room_event(RoomEvent::GlobalMessage { + room, + username, + message, + }); + } + other => self.handle_standing_message(other), + } + } + + /// The wishlist and privilege traffic: standing searches (codes 103/104) and + /// who is privileged (codes 69/92). + /// + /// Split out only because it keeps `handle_message`'s match to a readable + /// length; there is no behaviour here beyond dispatch. + fn handle_standing_message(&mut self, message: ServerMessage) { + match message { + ServerMessage::PossibleParents(candidates) => { + self.forward_to_client(ClientOperation::PossibleParents( + candidates, + )); + } + ServerMessage::ResetDistributed => { + self.forward_to_client(ClientOperation::ResetDistributed); + } + ServerMessage::WishlistSearch { token, query } => { + self.queue_message(MessageFactory::build_wishlist_search( + token, &query, + )); + } + ServerMessage::WishlistInterval(seconds) => { + self.forward_to_client(ClientOperation::WishlistInterval( + seconds, + )); + } + ServerMessage::PrivilegedUsers(users) => { + self.forward_to_client(ClientOperation::PrivilegedUsers(users)); + } + ServerMessage::OwnPrivileges(seconds) => { + self.forward_to_client(ClientOperation::OwnPrivileges(seconds)); + } + ServerMessage::CheckPrivileges => { + self.queue_message(MessageFactory::build_check_privileges()); + } + message @ (ServerMessage::RecommendationsReceived { .. } + | ServerMessage::GlobalRecommendationsReceived { + .. + } + | ServerMessage::ItemRecommendationsReceived { + .. + } + | ServerMessage::SimilarUsersReceived { .. } + | ServerMessage::ItemSimilarUsersReceived { .. } + | ServerMessage::UserInterestsReceived(..)) => { + self.handle_taste_message(message); + } + ServerMessage::CantConnectToPeer { token } => { + self.forward_to_client(ClientOperation::CantConnectToPeer { + token, + }); + } + ServerMessage::PrivateRoomMembers { room, users } => { + self.forward_room_event(RoomEvent::PrivateMembers { + room, + users, + }); + } + ServerMessage::PrivateRoomOperators { room, users } => { + self.forward_room_event(RoomEvent::PrivateOperators { + room, + users, + }); + } + ServerMessage::PrivateRoomRosterChanged { + room, + username, + members, + added, + } => { + self.forward_room_event(RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + }); + } + ServerMessage::OwnRoomStandingChanged { + room, + members, + granted, + } => { + self.forward_room_event(RoomEvent::OwnStandingChanged { + room, + members, + granted, + }); + } + ServerMessage::CantCreateRoom { room } => { + self.forward_room_event(RoomEvent::CantCreate { room }); + } + ServerMessage::ParentMinSpeed(speed) => { + self.forward_to_client(ClientOperation::ParentMinSpeed(speed)); + } + ServerMessage::ParentSpeedRatio(ratio) => { + self.forward_to_client(ClientOperation::ParentSpeedRatio( + ratio, + )); + } + ServerMessage::ExcludedSearchPhrases(phrases) => { + self.forward_to_client(ClientOperation::ExcludedSearchPhrases( + phrases, + )); + } + other => { + error!("[server] unroutable message: {:?}", other); + } + } + } + + /// What the server says about taste: the recommendation and similar-user + /// answers, and a user's interests. Split off `handle_standing_message` + /// only to keep that dispatch readable โ€” these arms share nothing with + /// the rest of it. + fn handle_taste_message(&self, message: ServerMessage) { + match message { + ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: false, + recommended, + unrecommended, + }); + } + ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + } => { + self.forward_to_client(ClientOperation::Recommendations { + global: true, + recommended, + unrecommended, + }); + } + ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + } => { + self.forward_to_client(ClientOperation::ItemRecommendations { + item, + recommendations, + }); + } + ServerMessage::SimilarUsersReceived { users } => { + self.forward_to_client(ClientOperation::SimilarUsers(users)); + } + ServerMessage::ItemSimilarUsersReceived { item, usernames } => { + self.forward_to_client(ClientOperation::ItemSimilarUsers { + item, + usernames, + }); + } + ServerMessage::UserInterestsReceived(interests) => { + self.forward_to_client(ClientOperation::UserInterests( + interests, + )); + } + // `handle_standing_message` routes only the variants above here. + _ => {} + } + } + + fn handle_connect_to_peer(&self, peer: Peer) { + if let Some(op) = match peer.connection_type { + ConnectionType::P | ConnectionType::F => { + Some(ClientOperation::ConnectToPeer(peer)) + } + ConnectionType::D => None, + } && let Err(e) = self.client_channel.send(op) + { + error!("[server] failed to send ConnectToPeer: {}", e); + } + } + + pub(super) fn handle_login_status(&mut self, message: bool) { + // Send the post-login handshake exactly once, only on success, + // on the live path (the old ServerActor::login did this but was + // never called). Advertises real shared counts and, when + // listening, the port peers must connect to. + if message { + for msg in post_login_messages( + self.enable_listen, + self.listen_port, + self.shared_folder_count, + self.shared_file_count, + ) { + self.send_message(msg); + } + self.session.clear(); + // The distributed stance is the leaf's to announce, and a new + // session starts without a parent. + self.forward_to_client(ClientOperation::ResetDistributed); + // The server holds interests only for the life of a session. + self.forward_to_client(ClientOperation::SessionEstablished); + } + match self.context.write_safe() { + Ok(mut ctx) => ctx.logged_in = Some(message), + Err(e) => { + error!("[server] LoginStatus write: {}", e); + } + } + } + + fn handle_get_peer_address_response( + &self, + username: String, + host: String, + port: u32, + obfuscation_type: u32, + obfuscated_port: u16, + ) { + debug!( + "[server] Received GetPeerAddress response for {}: {}:{} (obf_type: {}, obf_port: {})", + username, host, port, obfuscation_type, obfuscated_port + ); + + if let Err(e) = + self.client_channel + .send(ClientOperation::GetPeerAddressResponse { + username, + host, + port, + obfuscation_type, + obfuscated_port, + }) + { + error!( + "[server] Error forwarding GetPeerAddress response to client: {}", + e + ); + } + } + + /// Hand an operation to the client loop, logging a dead channel rather + /// than unwinding the actor. + fn forward_to_client(&self, operation: ClientOperation) { + if let Err(e) = self.client_channel.send(operation) { + error!("[server] Error forwarding to client: {}", e); + } + } + + fn handle_private_message_received(&self, user_message: UserMessage) { + debug!("[server] Private message from {}", user_message.username()); + if let Err(e) = self + .client_channel + .send(ClientOperation::PrivateMessageReceived(user_message)) + { + error!( + "[server] Error forwarding private message to client: {}", + e + ); + } + } + + pub(super) fn handle_login( + &mut self, + username: String, + password: String, + version: ClientVersion, + response: std::sync::mpsc::Sender>, + ) { + if self.stream.is_none() && !self.initiate_connection() { + let _ = response.send(Err(SoulseekRs::NotConnected)); + return; + } + if let Ok(mut ctx) = self.context.write_safe() { + ctx.logged_in = None; + } + self.queue_message(MessageFactory::build_login_message( + &username, &password, version, + )); + + let start = std::time::Instant::now(); + + let context = self.context.clone(); + std::thread::spawn(move || { + loop { + if start.elapsed() >= LOGIN_VERDICT_TIMEOUT { + let _ = response.send(Err(SoulseekRs::Timeout)); + break; + } + + let logged_in = match context.read_safe() { + Ok(ctx) => ctx.logged_in, + Err(e) => { + let _ = response.send(Err(e)); + break; + } + }; + if let Some(logged_in) = logged_in { + let result = if logged_in { + Ok(true) + } else { + Err(SoulseekRs::AuthenticationFailed) + }; + let _ = response.send(result); + break; + } + + std::thread::sleep(Duration::from_millis(100)); + } + }); + } + + fn handle_file_search_request( + &self, + username: String, + token: u32, + query: String, + ) { + if let Err(e) = + self.client_channel.send(ClientOperation::IncomingSearch { + from_parent: false, + username, + token, + query, + }) + { + error!("[server] forward IncomingSearch: {}", e); + } + } +} diff --git a/soulseek-rs-lib/src/actor/server_actor/tests.rs b/soulseek-rs-lib/src/actor/server_actor/tests.rs index 8faba13..0809d99 100644 --- a/soulseek-rs-lib/src/actor/server_actor/tests.rs +++ b/soulseek-rs-lib/src/actor/server_actor/tests.rs @@ -210,6 +210,12 @@ fn a_successful_login_resets_the_distributed_leaf() { client.try_recv(), Ok(ClientOperation::ResetDistributed) )); + // A new session also has to be told what only the session held: our + // interests, which the server forgets when the old one ended. + assert!(matches!( + client.try_recv(), + Ok(ClientOperation::SessionEstablished) + )); actor.handle_login_status(false); assert!(client.try_recv().is_err(), "a failed login resets nothing"); } diff --git a/soulseek-rs-lib/src/client/children.rs b/soulseek-rs-lib/src/client/children.rs new file mode 100644 index 0000000..e60916d --- /dev/null +++ b/soulseek-rs-lib/src/client/children.rs @@ -0,0 +1,430 @@ +//! Serving children: the other half of the distributed search network. +//! +//! A leaf only reads the tree; a parent also carries it. When the client is +//! configured to accept children, peers dial us with a `D` connection, we tell +//! each one where our branch sits, and every search that reaches us is passed +//! down to all of them. That is the whole duty โ€” a child answers searches +//! itself, and reports its own place to the server. +//! +//! How many children to carry follows Nicotine+: the server announces the +//! upload speed a parent needs (`ParentMinSpeed`, code 83) and the divisor +//! turning speed into a child count (`ParentSpeedRatio`, code 84), and a +//! client whose recorded speed is below the minimum carries none. Until the +//! server has reported our own speed there is nothing to apply the formula to, +//! so the configured cap stands โ€” which is how slskd runs the whole time, with +//! an operator-set limit and no speed gate. +//! +//! Accepting children is opt-in ([`crate::ClientSettings::accept_children`]): +//! it costs a socket and the network's whole search stream per child, which is +//! not something a client should take on without being asked to. + +use std::collections::HashMap; +use std::io::Write; +use std::net::TcpStream; + +use crate::message::Message; +use crate::message::distributed; +use crate::{debug, trace}; + +/// How many children to carry at once. Nicotine+ caps its speed-derived limit +/// here too, for the same reason: each child is a socket and a copy of the +/// network's whole search stream. +pub const MAX_CHILDREN: usize = 10; + +/// The child limit the server's figures imply, following Nicotine+: +/// `speed / ratio / 100`, capped, and none at all when our recorded speed is +/// below the minimum the server set. +/// +/// `None` means the server's figures do not decide it โ€” either it has not +/// reported our speed yet, or it never announced a ratio at all (soulfind +/// does not) โ€” and the caller's own cap stands, which is how slskd runs the +/// whole time. +#[must_use] +pub fn limit_from_speed( + own_speed: Option, + min_speed: u32, + ratio: u32, +) -> Option { + if ratio == 0 { + return None; + } + let speed = own_speed?; + if speed < min_speed { + return Some(0); + } + Some((speed / ratio / 100) as usize) +} + +/// The children hanging from us, keyed by username. +pub struct Children { + links: HashMap, + /// The cap in force: [`MAX_CHILDREN`], lowered to what the server's + /// figures allow once it has reported our speed. + max: usize, + accepting: bool, + /// Whether anything is feeding us the search stream โ€” a parent of our own, + /// or the server. Nicotine+ refuses children without one, and rightly: + /// a child hanging from a branch that receives nothing gets nothing. + fed: bool, +} + +impl Default for Children { + fn default() -> Self { + Self::new(false) + } +} + +impl Children { + #[must_use] + pub fn new(accepting: bool) -> Self { + Self { + links: HashMap::new(), + max: MAX_CHILDREN, + accepting, + fed: false, + } + } + + /// Apply the server's figures: `own_speed` is what it records for us, and + /// the other two are what it announced in codes 83 and 84. + pub fn set_limits( + &mut self, + own_speed: Option, + min_speed: u32, + ratio: u32, + ) { + self.max = limit_from_speed(own_speed, min_speed, ratio) + .map_or(MAX_CHILDREN, |limit| limit.min(MAX_CHILDREN)); + debug!("[distributed] child limit is now {}", self.max); + } + + /// Say whether something is feeding us the search stream. A client with + /// nothing to relay takes no children, and drops any it has. + pub const fn set_fed(&mut self, fed: bool) { + self.fed = fed; + } + + #[must_use] + pub const fn is_fed(&self) -> bool { + self.fed + } + + /// Whether we would take another child right now: we serve children, we + /// have something to relay, and we are under the cap. This is what the + /// server is told in `AcceptChildren` (code 100). + #[must_use] + pub fn has_room(&self) -> bool { + self.accepting && self.fed && self.links.len() < self.max + } + + #[must_use] + pub fn len(&self) -> usize { + self.links.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.links.is_empty() + } + + #[must_use] + pub fn usernames(&self) -> Vec { + let mut names: Vec = self.links.keys().cloned().collect(); + names.sort(); + names + } + + /// Take on `username` as a child. Refused when we serve no children, have + /// nothing to relay, are full, or already carry that user. + pub fn accept(&mut self, username: &str, stream: TcpStream) -> bool { + if !self.has_room() { + return false; + } + // A second connection from a child we already carry is refused, as + // Nicotine+ does: the link we hold is the live one, and dropping it + // for an unproven second would cost that child its search stream. + if self.links.contains_key(username) { + debug!( + "[distributed] refusing child {}: already carried", + username + ); + return false; + } + self.links.insert(username.to_string(), stream); + true + } + + /// Drop every child, closing their links: what a client does when it can + /// no longer feed them. + pub fn drop_all(&mut self) { + if !self.links.is_empty() { + debug!("[distributed] dropping {} children", self.links.len()); + } + self.links.clear(); + } + + pub fn remove(&mut self, username: &str) { + if self.links.remove(username).is_some() { + debug!("[distributed] child {} is gone", username); + } + } + + /// Send one frame to `username`, dropping the child if the write fails. + pub fn send_to(&mut self, username: &str, message: &Message) { + let failed = match self.links.get_mut(username) { + Some(stream) => stream.write_all(&message.get_buffer()).is_err(), + None => return, + }; + if failed { + self.remove(username); + } + } + + /// Send one frame to every child, dropping those whose write fails: a + /// child that has gone away must not keep its slot. + pub fn broadcast(&mut self, message: &Message) { + let bytes = message.get_buffer(); + let mut dead = Vec::new(); + for (username, stream) in &mut self.links { + if stream.write_all(&bytes).is_err() { + dead.push(username.clone()); + } + } + for username in dead { + self.remove(&username); + } + } + + /// Tell `username` where our branch sits โ€” what a child needs before it + /// can report its own place to the server. + pub fn send_stance_to(&mut self, username: &str, root: &str, level: u32) { + trace!( + "[distributed] telling {} we sit at {}/{}", + username, root, level + ); + self.send_to(username, &distributed::build_branch_root(root)); + self.send_to( + username, + &distributed::build_branch_level( + i32::try_from(level).unwrap_or(i32::MAX), + ), + ); + } + + /// Tell every child our branch moved. + pub fn broadcast_stance(&mut self, root: &str, level: u32) { + self.broadcast(&distributed::build_branch_root(root)); + self.broadcast(&distributed::build_branch_level( + i32::try_from(level).unwrap_or(i32::MAX), + )); + } + + /// Pass a search down the tree. + pub fn broadcast_search( + &mut self, + username: &str, + token: u32, + query: &str, + ) { + if self.is_empty() { + return; + } + self.broadcast(&distributed::build_search(username, token, query)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + use std::net::{TcpListener, TcpStream}; + + /// A connected socket pair: the child's end and ours. + fn pair() -> (TcpStream, TcpStream) { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + let ours = TcpStream::connect(addr).expect("connect"); + let (theirs, _) = listener.accept().expect("accept"); + (theirs, ours) + } + + /// A registry that serves children and has something to relay. + fn serving() -> Children { + let mut children = Children::new(true); + children.set_fed(true); + children + } + + fn read_frames(stream: &mut TcpStream, bytes: usize) -> Vec { + stream + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .expect("timeout"); + let mut buf = vec![0u8; bytes]; + stream.read_exact(&mut buf).expect("read"); + buf + } + + #[test] + fn a_client_that_does_not_accept_children_takes_none() { + let (_child, ours) = pair(); + let mut children = Children::new(false); + children.set_fed(true); + assert!(!children.accept("alice", ours)); + assert!(children.is_empty()); + assert!(!children.has_room()); + } + + #[test] + fn a_client_nothing_feeds_takes_no_children() { + // Nicotine+ refuses children while it has neither a parent nor the + // server relaying searches: a child would hang from a branch that + // receives nothing. + let (_child, ours) = pair(); + let mut children = Children::new(true); + assert!(!children.has_room(), "unfed means no room"); + assert!(!children.accept("alice", ours)); + + children.set_fed(true); + let (_second, again) = pair(); + assert!(children.accept("alice", again)); + } + + #[test] + fn children_are_let_go_when_the_stream_dries_up() { + let mut children = serving(); + let (_child, ours) = pair(); + children.accept("alice", ours); + + children.set_fed(false); + children.drop_all(); + assert!(children.is_empty()); + assert!(!children.has_room()); + } + + #[test] + fn children_are_taken_up_to_the_cap() { + let mut children = serving(); + let mut ends = Vec::new(); + for i in 0..MAX_CHILDREN { + let (child, ours) = pair(); + ends.push(child); + assert!(children.accept(&format!("user{i}"), ours)); + } + assert_eq!(ends.len(), MAX_CHILDREN, "every link is still held"); + assert!(!children.has_room(), "a full parent has no room"); + + let (_late, ours) = pair(); + assert!(!children.accept("one_too_many", ours)); + assert_eq!(children.len(), MAX_CHILDREN); + } + + #[test] + fn a_second_link_from_a_child_we_carry_is_refused() { + // The link we hold is the live one; dropping it for an unproven + // second would cost that child its search stream. + let mut children = serving(); + let (_first, ours) = pair(); + assert!(children.accept("alice", ours)); + let (_second, again) = pair(); + assert!(!children.accept("alice", again)); + assert_eq!(children.usernames(), vec!["alice".to_string()]); + } + + #[test] + fn the_child_limit_follows_the_servers_figures() { + // Nicotine+: speed / ratio / 100, capped, and none at all below the + // minimum speed the server set. + assert_eq!(limit_from_speed(Some(50_000), 1_024, 50), Some(10)); + assert_eq!(limit_from_speed(Some(10_000), 1_024, 50), Some(2)); + assert_eq!( + limit_from_speed(Some(500), 1_024, 50), + Some(0), + "too slow to parent" + ); + assert_eq!( + limit_from_speed(Some(10_000), 1_024, 0), + None, + "a server that never announced a ratio does not decide the limit" + ); + assert_eq!( + limit_from_speed(None, 1_024, 50), + None, + "an unreported speed leaves the caller's own cap standing" + ); + } + + #[test] + fn a_speed_the_server_has_not_reported_leaves_the_cap_alone() { + let mut children = serving(); + children.set_limits(None, 1_024, 50); + assert!(children.has_room(), "an unreported speed leaves us open"); + + // The server's figures win: at this speed the formula allows two. + children.set_limits(Some(10_000), 1_024, 50); + let mut ends = Vec::new(); + for i in 0..2 { + let (child, ours) = pair(); + ends.push(child); + assert!(children.accept(&format!("user{i}"), ours)); + } + assert!(!children.has_room(), "two is the limit at this speed"); + + children.set_limits(Some(100), 1_024, 50); + assert!(!children.has_room(), "a slow client carries none"); + + children.set_limits(Some(100), 1_024, 0); + assert!( + children.has_room(), + "a server with no figures leaves our own cap standing" + ); + assert_eq!(ends.len(), 2); + } + + #[test] + fn a_search_reaches_every_child() { + let mut children = serving(); + let (mut first, ours) = pair(); + let (mut second, theirs) = pair(); + children.accept("alice", ours); + children.accept("bob", theirs); + + children.broadcast_search("seeker", 42, "jazz"); + + let expected = + distributed::build_search("seeker", 42, "jazz").get_buffer(); + assert_eq!(read_frames(&mut first, expected.len()), expected); + assert_eq!(read_frames(&mut second, expected.len()), expected); + } + + #[test] + fn a_child_that_hung_up_loses_its_slot() { + let mut children = serving(); + let (child, ours) = pair(); + children.accept("alice", ours); + drop(child); + + // Writes keep landing in the send buffer until the peer's RST has + // crossed the loopback, which under load takes real wall-clock time. + for _ in 0..100 { + children.broadcast_search("seeker", 1, "q"); + if children.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(children.is_empty(), "a dead child is dropped"); + } + + #[test] + fn a_child_is_told_where_our_branch_sits() { + let mut children = serving(); + let (mut child, ours) = pair(); + children.accept("alice", ours); + + children.send_stance_to("alice", "rooty", 3); + + let mut expected = distributed::build_branch_root("rooty").get_buffer(); + expected.extend(distributed::build_branch_level(3).get_buffer()); + assert_eq!(read_frames(&mut child, expected.len()), expected); + } +} diff --git a/soulseek-rs-lib/src/client/connection.rs b/soulseek-rs-lib/src/client/connection.rs index e6d0436..01bffb7 100644 --- a/soulseek-rs-lib/src/client/connection.rs +++ b/soulseek-rs-lib/src/client/connection.rs @@ -1,9 +1,10 @@ use super::{ - Arc, Client, ClientContext, ClientOperation, ConnectionType, DownloadPeer, - DownloadStatus, Listen, Peer, PeerRegistry, Receiver, Result, RwLock, + Arc, AtomicBool, Client, ClientContext, ClientOperation, ConnectionType, + DownloadPeer, Listen, Peer, PeerRegistry, Receiver, Result, RwLock, RwLockExt, Sender, ServerActor, ServerMessage, Shares, SoulseekRs, TcpStream, error, info, mpsc, thread, trace, warn, }; +use std::sync::atomic::Ordering; /// Ceiling on the wait for a login verdict. Generous enough for a slow server /// and a retrying connect, short enough that an unattended caller ends. @@ -96,6 +97,7 @@ impl Client { } else { None }; + self.listener_stopped = Arc::new(AtomicBool::new(false)); let mut server_actor = ServerActor::new( self.address.clone(), @@ -118,9 +120,16 @@ impl Client { let client_sender = listen_sender; let context = self.context.clone(); let own_username = self.username.clone(); + let stopped = self.listener_stopped.clone(); thread::spawn(move || { - Listen::serve(&listener, client_sender, context, own_username); + Listen::serve( + &listener, + client_sender, + context, + own_username, + stopped, + ); }); } @@ -133,6 +142,42 @@ impl Client { Ok(()) } + /// Close the server connection and every peer connection. + /// + /// The server sees the socket close and marks us offline, which is what + /// releases the username: a client that simply goes out of scope without + /// this leaves a session the server still believes in, so mail addressed + /// to us is delivered to a connection nobody is reading and later logins + /// collide with our own ghost. + /// + /// Called automatically when the client is dropped. The peer listener's + /// bound port, if one was opened, is released with the process. + pub fn disconnect(&mut self) { + if let Some(handle) = self.server_handle.take() { + let _ = handle.stop(); + } + let registry = match self.context.write_safe() { + Ok(mut ctx) => { + // Dropping our end of the operations channel is what lets its + // thread finish once the peers and the listener have gone. + ctx.operations = None; + ctx.peer_registry.clone() + } + Err(_) => None, + }; + if let Some(registry) = registry { + registry.stop_all(); + } + // The listener thread is parked in accept(), so it only notices the + // flag when a connection arrives: dial it once ourselves. Without + // this the port stays bound for the life of the process, and the next + // client falls back to an ephemeral port nobody was told about. + self.listener_stopped.store(true, Ordering::Relaxed); + if let Some(port) = self.bound_port.take() { + let _ = TcpStream::connect(("127.0.0.1", port)); + } + } + /// Log in and wait for the server's verdict. /// /// The wait is bounded: the actor only answers once it has a working @@ -240,6 +285,25 @@ impl Client { registered } + /// Tell the server we could not reach the peer it asked us to connect to, + /// quoting the token that peer is waiting on. + pub(crate) fn report_cant_connect( + client_context: &Arc>, + token: u32, + username: &str, + ) { + let sender = match client_context.read_safe() { + Ok(ctx) => ctx.server_sender.clone(), + Err(_) => return, + }; + let Some(sender) = sender else { return }; + let message = + crate::message::server::MessageFactory::build_cant_connect_to_peer( + token, username, + ); + let _ = sender.send(ServerMessage::SendMessage(message)); + } + pub(crate) fn connect_to_peer( peer: Peer, client_context: Arc>, @@ -282,6 +346,8 @@ impl Client { own_username, ); + let brokered = peer_clone.brokered; + let username = peer_clone.username; match download_peer.download_file( client_context.clone(), None, @@ -292,25 +358,35 @@ impl Client { "[client] downloaded {} bytes {:?} ", filename, download.size ); - let _ = download.sender.send(DownloadStatus::Completed); - match client_context.write_safe() { - Ok(mut ctx) => ctx.update_download_with_status( - download.token, - DownloadStatus::Completed, - ), - Err(e) => error!( - "[client] connect_to_peer F write: {}", - e - ), - } } Err(e) => { trace!("[client] failed to download: {}", e); + // A file connection the server asked us to make and + // we could not complete owes that peer a + // CantConnectToPeer, exactly as a control connection + // does: it is waiting on this token. + if brokered { + Self::report_cant_connect( + &client_context, + token, + &username, + ); + } } } } ConnectionType::D => { - error!("ConnectionType::D not implemented"); + // ponytail: we never dial out to adopt a child โ€” children + // reach us through the listener. Tell the peer rather than + // leave it waiting; wire the outbound half if firewalled + // children turn out to matter. + if let (true, Some(token)) = (peer.brokered, peer.token) { + Self::report_cant_connect( + &client_context, + token, + &peer.username, + ); + } } } } diff --git a/soulseek-rs-lib/src/client/context.rs b/soulseek-rs-lib/src/client/context.rs new file mode 100644 index 0000000..b51a58a --- /dev/null +++ b/soulseek-rs-lib/src/client/context.rs @@ -0,0 +1,761 @@ +//! What the client's shared context does, as opposed to what it holds. +//! +//! A child module rather than a sibling: the state itself stays declared in +//! `client`, so these methods keep reaching its private fields without any +//! of them having to be widened for the move. + +use super::{ + ActorSystem, Arc, AtomicUsize, BROKER_CONNECT_TIMEOUT, + BROWSE_PROTECT_WINDOW, ClientContext, DEFAULT_MAX_PEERS, + DEFAULT_UPLOAD_SLOTS, DownloadStatus, DownloadStore, HashMap, HashSet, + Instant, PENDING_PEER_TTL, Recommendation, RoomEvent, RoomInfo, RoomTicker, + RoomUserStats, SharedDirectory, Shares, SimilarUser, UserInfo, + UserInterests, UserMessage, UserPresence, UserStats, UserStatus, children, + distributed, sorted_unique, +}; + +impl ClientContext { + /// A context for the client that logs in as `username`. + #[must_use] + pub fn for_user(username: &str) -> Self { + Self { + leaf: distributed::Leaf::new(username), + ..Self::new() + } + } + + #[must_use] + pub fn new() -> Self { + let actor_system = Arc::new(ActorSystem::new()); + + Self { + peer_registry: None, + server_sender: None, + operations: None, + leaf: distributed::Leaf::new(""), + children: children::Children::default(), + announced_accept_children: None, + own_average_speed: None, + parent_min_speed: 0, + parent_speed_ratio: 0, + searches: HashMap::new(), + private_messages: Vec::new(), + pending_connect_tokens: HashMap::new(), + max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)), + shares: Arc::new(Shares::empty()), + shared_directories: Vec::new(), + peer_addresses: HashMap::new(), + pending_peer_messages: HashMap::new(), + uploads: HashMap::new(), + active_uploads: HashMap::new(), + pending_serves: HashMap::new(), + browse_results: HashMap::new(), + folder_contents: HashMap::new(), + peer_infos: HashMap::new(), + pending_browses: HashMap::new(), + room_list: Vec::new(), + room_events: Vec::new(), + room_members: HashMap::new(), + room_member_stats: HashMap::new(), + room_tickers: HashMap::new(), + private_room_members: HashMap::new(), + private_room_operators: HashMap::new(), + recommendations: None, + global_recommendations: None, + item_recommendations: HashMap::new(), + similar_users: Vec::new(), + item_similar_users: HashMap::new(), + user_interests: HashMap::new(), + own_interests: UserInterests::default(), + user_info: HashMap::new(), + watched_users: HashSet::new(), + wishlist_interval: None, + privileged_users: HashSet::new(), + own_privileges: None, + excluded_search_phrases: Vec::new(), + upload_queue: Vec::new(), + upload_seq: 0, + upload_slots: DEFAULT_UPLOAD_SLOTS, + last_upload_speed: 0, + upload_events: Vec::new(), + downloads: DownloadStore::new(), + actor_system, + } + } + + /// Apply a chat-room event: keep the room-list snapshot and the per-room + /// rosters current, then queue the event for the client/UI to drain. + pub fn apply_room_event(&mut self, event: RoomEvent) { + match &event { + RoomEvent::List(rooms) => self.room_list.clone_from(rooms), + RoomEvent::Joined { room, users } => { + let mut members = users.clone(); + members.sort(); + members.dedup(); + self.room_members.insert(room.clone(), members); + } + RoomEvent::Left { room } => { + self.room_members.remove(room); + // A board for a room we are not in is stale; the server sends + // the whole board again on the next join. + self.room_tickers.remove(room); + } + RoomEvent::UserJoined { room, username } => { + let members = + self.room_members.entry(room.clone()).or_default(); + if let Err(at) = members.binary_search(username) { + members.insert(at, username.clone()); + } + } + RoomEvent::UserLeft { room, username } => { + if let Some(members) = self.room_members.get_mut(room) + && let Ok(at) = members.binary_search(username) + { + members.remove(at); + } + } + RoomEvent::Tickers { room, tickers } => { + self.room_tickers.insert(room.clone(), tickers.clone()); + } + RoomEvent::TickerAdded { + room, + username, + ticker, + } => { + // A user has at most one ticker: an added one replaces theirs + // rather than stacking, which is how the server treats it. + let board = self.room_tickers.entry(room.clone()).or_default(); + board.retain(|t| &t.username != username); + board.push(RoomTicker { + username: username.clone(), + ticker: ticker.clone(), + }); + } + RoomEvent::TickerRemoved { room, username } => { + if let Some(board) = self.room_tickers.get_mut(room) { + board.retain(|t| &t.username != username); + } + } + RoomEvent::PrivateMembers { room, users } => { + self.private_room_members + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateOperators { room, users } => { + self.private_room_operators + .insert(room.clone(), sorted_unique(users)); + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => { + let roster = if *members { + self.private_room_members.entry(room.clone()).or_default() + } else { + self.private_room_operators.entry(room.clone()).or_default() + }; + match (added, roster.binary_search(username)) { + (true, Err(at)) => roster.insert(at, username.clone()), + (false, Ok(at)) => { + roster.remove(at); + } + _ => {} + } + } + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => { + // Losing membership takes both rosters with it: a room we + // cannot enter has none worth showing. Losing operatorship + // leaves us a member who still sees them, and the server + // narrates that roster change separately (code 144). + if !granted && *members { + self.private_room_members.remove(room); + self.private_room_operators.remove(room); + } + } + RoomEvent::Message { .. } + | RoomEvent::GlobalMessage { .. } + | RoomEvent::CantCreate { .. } => {} + } + self.room_events.push(event); + } + + /// Remember an interest of our own so it can be sent again next login. + /// Interests are matched by the server case-insensitively, and Nicotine+ + /// lowercases them before sending; do the same so two spellings of one + /// interest cannot both be held. + /// Returns the stored spelling, or `None` for an empty item โ€” which is + /// no interest, and must not be sent to the server either. + pub fn add_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { + let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + if !list.contains(&item) { + list.push(item.clone()); + } + Some(item) + } + + /// Forget one of our own interests. `None` for an empty item, as above. + pub fn remove_own_interest( + &mut self, + item: &str, + liked: bool, + ) -> Option { + let item = item.trim().to_lowercase(); + if item.is_empty() { + return None; + } + let list = if liked { + &mut self.own_interests.likes + } else { + &mut self.own_interests.hates + }; + list.retain(|held| held != &item); + Some(item) + } + + /// What we like and hate, as last set. + #[must_use] + pub fn own_interests(&self) -> UserInterests { + self.own_interests.clone() + } + + /// Take the `AcceptChildren` answer to send, or `None` when the server + /// already has this one. + pub fn accept_children_change(&mut self) -> Option { + let has_room = self.children.has_room(); + (self.announced_accept_children != Some(has_room)).then(|| { + self.announced_accept_children = Some(has_room); + has_room + }) + } + + /// The upload speed the server records for us, if it has said. + #[must_use] + pub const fn own_average_speed(&self) -> Option { + self.own_average_speed + } + + /// Record what the server says our own upload speed is, and re-derive the + /// child limit from it. + pub fn set_own_average_speed(&mut self, speed: u32) { + self.own_average_speed = Some(speed); + self.apply_child_limits(); + } + + /// Record the server's parent figures (codes 83 and 84). + pub fn set_parent_min_speed(&mut self, speed: u32) { + self.parent_min_speed = speed; + self.apply_child_limits(); + } + + pub fn set_parent_speed_ratio(&mut self, ratio: u32) { + self.parent_speed_ratio = ratio; + self.apply_child_limits(); + } + + fn apply_child_limits(&mut self) { + self.children.set_limits( + self.own_average_speed, + self.parent_min_speed, + self.parent_speed_ratio, + ); + } + + /// Whether something is feeding us the distributed search stream: a + /// parent of our own, or the server relaying searches to us. Children may + /// only hang from a client that has one. + pub fn set_fed_by(&mut self, fed: bool) { + self.children.set_fed(fed); + if !fed { + self.children.drop_all(); + } + } + + /// Record what `username` says about itself. + pub fn store_peer_info( + &mut self, + username: String, + info: crate::message::peer::PeerInfo, + ) { + self.peer_infos.insert(username, info); + } + + /// What `username` last said about itself, if anything. + #[must_use] + pub fn peer_info( + &self, + username: &str, + ) -> Option { + self.peer_infos.get(username).cloned() + } + + /// Drop what we hold about `username`, so a poll after a fresh request + /// cannot report the previous answer. + pub fn invalidate_peer_info(&mut self, username: &str) { + self.peer_infos.remove(username); + } + + /// Record one folder's listing from `username`. + pub fn store_folder_contents( + &mut self, + username: String, + folder: String, + directories: Vec, + ) { + self.folder_contents.insert((username, folder), directories); + } + + /// Remove and return the listing of `folder` from `username`, if it has + /// arrived. + pub fn take_folder_contents( + &mut self, + username: &str, + folder: &str, + ) -> Option> { + self.folder_contents + .remove(&(username.to_string(), folder.to_string())) + } + + /// Who may enter the private room `room`, as last reported. + #[must_use] + pub fn private_room_members(&self, room: &str) -> Vec { + self.private_room_members + .get(room) + .cloned() + .unwrap_or_default() + } + + /// Who runs the private room `room`, as last reported. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + self.private_room_operators + .get(room) + .cloned() + .unwrap_or_default() + } + + /// The private rooms we belong to, in a stable order. + #[must_use] + pub fn private_rooms(&self) -> Vec { + let mut rooms: Vec = + self.private_room_members.keys().cloned().collect(); + rooms.sort(); + rooms + } + + /// The ticker board of `room` as last reported by the server. + #[must_use] + pub fn room_tickers(&self, room: &str) -> Vec { + self.room_tickers.get(room).cloned().unwrap_or_default() + } + + /// Record a recommendations reply; `global` selects the server-wide set. + pub fn apply_recommendations( + &mut self, + global: bool, + recommended: Vec, + unrecommended: Vec, + ) { + let slot = if global { + &mut self.global_recommendations + } else { + &mut self.recommendations + }; + *slot = Some((recommended, unrecommended)); + } + + /// The last recommendations reply, or `None` until one arrives. + #[must_use] + pub fn recommendations( + &self, + global: bool, + ) -> Option<(Vec, Vec)> { + if global { + self.global_recommendations.clone() + } else { + self.recommendations.clone() + } + } + + pub fn apply_item_recommendations( + &mut self, + item: String, + recommendations: Vec, + ) { + self.item_recommendations.insert(item, recommendations); + } + + #[must_use] + pub fn item_recommendations(&self, item: &str) -> Vec { + self.item_recommendations + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_similar_users(&mut self, users: Vec) { + self.similar_users = users; + } + + #[must_use] + pub fn similar_users(&self) -> Vec { + self.similar_users.clone() + } + + pub fn apply_item_similar_users( + &mut self, + item: String, + usernames: Vec, + ) { + self.item_similar_users.insert(item, usernames); + } + + #[must_use] + pub fn item_similar_users(&self, item: &str) -> Vec { + self.item_similar_users + .get(item) + .cloned() + .unwrap_or_default() + } + + pub fn apply_user_interests(&mut self, interests: UserInterests) { + self.user_interests + .insert(interests.username.clone(), interests); + } + + #[must_use] + pub fn user_interests(&self, username: &str) -> Option { + self.user_interests.get(username).cloned() + } + + /// Record the phrases the server excludes (code 160), lowercased here so + /// the per-file check is a plain `contains` on a lowercased path. + pub fn set_excluded_search_phrases(&mut self, phrases: Vec) { + self.excluded_search_phrases = + phrases.iter().map(|p| p.to_lowercase()).collect(); + } + + /// The phrases the server refuses to search for. + #[must_use] + pub fn excluded_search_phrases(&self) -> Vec { + self.excluded_search_phrases.clone() + } + + /// Forget any interests cached for `username`, so a poll after a fresh + /// request cannot mistake the previous answer for this one. + pub fn invalidate_user_interests(&mut self, username: &str) { + self.user_interests.remove(username); + } + + /// Record a `GetUserStatus` reply, merging it with any statistics already + /// received for that user. + pub fn apply_user_status( + &mut self, + username: String, + status: u32, + privileged: bool, + ) { + self.user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username)) + .presence = Some(UserPresence { + status: UserStatus::from_code(status), + privileged, + }); + } + + /// Forget what we know about `username`, so the next poll reports the + /// answer to the request being made now rather than the previous one. + pub fn invalidate_user_info(&mut self, username: &str) { + self.user_info.remove(username); + } + + /// Record a `GetUserStats` reply, merging it with any status already + /// received for that user. + pub fn apply_user_stats( + &mut self, + username: String, + average_speed: u32, + shared_files: u32, + shared_folders: u32, + ) { + self.user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username)) + .stats = Some(UserStats { + average_speed, + shared_files, + shared_folders, + }); + } + + /// Record that we are now watching `username`. + pub fn add_watched_user(&mut self, username: &str) { + self.watched_users.insert(username.to_string()); + } + + /// Forget a watch, dropping any snapshot we held for that user so a later + /// re-watch reports a fresh answer rather than a stale one. + pub fn remove_watched_user(&mut self, username: &str) { + self.watched_users.remove(username); + self.user_info.remove(username); + } + + /// Everyone we are currently watching, sorted by name. + #[must_use] + pub fn watched_users(&self) -> Vec { + let mut users: Vec = + self.watched_users.iter().cloned().collect(); + users.sort(); + users + } + + /// Record the initial snapshot from a `WatchUser` reply. A username the + /// server does not know carries no stats and is dropped from the watch + /// list, since the server will never push anything for it. + pub fn apply_watched_user( + &mut self, + username: String, + exists: bool, + status: Option, + average_speed: Option, + shared_files: Option, + shared_folders: Option, + ) { + if !exists { + self.watched_users.remove(&username); + return; + } + if let Some(status) = status { + let entry = self + .user_info + .entry(username.clone()) + .or_insert_with(|| UserInfo::pending(username.clone())); + // The watch reply carries no privileged flag, so keep whatever a + // previous GetUserStatus told us rather than asserting `false`. + let privileged = + entry.presence.as_ref().is_some_and(|p| p.privileged); + entry.presence = Some(UserPresence { + status: UserStatus::from_code(status), + privileged, + }); + } + if let (Some(average_speed), Some(shared_files), Some(shared_folders)) = + (average_speed, shared_files, shared_folders) + { + self.apply_user_stats( + username, + average_speed, + shared_files, + shared_folders, + ); + } + } + + /// What the server has said about `username` so far. + #[must_use] + pub fn user_info(&self, username: &str) -> Option { + self.user_info.get(username).cloned() + } + + /// Who is currently in `room`, sorted, or empty when we are not in it. + #[must_use] + pub fn room_members(&self, room: &str) -> Vec { + self.room_members.get(room).cloned().unwrap_or_default() + } + + /// Record the per-member statistics of a room we just joined, replacing + /// any previous snapshot for it. + pub fn apply_room_member_stats( + &mut self, + room: String, + stats: Vec, + ) { + self.room_member_stats.insert(room, stats); + } + + /// What the server reported about the members of `room`, sorted by name, + /// or empty for a room we have not joined. + #[must_use] + pub fn room_member_stats(&self, room: &str) -> Vec { + let mut stats = self + .room_member_stats + .get(room) + .cloned() + .unwrap_or_default(); + stats.sort_by(|a, b| a.username.cmp(&b.username)); + stats + } + + /// The latest snapshot of the public chat-room list. + #[must_use] + pub fn room_list(&self) -> Vec { + self.room_list.clone() + } + + /// Remove and return all chat-room events received since the last call. + #[must_use] + pub fn take_room_events(&mut self) -> Vec { + std::mem::take(&mut self.room_events) + } + + /// Cache a peer's listen address learned from a GetPeerAddress response. + pub fn cache_peer_address( + &mut self, + username: &str, + host: String, + port: u32, + ) { + self.peer_addresses + .insert(username.to_string(), (host, port)); + } + + /// The cached listen address for `username`, if known. + #[must_use] + pub fn peer_address(&self, username: &str) -> Option<(String, u32)> { + self.peer_addresses.get(username).cloned() + } + + /// Queue a peer message to send once a control connection to `username` is up. + pub fn queue_peer_message( + &mut self, + username: &str, + message: crate::message::Message, + ) { + let entry = self + .pending_peer_messages + .entry(username.to_string()) + .or_insert_with(|| (Instant::now(), Vec::new())); + entry.0 = Instant::now(); + entry.1.push(message); + } + + /// Remove and return the messages queued for `username`. + pub fn take_peer_messages( + &mut self, + username: &str, + ) -> Vec { + self.pending_peer_messages + .remove(username) + .map(|(_, messages)| messages) + .unwrap_or_default() + } + + /// Drop peer messages nobody could deliver within + /// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed. + pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) { + self.pending_peer_messages.retain(|_, (since, _)| { + now.duration_since(*since) < PENDING_PEER_TTL + }); + } + + /// Store a shared-file listing received from browsing `username`. + pub fn store_browse_result( + &mut self, + username: String, + directories: Vec, + ) { + self.pending_browses.remove(&username); + self.browse_results.insert(username, directories); + } + + pub(crate) fn mark_browse_pending(&mut self, username: &str) { + let now = Instant::now(); + self.pending_browses.retain(|_, deadline| *deadline > now); + self.pending_browses + .insert(username.to_string(), now + BROWSE_PROTECT_WINDOW); + } + + /// Remove and return the shared-file listing browsed from `username`. + pub fn take_browse_result( + &mut self, + username: &str, + ) -> Option> { + self.browse_results.remove(username) + } + + /// Remember that a server-brokered connection to `username` is pending under + /// `token`; the peer will quote it back in a PierceFirewall. + pub fn add_pending_connect(&mut self, token: u32, username: String) { + self.pending_connect_tokens + .insert(token, (username, Instant::now() + BROKER_CONNECT_TIMEOUT)); + } + + /// Resolve and consume the peer expected for a brokered connection `token`. + pub fn take_pending_connect(&mut self, token: u32) -> Option { + self.pending_connect_tokens + .remove(&token) + .map(|(username, _)| username) + } + + pub fn take_expired_connects(&mut self, now: Instant) -> Vec { + let mut expired = Vec::new(); + self.pending_connect_tokens + .retain(|_, (username, deadline)| { + if now >= *deadline { + expired.push(std::mem::take(username)); + return false; + } + true + }); + expired + } + + pub(crate) fn protected_peers(&self) -> HashSet { + let mut protected: HashSet = self + .downloads + .list() + .iter() + .filter(|d| { + matches!( + d.status, + DownloadStatus::Queued + | DownloadStatus::InProgress { .. } + | DownloadStatus::Paused { .. } + ) + }) + .map(|d| d.username.clone()) + .collect(); + protected + .extend(self.active_uploads.values().map(|u| u.username.clone())); + protected.extend(self.uploads.values().map(|j| j.downloader.clone())); + protected + .extend(self.upload_queue.iter().map(|q| q.downloader.clone())); + protected.extend(self.pending_serves.keys().cloned()); + protected.extend(self.pending_peer_messages.keys().cloned()); + let now = Instant::now(); + protected.extend( + self.pending_browses + .iter() + .filter(|(_, deadline)| **deadline > now) + .map(|(username, _)| username.clone()), + ); + protected + } + + /// Record a private message received from another user. + pub fn push_private_message(&mut self, message: UserMessage) { + self.private_messages.push(message); + } + + /// Remove and return all buffered private messages. + pub fn take_private_messages(&mut self) -> Vec { + std::mem::take(&mut self.private_messages) + } +} diff --git a/soulseek-rs-lib/src/client/distributed.rs b/soulseek-rs-lib/src/client/distributed.rs index 013ecf9..98344cf 100644 --- a/soulseek-rs-lib/src/client/distributed.rs +++ b/soulseek-rs-lib/src/client/distributed.rs @@ -297,15 +297,39 @@ pub fn announce( server: Option<&Sender>, branch: &Branch, has_parent: bool, + accept_children: bool, ) { let Some(server) = server else { return; }; - for message in distributed::stance(&branch.root, branch.level, has_parent) { + for message in distributed::stance( + &branch.root, + branch.level, + has_parent, + accept_children, + ) { let _ = server.send(ServerMessage::SendMessage(message)); } } +/// Tell the server where we sit, and tell our children too: a branch that +/// moved leaves every child reporting a stale place until they hear it. +pub fn announce_move( + ctx: &mut super::ClientContext, + branch: &Branch, + has_parent: bool, +) { + let server = ctx.server_sender.clone(); + // What the server needs to know is whether it may offer us to another + // peer right now, not whether we serve children in principle. The stance + // carries it, so record it as told: a separate AcceptChildren right + // behind this one would say nothing new. + let has_room = ctx.children.has_room(); + let _ = ctx.accept_children_change(); + announce(server.as_ref(), branch, has_parent, has_room); + ctx.children.broadcast_stance(&branch.root, branch.level); +} + /// Dial a parent candidate on its own thread: a `D` PeerInit, then whatever it /// sends comes back as operations until the link closes or is cancelled. pub fn spawn_link(dial: Dial, own: String, ops: Sender) { @@ -668,7 +692,7 @@ mod tests { fn announcing_tells_the_server_the_root_the_level_and_that_we_have_a_parent() { let (tx, rx) = std::sync::mpsc::channel(); - announce(Some(&tx), &Branch::under("rooty", 4), true); + announce(Some(&tx), &Branch::under("rooty", 4), true, false); let mut sent: Vec = Vec::new(); while let Ok(ServerMessage::SendMessage(m)) = rx.try_recv() { sent.push(m); diff --git a/soulseek-rs-lib/src/client/mod.rs b/soulseek-rs-lib/src/client/mod.rs index 5dc3a34..21d7dfd 100644 --- a/soulseek-rs-lib/src/client/mod.rs +++ b/soulseek-rs-lib/src/client/mod.rs @@ -4,9 +4,9 @@ use crate::actor::server_actor::{ }; use crate::download_store::{DownloadStore, collect_failed_tokens}; use crate::types::{ - ClientVersion, DownloadMetadata, DownloadStatus, RoomEvent, RoomInfo, - RoomUserStats, SessionLoss, SessionWatch, UserInfo, UserPresence, - UserStats, UserStatus, + ClientVersion, DownloadMetadata, DownloadStatus, Recommendation, RoomEvent, + RoomInfo, RoomTicker, RoomUserStats, SessionLoss, SessionWatch, + SimilarUser, UserInfo, UserInterests, UserPresence, UserStats, UserStatus, }; use crate::utils::logger; use crate::{ @@ -149,6 +149,11 @@ fn upload_speed( /// Build a `FileSearchResponse` for `query` against `shares`, or `None` if /// nothing matches. `own_username` is the name the searcher will download from. +// One reply's worth of state: the shares to search, who we are, and the +// figures the reply advertises. Kept as arguments rather than a struct +// because every one of them is read straight from the client context at the +// call site, and a struct would only move the same list one line up. +#[allow(clippy::too_many_arguments)] fn build_search_response( shares: &Shares, own_username: &str, @@ -157,13 +162,21 @@ fn build_search_response( free_slot: bool, speed: u32, queue_length: u32, + excluded_phrases: &[String], ) -> Option { let matches = shares.search(query); if matches.is_empty() { return None; } + // A file whose path carries a phrase the server excludes (code 160) is + // left out of the reply, the way Nicotine+ does it: the exclusion is the + // server policing what travels the search network, and answering with one + // anyway is what it is asking us not to do. let entries: Vec = matches .iter() + .filter(|f| { + !path_carries_excluded_phrase(&f.virtual_path, excluded_phrases) + }) .take(crate::types::MAX_SEARCH_REPLY_FILES) .map(|f| FileEntry { name: &f.virtual_path, @@ -171,6 +184,9 @@ fn build_search_response( attribs: &f.attributes, }) .collect(); + if entries.is_empty() { + return None; + } Some(build_file_search_response( own_username, token, @@ -181,6 +197,18 @@ fn build_search_response( )) } +/// Whether `path` contains any of the phrases the server excludes. Matching +/// is on the lowercased path, as the phrases themselves are lowercase. +fn path_carries_excluded_phrase(path: &str, excluded: &[String]) -> bool { + if excluded.is_empty() { + return false; + } + let lowered = path.to_lowercase(); + excluded + .iter() + .any(|phrase| !phrase.is_empty() && lowered.contains(phrase)) +} + #[derive(Debug, Clone)] pub struct ClientSettings { pub username: String, @@ -191,6 +219,11 @@ pub struct ClientSettings { /// Directories whose files are shared with (uploaded to) other peers. /// Empty means nothing is shared. pub shared_directories: Vec, + /// Whether to serve children in the distributed search network: peers + /// hang from us with a `D` connection and we pass every search we receive + /// down to them. Off by default โ€” it costs a socket and the network's + /// whole search stream per child. + pub accept_children: bool, /// The version reported to the server on login. Defaults to the /// soulseek-rs major version with minor version 1 ("unidentified"); /// clients built on this library should reserve their own minor @@ -223,6 +256,7 @@ impl Default for ClientSettings { enable_listen: true, listen_port: DEFAULT_LISTEN_PORT, shared_directories: Vec::new(), + accept_children: false, version: ClientVersion::default(), } } @@ -281,6 +315,10 @@ pub enum ClientOperation { username: String, token: u32, query: String, + /// True when it came down the tree from our parent, which has + /// already passed it to our children and is subject to the answer + /// budget. A search the server relayed is neither. + from_parent: bool, }, /// A peer queued one of our shared files; `requester_key` is the registry /// key of the peer actor โ€” the peer's username. @@ -306,6 +344,18 @@ pub enum ClientOperation { token: u32, folder: String, }, + /// A peer answered our request for what it says about itself. + PeerInfoReceived { + username: String, + info: crate::message::peer::PeerInfo, + }, + /// A peer answered our request for one folder of their shares. + FolderContents { + username: String, + token: u32, + folder: String, + directories: Vec, + }, /// A peer we are browsing returned their shared-file listing. BrowseResult { username: String, @@ -313,8 +363,11 @@ pub enum ClientOperation { }, /// A direct outbound connection to this peer failed before it was /// established โ€” the peer is likely firewalled, so fall back to asking the - /// server to broker the connection. Carries the reporting actor's id. - PeerConnectFailed(u64, String), + /// server to broker the connection. Carries the reporting actor's id and, + /// when the dial was one the server asked us to make, the token that peer + /// quoted: that dial is answered with a `CantConnectToPeer` instead of a + /// broker request, since the peer is already waiting on the server. + PeerConnectFailed(u64, String, Option), /// Something happened in the chat-room subsystem (list refreshed, a room /// joined/left, a message said, a member joined/left). RoomEvent(RoomEvent), @@ -340,6 +393,46 @@ pub enum ClientOperation { PossibleParents(Vec<(String, String, u16)>), /// The server told us to drop our parent. ResetDistributed, + /// A session just started: anything the server keeps only per-session โ€” + /// our interests โ€” has to be sent again. + SessionEstablished, + /// A peer dialled us with a `D` connection, asking to hang from us in the + /// distributed search network. + ChildConnected { + username: String, + stream: std::net::TcpStream, + }, + /// Recommendations from the server: either from our own interests + /// (`global` false, code 54) or server-wide (`global` true, code 56). + Recommendations { + global: bool, + recommended: Vec, + unrecommended: Vec, + }, + /// Recommendations for one item (code 111). + ItemRecommendations { + item: String, + recommendations: Vec, + }, + /// Users the server considers similar to us (code 110). + SimilarUsers(Vec), + /// Users who like one item (code 112). + ItemSimilarUsers { + item: String, + usernames: Vec, + }, + /// What another user likes and hates (code 57). + UserInterests(UserInterests), + /// A peer we asked the server to broker gave up reaching us (code 1001). + CantConnectToPeer { + token: u32, + }, + /// Phrases the server refuses to search for (code 160). + ExcludedSearchPhrases(Vec), + /// The minimum upload speed for carrying children (code 83). + ParentMinSpeed(u32), + /// The divisor turning that speed into a child count (code 84). + ParentSpeedRatio(u32), /// A parent candidate told us how deep it sits. `link` says which dial /// to that user is talking. ParentBranchLevel { @@ -375,6 +468,18 @@ pub struct ClientContext { operations: Option>, /// Our place in the distributed search network. leaf: distributed::Leaf, + /// The children hanging from us, when the client serves any. + pub(crate) children: children::Children, + /// The last `AcceptChildren` answer we gave the server, so the same + /// answer is not repeated: it is a standing state, not a heartbeat. + announced_accept_children: Option, + /// The upload speed the server records for us, once it has told us. The + /// child limit is derived from it. + own_average_speed: Option, + /// The speed a parent needs (code 83) and the divisor turning speed into + /// a child count (code 84), as the server announced them. + parent_min_speed: u32, + parent_speed_ratio: u32, searches: HashMap, private_messages: Vec, /// Correlation tokens for server-brokered (firewalled) connections, mapping @@ -399,6 +504,10 @@ pub struct ClientContext { pending_serves: HashMap>, /// Shared-file listings received from peers we browsed. browse_results: HashMap>, + /// One-folder listings received from peers, keyed by peer and folder. + folder_contents: HashMap<(String, String), Vec>, + /// What peers said about themselves (peer code 16), keyed by peer. + peer_infos: HashMap, pending_browses: HashMap, /// Latest snapshot of the public chat-room list (from `RoomList`, code 64). room_list: Vec, @@ -410,6 +519,31 @@ pub struct ClientContext { /// Per-member statistics for each joined room, from the stat vectors the /// server sends alongside the membership list. room_member_stats: HashMap>, + /// Who may enter each private room we belong to (code 133), kept current + /// from the later roster changes. + private_room_members: HashMap>, + /// Who runs each of those rooms (code 148). + private_room_operators: HashMap>, + /// The ticker board of each joined room, kept current from the board sent + /// on join (code 113) and the later add/remove events (114/115). + room_tickers: HashMap>, + /// The latest recommendations from our own interests (code 54), as + /// (recommended, recommended-against). + recommendations: Option<(Vec, Vec)>, + /// The latest server-wide recommendations (code 56). + global_recommendations: Option<(Vec, Vec)>, + /// Per-item recommendations (code 111), keyed by the item asked about. + item_recommendations: HashMap>, + /// The latest similar-user answer (code 110). + similar_users: Vec, + /// Who likes an item (code 112), keyed by the item asked about. + item_similar_users: HashMap>, + /// Interests of other users (code 57), keyed by username. + user_interests: HashMap, + /// What we ourselves like and hate. The server keeps these only for the + /// duration of a session, so they are held here and sent again after each + /// login โ€” the same thing Nicotine+ does from its config. + own_interests: UserInterests, /// What the server has told us about other users, merged across the /// separate status and statistics replies. user_info: HashMap, @@ -425,6 +559,9 @@ pub struct ClientContext { privileged_users: HashSet, /// Seconds of our own privileges left (code 92), once we have asked. own_privileges: Option, + /// Phrases the server excludes from the search network (code 160). Files + /// whose path carries one are left out of the replies we send. + excluded_search_phrases: Vec, /// Peers waiting for one of our upload slots. upload_queue: Vec, /// Arrival counter for the queue's first-come tie-break. @@ -441,6 +578,15 @@ pub struct ClientContext { upload_events: Vec, actor_system: Arc, } +/// A roster sorted and de-duplicated, so membership lookups can binary-search +/// it and a repeated name cannot appear twice. +fn sorted_unique(users: &[String]) -> Vec { + let mut users = users.to_vec(); + users.sort(); + users.dedup(); + users +} + impl Default for ClientContext { fn default() -> Self { Self::new() @@ -483,386 +629,6 @@ impl ClientContext { } } -impl ClientContext { - /// A context for the client that logs in as `username`. - #[must_use] - pub fn for_user(username: &str) -> Self { - Self { - leaf: distributed::Leaf::new(username), - ..Self::new() - } - } - - #[must_use] - pub fn new() -> Self { - let actor_system = Arc::new(ActorSystem::new()); - - Self { - peer_registry: None, - server_sender: None, - operations: None, - leaf: distributed::Leaf::new(""), - searches: HashMap::new(), - private_messages: Vec::new(), - pending_connect_tokens: HashMap::new(), - max_peers: Arc::new(AtomicUsize::new(DEFAULT_MAX_PEERS)), - shares: Arc::new(Shares::empty()), - shared_directories: Vec::new(), - peer_addresses: HashMap::new(), - pending_peer_messages: HashMap::new(), - uploads: HashMap::new(), - active_uploads: HashMap::new(), - pending_serves: HashMap::new(), - browse_results: HashMap::new(), - pending_browses: HashMap::new(), - room_list: Vec::new(), - room_events: Vec::new(), - room_members: HashMap::new(), - room_member_stats: HashMap::new(), - user_info: HashMap::new(), - watched_users: HashSet::new(), - wishlist_interval: None, - privileged_users: HashSet::new(), - own_privileges: None, - upload_queue: Vec::new(), - upload_seq: 0, - upload_slots: DEFAULT_UPLOAD_SLOTS, - last_upload_speed: 0, - upload_events: Vec::new(), - downloads: DownloadStore::new(), - actor_system, - } - } - - /// Apply a chat-room event: keep the room-list snapshot and the per-room - /// rosters current, then queue the event for the client/UI to drain. - pub fn apply_room_event(&mut self, event: RoomEvent) { - match &event { - RoomEvent::List(rooms) => self.room_list.clone_from(rooms), - RoomEvent::Joined { room, users } => { - let mut members = users.clone(); - members.sort(); - members.dedup(); - self.room_members.insert(room.clone(), members); - } - RoomEvent::Left { room } => { - self.room_members.remove(room); - } - RoomEvent::UserJoined { room, username } => { - let members = - self.room_members.entry(room.clone()).or_default(); - if let Err(at) = members.binary_search(username) { - members.insert(at, username.clone()); - } - } - RoomEvent::UserLeft { room, username } => { - if let Some(members) = self.room_members.get_mut(room) - && let Ok(at) = members.binary_search(username) - { - members.remove(at); - } - } - RoomEvent::Message { .. } => {} - } - self.room_events.push(event); - } - - /// Record a `GetUserStatus` reply, merging it with any statistics already - /// received for that user. - pub fn apply_user_status( - &mut self, - username: String, - status: u32, - privileged: bool, - ) { - self.user_info - .entry(username.clone()) - .or_insert_with(|| UserInfo::pending(username)) - .presence = Some(UserPresence { - status: UserStatus::from_code(status), - privileged, - }); - } - - /// Forget what we know about `username`, so the next poll reports the - /// answer to the request being made now rather than the previous one. - pub fn invalidate_user_info(&mut self, username: &str) { - self.user_info.remove(username); - } - - /// Record a `GetUserStats` reply, merging it with any status already - /// received for that user. - pub fn apply_user_stats( - &mut self, - username: String, - average_speed: u32, - shared_files: u32, - shared_folders: u32, - ) { - self.user_info - .entry(username.clone()) - .or_insert_with(|| UserInfo::pending(username)) - .stats = Some(UserStats { - average_speed, - shared_files, - shared_folders, - }); - } - - /// Record that we are now watching `username`. - pub fn add_watched_user(&mut self, username: &str) { - self.watched_users.insert(username.to_string()); - } - - /// Forget a watch, dropping any snapshot we held for that user so a later - /// re-watch reports a fresh answer rather than a stale one. - pub fn remove_watched_user(&mut self, username: &str) { - self.watched_users.remove(username); - self.user_info.remove(username); - } - - /// Everyone we are currently watching, sorted by name. - #[must_use] - pub fn watched_users(&self) -> Vec { - let mut users: Vec = - self.watched_users.iter().cloned().collect(); - users.sort(); - users - } - - /// Record the initial snapshot from a `WatchUser` reply. A username the - /// server does not know carries no stats and is dropped from the watch - /// list, since the server will never push anything for it. - pub fn apply_watched_user( - &mut self, - username: String, - exists: bool, - status: Option, - average_speed: Option, - shared_files: Option, - shared_folders: Option, - ) { - if !exists { - self.watched_users.remove(&username); - return; - } - if let Some(status) = status { - let entry = self - .user_info - .entry(username.clone()) - .or_insert_with(|| UserInfo::pending(username.clone())); - // The watch reply carries no privileged flag, so keep whatever a - // previous GetUserStatus told us rather than asserting `false`. - let privileged = - entry.presence.as_ref().is_some_and(|p| p.privileged); - entry.presence = Some(UserPresence { - status: UserStatus::from_code(status), - privileged, - }); - } - if let (Some(average_speed), Some(shared_files), Some(shared_folders)) = - (average_speed, shared_files, shared_folders) - { - self.apply_user_stats( - username, - average_speed, - shared_files, - shared_folders, - ); - } - } - - /// What the server has said about `username` so far. - #[must_use] - pub fn user_info(&self, username: &str) -> Option { - self.user_info.get(username).cloned() - } - - /// Who is currently in `room`, sorted, or empty when we are not in it. - #[must_use] - pub fn room_members(&self, room: &str) -> Vec { - self.room_members.get(room).cloned().unwrap_or_default() - } - - /// Record the per-member statistics of a room we just joined, replacing - /// any previous snapshot for it. - pub fn apply_room_member_stats( - &mut self, - room: String, - stats: Vec, - ) { - self.room_member_stats.insert(room, stats); - } - - /// What the server reported about the members of `room`, sorted by name, - /// or empty for a room we have not joined. - #[must_use] - pub fn room_member_stats(&self, room: &str) -> Vec { - let mut stats = self - .room_member_stats - .get(room) - .cloned() - .unwrap_or_default(); - stats.sort_by(|a, b| a.username.cmp(&b.username)); - stats - } - - /// The latest snapshot of the public chat-room list. - #[must_use] - pub fn room_list(&self) -> Vec { - self.room_list.clone() - } - - /// Remove and return all chat-room events received since the last call. - #[must_use] - pub fn take_room_events(&mut self) -> Vec { - std::mem::take(&mut self.room_events) - } - - /// Cache a peer's listen address learned from a GetPeerAddress response. - pub fn cache_peer_address( - &mut self, - username: &str, - host: String, - port: u32, - ) { - self.peer_addresses - .insert(username.to_string(), (host, port)); - } - - /// The cached listen address for `username`, if known. - #[must_use] - pub fn peer_address(&self, username: &str) -> Option<(String, u32)> { - self.peer_addresses.get(username).cloned() - } - - /// Queue a peer message to send once a control connection to `username` is up. - pub fn queue_peer_message( - &mut self, - username: &str, - message: crate::message::Message, - ) { - let entry = self - .pending_peer_messages - .entry(username.to_string()) - .or_insert_with(|| (Instant::now(), Vec::new())); - entry.0 = Instant::now(); - entry.1.push(message); - } - - /// Remove and return the messages queued for `username`. - pub fn take_peer_messages( - &mut self, - username: &str, - ) -> Vec { - self.pending_peer_messages - .remove(username) - .map(|(_, messages)| messages) - .unwrap_or_default() - } - - /// Drop peer messages nobody could deliver within - /// [`PENDING_PEER_TTL`]: the searcher went offline, or never existed. - pub(crate) fn expire_pending_peer_messages(&mut self, now: Instant) { - self.pending_peer_messages.retain(|_, (since, _)| { - now.duration_since(*since) < PENDING_PEER_TTL - }); - } - - /// Store a shared-file listing received from browsing `username`. - pub fn store_browse_result( - &mut self, - username: String, - directories: Vec, - ) { - self.pending_browses.remove(&username); - self.browse_results.insert(username, directories); - } - - pub(crate) fn mark_browse_pending(&mut self, username: &str) { - let now = Instant::now(); - self.pending_browses.retain(|_, deadline| *deadline > now); - self.pending_browses - .insert(username.to_string(), now + BROWSE_PROTECT_WINDOW); - } - - /// Remove and return the shared-file listing browsed from `username`. - pub fn take_browse_result( - &mut self, - username: &str, - ) -> Option> { - self.browse_results.remove(username) - } - - /// Remember that a server-brokered connection to `username` is pending under - /// `token`; the peer will quote it back in a PierceFirewall. - pub fn add_pending_connect(&mut self, token: u32, username: String) { - self.pending_connect_tokens - .insert(token, (username, Instant::now() + BROKER_CONNECT_TIMEOUT)); - } - - /// Resolve and consume the peer expected for a brokered connection `token`. - pub fn take_pending_connect(&mut self, token: u32) -> Option { - self.pending_connect_tokens - .remove(&token) - .map(|(username, _)| username) - } - - pub fn take_expired_connects(&mut self, now: Instant) -> Vec { - let mut expired = Vec::new(); - self.pending_connect_tokens - .retain(|_, (username, deadline)| { - if now >= *deadline { - expired.push(std::mem::take(username)); - return false; - } - true - }); - expired - } - - pub(crate) fn protected_peers(&self) -> HashSet { - let mut protected: HashSet = self - .downloads - .list() - .iter() - .filter(|d| { - matches!( - d.status, - DownloadStatus::Queued - | DownloadStatus::InProgress { .. } - | DownloadStatus::Paused { .. } - ) - }) - .map(|d| d.username.clone()) - .collect(); - protected - .extend(self.active_uploads.values().map(|u| u.username.clone())); - protected.extend(self.uploads.values().map(|j| j.downloader.clone())); - protected - .extend(self.upload_queue.iter().map(|q| q.downloader.clone())); - protected.extend(self.pending_serves.keys().cloned()); - protected.extend(self.pending_peer_messages.keys().cloned()); - let now = Instant::now(); - protected.extend( - self.pending_browses - .iter() - .filter(|(_, deadline)| **deadline > now) - .map(|(username, _)| username.clone()), - ); - protected - } - - /// Record a private message received from another user. - pub fn push_private_message(&mut self, message: UserMessage) { - self.private_messages.push(message); - } - - /// Remove and return all buffered private messages. - pub fn take_private_messages(&mut self) -> Vec { - std::mem::take(&mut self.private_messages) - } -} pub struct Client { enable_listen: bool, listen_port: u16, @@ -877,6 +643,15 @@ pub struct Client { server_handle: Option>, context: Arc>, session: SessionWatch, + /// Tells the peer listener to stop, so a disconnected client releases the + /// port it bound. + listener_stopped: Arc, +} + +impl Drop for Client { + fn drop(&mut self) { + self.disconnect(); + } } impl Client { @@ -890,20 +665,35 @@ impl Client { #[must_use] pub fn with_settings(settings: ClientSettings) -> Self { logger::init(); + let mut context = ClientContext::for_user(&settings.username); + context.children = children::Children::new(settings.accept_children); Self { enable_listen: settings.enable_listen, listen_port: settings.listen_port, bound_port: None, address: settings.server_address, - context: Arc::new(RwLock::new(ClientContext::for_user( - &settings.username, - ))), + context: Arc::new(RwLock::new(context)), username: settings.username, password: settings.password, version: settings.version, shared_directories: settings.shared_directories, server_handle: None, session: SessionWatch::default(), + listener_stopped: Arc::new(AtomicBool::new(false)), + } + } + + /// The children currently hanging from us in the distributed search + /// network, by username. Empty unless the client was built with + /// [`ClientSettings::accept_children`]. + #[must_use] + pub fn children(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.children.usernames(), + Err(e) => { + error!("[client] children: {}", e); + Vec::new() + } } } @@ -939,6 +729,17 @@ impl Client { .unwrap_or_default() } + /// The share index as a peer receives it: the same directory listing + /// `GetShareFileList` answers with, so what this returns is what the + /// network sees. + #[must_use] + pub fn shared_listing(&self) -> Vec { + self.context + .read_safe() + .map(|ctx| ctx.shares.directories()) + .unwrap_or_default() + } + /// `(folders, files)` currently shared with peers. #[must_use] pub fn shared_counts(&self) -> (u32, u32) { @@ -1044,12 +845,15 @@ impl Client { } } +mod children; mod connection; +mod context; mod distributed; mod downloads; mod operations; mod rooms; mod search; +mod social; mod upload_queue; mod uploads; diff --git a/soulseek-rs-lib/src/client/operations.rs b/soulseek-rs-lib/src/client/operations/mod.rs similarity index 51% rename from soulseek-rs-lib/src/client/operations.rs rename to soulseek-rs-lib/src/client/operations/mod.rs index bedf5a3..d8f9905 100644 --- a/soulseek-rs-lib/src/client/operations.rs +++ b/soulseek-rs-lib/src/client/operations/mod.rs @@ -8,8 +8,24 @@ use super::{ use crate::message::server::MessageFactory; use crate::peer::DownloadError; +mod peers; +mod search; +mod transfers; +mod tree; + const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1); +/// How often to ask the peers holding our queued downloads where they sit +/// (peer code 51). Five minutes is what Nicotine+ uses: often enough that a +/// queue position on screen means something, rare enough that a peer with a +/// long queue is not pestered. +const QUEUE_POSITION_INTERVAL: Duration = Duration::from_mins(5); + +/// How long a write to a child may block. The relay runs under the client +/// context lock, so an unbounded write would freeze every other operation +/// behind one child that stopped reading. +const CHILD_WRITE_TIMEOUT: Duration = Duration::from_secs(5); + impl Client { pub(crate) fn listen_to_client_operations( reader: Receiver, @@ -18,6 +34,7 @@ impl Client { ) { thread::spawn(move || { let mut last_sweep = Instant::now(); + let mut last_queue_poll = Instant::now(); loop { let next = reader.recv_timeout(CONNECT_SWEEP_INTERVAL); if last_sweep.elapsed() >= CONNECT_SWEEP_INTERVAL { @@ -29,14 +46,16 @@ impl Client { if let Some(branch) = ctx.leaf.due_announcement(Instant::now()) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, + super::distributed::announce_move( + &mut ctx, &branch, true, ); } } } + if last_queue_poll.elapsed() >= QUEUE_POSITION_INTERVAL { + last_queue_poll = Instant::now(); + Self::poll_queue_positions(&client_context); + } let operation = match next { Ok(operation) => operation, Err(mpsc::RecvTimeoutError::Timeout) => continue, @@ -84,175 +103,24 @@ impl Client { } } } - ClientOperation::PeerDisconnected(id, username, error) => { - // Scope the read guard: process_failed_uploads - // below acquires a write lock on the same - // RwLock, which would self-deadlock the entire - // client ops loop if this read guard were still - // held on this thread. Evict only if this exact - // actor still occupies the slot, so a replaced - // actor's shutdown can't remove its successor. - { - let context = match client_context.read_safe() { - Ok(c) => c, - Err(e) => { - error!( - "[client] PeerDisconnected read: {}", - e - ); - continue; - } - }; - if let Some(ref registry) = context.peer_registry - && let Some(handle) = - registry.remove_peer_if(&username, id) - { - let _ = handle.stop(); - } - } - // Only an error is evidence the peer is gone. A clean - // close โ€” our idle reaper, or a remote client tidying - // an idle socket while it waits in our queue โ€” must - // not throw away everything that peer has queued: the - // connection comes back, the queue cannot. A departed - // peer wedging uploads shut is still covered: its - // erroring transfer fails within the socket deadlines - // and the error branch here frees its slots. - if let Some(error) = error { - warn!( - "[client] Peer {} disconnected with error: {:?}", - username, error - ); - Self::process_failed_uploads( - client_context.clone(), - &username, - None, - ); - Self::release_upload_slots( - &client_context, - &username, - ); - } + operation @ (ClientOperation::PeerDisconnected(..) + | ClientOperation::PeerConnectFailed(..) + | ClientOperation::ChildConnected { .. }) => { + Self::on_peers( + operation, + &client_context, + &own_username, + ); } - ClientOperation::DownloadFromPeer(token, peer, allowed) => { - let maybe_download = match client_context.read_safe() { - Ok(ctx) => { - ctx.get_download_by_token(token).cloned() - } - Err(e) => { - error!("[client] DownloadFromPeer read: {}", e); - continue; - } - }; - let own_username = own_username.clone(); - let client_context_clone = client_context.clone(); - - trace!( - "[client] DownloadFromPeer token: {} peer: {:?}", - token, peer + operation @ (ClientOperation::DownloadFromPeer(..) + | ClientOperation::UpdateDownloadTokens(..) + | ClientOperation::StartUpload { .. } + | ClientOperation::QueueUpload { .. }) => { + Self::on_transfers( + operation, + &client_context, + &own_username, ); - let Some(download) = maybe_download else { - error!( - "Can't find download with token {:?}", - token - ); - continue; - }; - if matches!( - download.status, - DownloadStatus::InProgress { .. } - | DownloadStatus::Completed - | DownloadStatus::Cancelled - ) { - debug!( - "[client] transfer token {} already claimed; \ - ignoring replayed TransferResponse", - token - ); - continue; - } - if let Ok(mut ctx) = client_context.write_safe() { - ctx.update_download_with_status( - token, - DownloadStatus::InProgress { - bytes_downloaded: 0, - total_bytes: download.size, - speed_bytes_per_sec: 0.0, - }, - ); - } - - thread::spawn(move || { - let download_peer = DownloadPeer::new( - download.username.clone(), - peer.host.clone(), - peer.port, - token, - allowed, - own_username, - ); - let Some(filename) = - download.filename.split('\\').next_back() - else { - error!( - "Cant find filename to save download: {:?}", - download.filename - ); - return; - }; - match download_peer.download_file( - client_context_clone.clone(), - Some(download.clone()), - None, - ) { - Ok((download, filename)) => { - let _ = download - .sender - .send(DownloadStatus::Completed); - match client_context_clone.write_safe() { - Ok(mut ctx) => ctx - .update_download_with_status( - download.token, - DownloadStatus::Completed, - ), - Err(e) => error!( - "[client] download complete write: {}", - e - ), - } - info!( - "Successfully downloaded {} bytes to {}", - download.size, filename - ); - } - Err(DownloadError::Cancelled) => {} - Err(e) => { - let reason = Some(e.to_string()); - let _ = download.sender.send( - DownloadStatus::Failed(reason.clone()), - ); - match client_context_clone.write_safe() { - Ok(mut ctx) => ctx - .update_download_with_status( - download.token, - DownloadStatus::Failed(reason), - ), - Err(e) => error!( - "[client] download failed write: {}", - e - ), - } - error!( - "Failed to download file '{}' from {}:{} (token: {}) - Error: {}", - filename, - peer.host, - peer.port, - download.token, - e - ); - } - } - }); } ClientOperation::GetPeerAddressResponse { username, @@ -353,78 +221,6 @@ impl Client { ); } } - ClientOperation::UpdateDownloadTokens( - transfer, - username, - ) => { - let mut context = match client_context.write_safe() { - Ok(c) => c, - Err(e) => { - error!( - "[client] UpdateDownloadTokens write: {}", - e - ); - continue; - } - }; - - let download_to_update = - context.get_downloads().iter().find_map(|d| { - if d.username == username - && d.filename == transfer.filename - { - Some((d.token, d.clone())) - } else { - None - } - }); - - let cancelled = download_to_update - .as_ref() - .is_some_and(|(_, d)| { - matches!(d.status, DownloadStatus::Cancelled) - }); - if !cancelled - && let Some((old_token, download)) = - download_to_update - { - trace!( - "[client] UpdateDownloadTokens found {old_token}, transfer: {:?}", - transfer - ); - - context.add_download(Download { - token: transfer.token, - size: transfer.size, - ..download - }); - context.remove_download(old_token); - } - - // Only now invite the file connection: it is - // matched by this token, which is recorded as - // of the line above. Answering any earlier - // races the peer's connection against our own - // bookkeeping. - let registry = context.peer_registry.clone(); - drop(context); - if let Some(registry) = registry { - let response = if cancelled { - MessageFactory::build_transfer_denial_message( - transfer.token, - "Cancelled", - ) - } else { - MessageFactory::build_transfer_response_message( - transfer, - ) - }; - let _ = registry.send_to_peer( - &username, - PeerMessage::SendMessage(response), - ); - } - } ClientOperation::UploadFailed(username, filename) => { Self::process_failed_uploads( client_context.clone(), @@ -491,17 +287,35 @@ impl Client { average_speed, shared_files, shared_folders, - } => match client_context.write_safe() { - Ok(mut ctx) => ctx.apply_user_stats( - username, - average_speed, - shared_files, - shared_folders, - ), - Err(e) => { - error!("[client] UserStatsReceived write: {}", e); + } => { + // Stats about ourselves carry the speed the server + // records for us โ€” pushed whenever it changes, since + // we watch ourselves โ€” and the distributed child + // limit is derived from it. + let own = username == own_username; + match client_context.write_safe() { + Ok(mut ctx) => { + ctx.apply_user_stats( + username, + average_speed, + shared_files, + shared_folders, + ); + if own { + ctx.set_own_average_speed(average_speed); + } + } + Err(e) => { + error!( + "[client] UserStatsReceived write: {}", + e + ); + } } - }, + if own { + Self::announce_child_capacity(&client_context); + } + } ClientOperation::WatchedUserReceived { username, exists, @@ -509,19 +323,36 @@ impl Client { average_speed, shared_files, shared_folders, - } => match client_context.write_safe() { - Ok(mut ctx) => ctx.apply_watched_user( - username, - exists, - status, - average_speed, - shared_files, - shared_folders, - ), - Err(e) => { - error!("[client] WatchedUserReceived write: {}", e); + } => { + let own = username == own_username; + match client_context.write_safe() { + Ok(mut ctx) => { + ctx.apply_watched_user( + username, + exists, + status, + average_speed, + shared_files, + shared_folders, + ); + // Watching ourselves is how our own recorded + // speed reaches us, and the child limit is + // derived from it. + if own && let Some(speed) = average_speed { + ctx.set_own_average_speed(speed); + } + } + Err(e) => { + error!( + "[client] WatchedUserReceived write: {}", + e + ); + } } - }, + if own { + Self::announce_child_capacity(&client_context); + } + } ClientOperation::RoomEvent(event) => { match client_context.write_safe() { Ok(mut ctx) => ctx.apply_room_event(event), @@ -591,95 +422,12 @@ impl Client { } } } - ClientOperation::IncomingSearch { - username, - token, - query, - } => { - // Don't answer our own distributed search. - if username == own_username { - continue; - } - let response = match client_context.read_safe() { - Ok(ctx) => build_search_response( - &ctx.shares, - &own_username, - token, - &query, - ctx.has_free_upload_slot(), - ctx.last_upload_speed, - ctx.upload_queue.len() as u32, - ), - Err(e) => { - error!("[client] IncomingSearch read: {}", e); - continue; - } - }; - let Some(message) = response else { - continue; // no matching shares - }; - - // Deliver to the searcher: send now if we have a - // control connection, else open one and queue. - let (connected, registry, server_sender) = - match client_context.read_safe() { - Ok(ctx) => ( - ctx.peer_registry - .as_ref() - .is_some_and(|r| r.contains(&username)), - ctx.peer_registry.clone(), - ctx.server_sender.clone(), - ), - Err(_) => continue, - }; - if connected { - if let Some(registry) = registry { - let _ = registry.send_to_peer( - &username, - PeerMessage::SendMessage(message), - ); - } - } else { - if let Ok(mut ctx) = client_context.write_safe() { - ctx.queue_peer_message(&username, message); - } - if let Some(sender) = server_sender { - let _ = sender.send( - ServerMessage::GetPeerAddress(username), - ); - } - } - } - ClientOperation::QueueUpload { - requester_key, - filename, - } => { - // The peer served next may not be this one. - match client_context.write_safe() { - Ok(mut ctx) => { - let Some(file) = ctx.shares.get(&filename) - else { - debug!( - "[client] QueueUpload for unknown file {}", - filename - ); - continue; - }; - let size = file.size; - let real_path = file.real_path.clone(); - ctx.enqueue_upload( - &requester_key, - &filename, - real_path, - size, - ); - } - Err(e) => { - error!("[client] QueueUpload write: {}", e); - continue; - } - } - Self::pump_upload_queue(&client_context); + operation @ ClientOperation::IncomingSearch { .. } => { + Self::on_search( + operation, + &client_context, + &own_username, + ); } ClientOperation::PlaceInQueueRequested { requester_key, @@ -725,96 +473,16 @@ impl Client { ); } } - ClientOperation::ParentBranchLevel { - parent, - link, - level, - } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if let Some(branch) = - ctx.leaf.branch_level(&parent, link, level) - { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, - ); - } + operation @ (ClientOperation::ParentSearch { .. } + | ClientOperation::ParentBranchLevel { + .. } - ClientOperation::ParentBranchRoot { - parent, - link, - root, - } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if let Some(branch) = - ctx.leaf.branch_root(&parent, link, &root) - { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, - ); - } + | ClientOperation::ParentBranchRoot { + .. } - ClientOperation::ParentSearch { - parent, - link, - username, - token, - query, - } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if let Some(branch) = - ctx.leaf.search_from(&parent, link) - { - super::distributed::announce( - ctx.server_sender.as_ref(), - &branch, - true, - ); - } - if ctx.leaf.is_parent(&parent, link) - && ctx.leaf.admit_search(Instant::now()) - && let Some(ops) = &ctx.operations - { - let _ = ops.send(ClientOperation::IncomingSearch { - username, - token, - query, - }); - } - } - ClientOperation::ParentClosed { parent, link } => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - if ctx.leaf.closed(&parent, link) { - super::distributed::announce( - ctx.server_sender.as_ref(), - &ctx.leaf.branch(), - false, - ); - } - } - // Also what a fresh login means: whatever tree we hung - // from belongs to the old session. - ClientOperation::ResetDistributed => { - let Ok(mut ctx) = client_context.write_safe() else { - continue; - }; - ctx.leaf.reset(); - super::distributed::announce( - ctx.server_sender.as_ref(), - &ctx.leaf.branch(), - false, - ); + | ClientOperation::ParentClosed { .. } + | ClientOperation::ResetDistributed) => { + Self::on_tree(operation, &client_context); } ClientOperation::PrivilegedUsers(users) => { if let Ok(mut ctx) = client_context.write_safe() { @@ -834,47 +502,145 @@ impl Client { ctx.own_privileges = Some(seconds); } } - ClientOperation::StartUpload { token } => { - // The peer accepted our offer: resolve their - // address (from the code-9 GetPeerAddress) and - // stream the file, or queue until it resolves. - let (job_addr, downloader) = match client_context - .write_safe() + ClientOperation::Recommendations { + global, + recommended, + unrecommended, + } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_recommendations( + global, + recommended, + unrecommended, + ); + } + } + ClientOperation::ItemRecommendations { + item, + recommendations, + } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_item_recommendations( + item, + recommendations, + ); + } + } + ClientOperation::SimilarUsers(users) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_similar_users(users); + } + } + ClientOperation::ItemSimilarUsers { item, usernames } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_item_similar_users(item, usernames); + } + } + ClientOperation::UserInterests(interests) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.apply_user_interests(interests); + } + } + ClientOperation::PeerInfoReceived { username, info } => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.store_peer_info(username, info); + } + } + ClientOperation::FolderContents { + username, + token, + folder, + directories, + } => { + trace!( + "[client] folder {} from {} (token {}): {} dirs", + folder, + username, + token, + directories.len() + ); + if let Ok(mut ctx) = client_context.write_safe() { + ctx.store_folder_contents( + username, + folder, + directories, + ); + } + } + ClientOperation::ParentMinSpeed(speed) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.set_parent_min_speed(speed); + } + Self::announce_child_capacity(&client_context); + } + ClientOperation::ParentSpeedRatio(ratio) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.set_parent_speed_ratio(ratio); + } + Self::announce_child_capacity(&client_context); + } + ClientOperation::SessionEstablished => { + // Interests live in the server only for the session + // that set them, so a new one starts by sending ours + // again โ€” what Nicotine+ does from its config file. + let (sender, interests) = match client_context + .read_safe() { - Ok(mut ctx) => { - ctx.mark_offer_answered(token); - let Some(job) = ctx.uploads.get(&token) else { - continue; - }; - ( - ctx.peer_address(&job.downloader), - job.downloader.clone(), - ) + Ok(ctx) => { + (ctx.server_sender.clone(), ctx.own_interests()) } Err(_) => continue, }; - if let Some((host, port)) = job_addr { - Self::spawn_serve( - &client_context, - &own_username, - token, - host, - port, - ); - } else { - if let Ok(mut ctx) = client_context.write_safe() { - ctx.pending_serves - .entry(downloader.clone()) - .or_default() - .push(token); - } - if let Ok(ctx) = client_context.read_safe() - && let Some(sender) = ctx.server_sender.clone() - { - let _ = sender.send( - ServerMessage::GetPeerAddress(downloader), + let Some(sender) = sender else { continue }; + // Watch ourselves: the server pushes a user's stats + // to whoever watches them, and our own recorded + // upload speed is what the distributed child limit is + // derived from. Nicotine+ reads the same figure from + // the stats the server sends about the logged-in + // user. + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_watch_user(&own_username), + )); + for item in &interests.likes { + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_add_thing_i_like(item), + )); + } + for item in &interests.hates { + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_add_thing_i_hate(item), + )); + } + } + ClientOperation::ExcludedSearchPhrases(phrases) => { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.set_excluded_search_phrases(phrases); + } + } + ClientOperation::CantConnectToPeer { token } => { + // The peer we asked the server to broker gave up. The + // token is the correlation we sent, so it names the + // peer: end the wait now instead of letting every + // queued download for them sit until the timeout. + let username = match client_context.write_safe() { + Ok(mut ctx) => ctx.take_pending_connect(token), + Err(e) => { + error!( + "[client] CantConnectToPeer write: {}", + e ); + None } + }; + if let Some(username) = username { + debug!( + "[client] {} cannot connect back (token {})", + username, token + ); + Self::fail_queued_downloads( + &client_context, + &username, + ); } } ClientOperation::ShareListRequested { requester_key } => { @@ -932,57 +698,63 @@ impl Client { ctx.store_browse_result(username, directories); } } - ClientOperation::PeerConnectFailed(id, username) => { - // Direct connect failed: ask the server to - // broker it. Register a correlation token, then - // send ConnectToPeer so the (firewalled) peer - // connects back to our listener quoting it. - let token = next_connect_token(); - let server_sender = match client_context.write_safe() { - Ok(mut ctx) => { - // Reap the dead outbound actor so it - // stops pinning a pool worker and no - // longer shadows the brokered reconnect - // (a stale registry entry would make - // later downloads queue into a dead, - // streamless actor and hang). Identity- - // aware so a newer namesake is untouched. - if let Some(handle) = - ctx.peer_registry.as_ref().and_then(|r| { - r.remove_peer_if(&username, id) - }) - { - let _ = handle.stop(); - } - ctx.add_pending_connect( - token, - username.clone(), - ); - ctx.server_sender.clone() - } - Err(e) => { - error!( - "[client] PeerConnectFailed write: {}", - e - ); - continue; - } - }; - let Some(sender) = server_sender else { - continue; - }; - let msg = crate::message::server::MessageFactory::build_connect_to_peer( - token, - &username, - ConnectionType::P, - ); - let _ = sender.send(ServerMessage::SendMessage(msg)); - } } } }); } + /// Ask every peer holding a queued download of ours where it sits. The + /// answers arrive as `PlaceInQueueResponse` and land on the downloads. + fn poll_queue_positions(client_context: &Arc>) { + let (registry, queued) = match client_context.read_safe() { + Ok(ctx) => ( + ctx.peer_registry.clone(), + ctx.get_downloads() + .iter() + .filter(|d| matches!(d.status, DownloadStatus::Queued)) + .map(|d| (d.username.clone(), d.filename.clone())) + .collect::>(), + ), + Err(_) => return, + }; + let Some(registry) = registry else { return }; + for (username, filename) in queued { + // Only over a connection we already hold: a peer we cannot reach + // is a connection problem, not a queue question. + if !registry.contains(&username) { + continue; + } + let request = + MessageFactory::build_place_in_queue_request(&filename); + let _ = registry + .send_to_peer(&username, PeerMessage::SendMessage(request)); + } + } + + /// Tell the server whether we would take another child right now + /// (`AcceptChildren`, code 100). Sent whenever that answer changes: the + /// server only offers us to peers while it is true. + fn announce_child_capacity(client_context: &Arc>) { + let (sender, has_room) = match client_context.write_safe() { + Ok(mut ctx) => { + // Only on a change: the server holds this as a standing + // state, and repeating it on every message we happen to + // receive would be noise. + let Some(has_room) = ctx.accept_children_change() else { + return; + }; + (ctx.server_sender.clone(), has_room) + } + Err(_) => return, + }; + let Some(sender) = sender else { return }; + let message = + crate::message::server::MessageFactory::build_accept_children( + has_room, + ); + let _ = sender.send(ServerMessage::SendMessage(message)); + } + /// Answer a peer with the message `build` derives from the client state. fn reply_to_peer( client_context: &Arc>, diff --git a/soulseek-rs-lib/src/client/operations/peers.rs b/soulseek-rs-lib/src/client/operations/peers.rs new file mode 100644 index 0000000..41028d5 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/peers.rs @@ -0,0 +1,196 @@ +//! Dispatch arms about a peer connection appearing or going away. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it โ€” including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, CHILD_WRITE_TIMEOUT, Client, ClientContext, ClientOperation, + ConnectionType, RwLock, RwLockExt, ServerMessage, debug, error, + next_connect_token, warn, +}; + +impl Client { + pub(super) fn on_peers( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + match operation { + ClientOperation::PeerDisconnected(id, username, error) => { + // Scope the read guard: process_failed_uploads + // below acquires a write lock on the same + // RwLock, which would self-deadlock the entire + // client ops loop if this read guard were still + // held on this thread. Evict only if this exact + // actor still occupies the slot, so a replaced + // actor's shutdown can't remove its successor. + { + let context = match client_context.read_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] PeerDisconnected read: {}", e); + return; + } + }; + if let Some(ref registry) = context.peer_registry + && let Some(handle) = + registry.remove_peer_if(&username, id) + { + let _ = handle.stop(); + } + } + // Only an error is evidence the peer is gone. A clean + // close โ€” our idle reaper, or a remote client tidying + // an idle socket while it waits in our queue โ€” must + // not throw away everything that peer has queued: the + // connection comes back, the queue cannot. A departed + // peer wedging uploads shut is still covered: its + // erroring transfer fails within the socket deadlines + // and the error branch here frees its slots. + if let Some(error) = error { + warn!( + "[client] Peer {} disconnected with error: {:?}", + username, error + ); + Self::process_failed_uploads( + client_context.clone(), + &username, + None, + ); + Self::release_upload_slots(client_context, &username); + } + } + ClientOperation::PeerConnectFailed( + id, + username, + brokered_token, + ) => { + // Direct connect failed. A dial the server asked + // us to make is answered with CantConnectToPeer + // quoting the peer's own token: that peer is + // already waiting on the server, and asking it to + // broker the same connection back only trades one + // unreachable direction for the other. Every other + // dial falls back to the broker below. + if let Some(peer_token) = brokered_token { + let server_sender = match client_context.write_safe() { + Ok(ctx) => { + if let Some(handle) = ctx + .peer_registry + .as_ref() + .and_then(|r| r.remove_peer_if(&username, id)) + { + let _ = handle.stop(); + } + ctx.server_sender.clone() + } + Err(e) => { + error!("[client] PeerConnectFailed write: {}", e); + return; + } + }; + if let Some(sender) = server_sender { + let msg = crate::message::server::MessageFactory::build_cant_connect_to_peer( + peer_token, + &username, + ); + let _ = sender.send(ServerMessage::SendMessage(msg)); + } + // Nothing else is coming from this peer, so + // anything queued for it fails now rather than + // waiting out its timeout. + Self::fail_queued_downloads(client_context, &username); + return; + } + + // Direct connect failed: ask the server to + // broker it. Register a correlation token, then + // send ConnectToPeer so the (firewalled) peer + // connects back to our listener quoting it. + let token = next_connect_token(); + let server_sender = match client_context.write_safe() { + Ok(mut ctx) => { + // Reap the dead outbound actor so it + // stops pinning a pool worker and no + // longer shadows the brokered reconnect + // (a stale registry entry would make + // later downloads queue into a dead, + // streamless actor and hang). Identity- + // aware so a newer namesake is untouched. + if let Some(handle) = ctx + .peer_registry + .as_ref() + .and_then(|r| r.remove_peer_if(&username, id)) + { + let _ = handle.stop(); + } + ctx.add_pending_connect(token, username.clone()); + ctx.server_sender.clone() + } + Err(e) => { + error!("[client] PeerConnectFailed write: {}", e); + return; + } + }; + let Some(sender) = server_sender else { + return; + }; + let msg = crate::message::server::MessageFactory::build_connect_to_peer( + token, + &username, + ConnectionType::P, + ); + let _ = sender.send(ServerMessage::SendMessage(msg)); + } + ClientOperation::ChildConnected { username, stream } => { + // A peer wants to hang from us. Take it on if this + // client serves children and has room, then tell it + // where our branch sits so it can report its own + // place; otherwise the socket is dropped here and the + // peer looks for another parent. + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if !ctx.children.has_room() { + return; + } + // A child that stops reading must not wedge us: the + // relay writes under the context lock, so the write + // is bounded and a child that hits it loses its slot. + if stream.set_nodelay(true).is_err() + || stream + .set_write_timeout(Some(CHILD_WRITE_TIMEOUT)) + .is_err() + { + return; + } + if username == own_username { + return; // we cannot hang from ourselves + } + if ctx.children.accept(&username, stream) { + let branch = ctx.leaf.branch(); + ctx.children.send_stance_to( + &username, + &branch.root, + branch.level, + ); + debug!( + "[distributed] carrying {} children", + ctx.children.len() + ); + // The server stops offering us to peers once we + // are full, and starts again when one leaves. + let full = !ctx.children.has_room(); + drop(ctx); + if full { + Self::announce_child_capacity(client_context); + } + } + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/search.rs b/soulseek-rs-lib/src/client/operations/search.rs new file mode 100644 index 0000000..526633b --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/search.rs @@ -0,0 +1,118 @@ +//! The dispatch arm that answers a search reaching us from the network. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it โ€” including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, Client, ClientContext, ClientOperation, Instant, PeerMessage, RwLock, + RwLockExt, ServerMessage, build_search_response, error, +}; + +impl Client { + pub(super) fn on_search( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + let ClientOperation::IncomingSearch { + username, + token, + query, + from_parent, + } = operation + else { + return; + }; + { + // Every search we receive comes from the tree: from + // our parent, or from the server acting as one. That + // is what qualifies us to carry children at all โ€” + // Nicotine+ refuses them until something feeds it โ€” + // and passing the search down is the whole duty. + let (capacity_changed, answer) = match client_context.write_safe() { + Ok(mut ctx) => { + let was_fed = ctx.children.is_fed(); + let before = ctx.children.len(); + ctx.set_fed_by(true); + if !from_parent { + // A parent's search was passed down where + // it arrived; this is the server's. + ctx.children.broadcast_search(&username, token, &query); + } + // Answering costs a scan of everything we + // share, so the budget applies to searches + // from the tree. The server's own are few. + let answer = + !from_parent || ctx.leaf.admit_search(Instant::now()); + // A child whose socket has gone is dropped by + // that write, which frees a slot the server + // should hear about. + (!was_fed || ctx.children.len() != before, answer) + } + Err(_) => (false, false), + }; + if capacity_changed { + Self::announce_child_capacity(client_context); + } + if !answer { + return; + } + + // Don't answer our own distributed search. + if username == own_username { + return; + } + let response = match client_context.read_safe() { + Ok(ctx) => build_search_response( + &ctx.shares, + &own_username, + token, + &query, + ctx.has_free_upload_slot(), + ctx.last_upload_speed, + ctx.upload_queue.len() as u32, + &ctx.excluded_search_phrases, + ), + Err(e) => { + error!("[client] IncomingSearch read: {}", e); + return; + } + }; + let Some(message) = response else { + return; // no matching shares + }; + + // Deliver to the searcher: send now if we have a + // control connection, else open one and queue. + let (connected, registry, server_sender) = + match client_context.read_safe() { + Ok(ctx) => ( + ctx.peer_registry + .as_ref() + .is_some_and(|r| r.contains(&username)), + ctx.peer_registry.clone(), + ctx.server_sender.clone(), + ), + Err(_) => return, + }; + if connected { + if let Some(registry) = registry { + let _ = registry.send_to_peer( + &username, + PeerMessage::SendMessage(message), + ); + } + } else { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.queue_peer_message(&username, message); + } + if let Some(sender) = server_sender { + let _ = + sender.send(ServerMessage::GetPeerAddress(username)); + } + } + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/transfers.rs b/soulseek-rs-lib/src/client/operations/transfers.rs new file mode 100644 index 0000000..d41fd63 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/transfers.rs @@ -0,0 +1,219 @@ +//! Dispatch arms that move bytes: what a peer allows, what it asks of us, +//! and the tokens tying the two together. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it โ€” including `own_username`, which is re-owned on the way in +//! rather than have every line that clones it rewritten. + +use super::{ + Arc, Client, ClientContext, ClientOperation, Download, DownloadError, + DownloadPeer, DownloadStatus, MessageFactory, PeerMessage, RwLock, + RwLockExt, ServerMessage, debug, error, info, thread, trace, +}; + +impl Client { + pub(super) fn on_transfers( + operation: ClientOperation, + client_context: &Arc>, + own_username: &str, + ) { + let own_username = own_username.to_string(); + match operation { + ClientOperation::DownloadFromPeer(token, peer, allowed) => { + let maybe_download = match client_context.write_safe() { + Ok(mut ctx) => { + ctx.downloads.claim_for_peer(token, &peer.username) + } + Err(e) => { + error!("[client] DownloadFromPeer write: {}", e); + return; + } + }; + trace!( + "[client] DownloadFromPeer token: {} peer: {:?}", + token, peer + ); + let Some(download) = maybe_download else { + debug!( + "[client] transfer token {} is missing, belongs \ + to another peer, or is already claimed; ignoring", + token, + ); + return; + }; + + let client_context = client_context.clone(); + + thread::spawn(move || { + let download_peer = DownloadPeer::new( + download.username.clone(), + peer.host.clone(), + peer.port, + token, + allowed, + own_username, + ); + match download_peer.download_file( + client_context, + Some(download.clone()), + None, + ) { + Ok((download, filename)) => { + info!( + "Successfully downloaded {} bytes to {}", + download.size, filename + ); + } + Err(DownloadError::Cancelled) => {} + Err(e) => { + error!( + "Failed to download file '{}' from {}:{} (token: {}) - Error: {}", + download.filename, + peer.host, + peer.port, + download.token, + e + ); + } + } + }); + } + ClientOperation::UpdateDownloadTokens(transfer, username) => { + let mut context = match client_context.write_safe() { + Ok(c) => c, + Err(e) => { + error!("[client] UpdateDownloadTokens write: {}", e); + return; + } + }; + + let download_to_update = + context.get_downloads().iter().find_map(|d| { + if d.username == username + && d.filename == transfer.filename + { + Some((d.token, d.clone())) + } else { + None + } + }); + + let cancelled = + download_to_update.as_ref().is_some_and(|(_, d)| { + matches!(d.status, DownloadStatus::Cancelled) + }); + if !cancelled + && let Some((old_token, download)) = download_to_update + { + trace!( + "[client] UpdateDownloadTokens found {old_token}, transfer: {:?}", + transfer + ); + + context.add_download(Download { + token: transfer.token, + size: transfer.size, + ..download + }); + context.remove_download(old_token); + } + + // Only now invite the file connection: it is + // matched by this token, which is recorded as + // of the line above. Answering any earlier + // races the peer's connection against our own + // bookkeeping. + let registry = context.peer_registry.clone(); + drop(context); + if let Some(registry) = registry { + let response = if cancelled { + MessageFactory::build_transfer_denial_message( + transfer.token, + "Cancelled", + ) + } else { + MessageFactory::build_transfer_response_message( + transfer, + ) + }; + let _ = registry.send_to_peer( + &username, + PeerMessage::SendMessage(response), + ); + } + } + ClientOperation::StartUpload { token } => { + // The peer accepted our offer: resolve their + // address (from the code-9 GetPeerAddress) and + // stream the file, or queue until it resolves. + let (job_addr, downloader) = match client_context.write_safe() { + Ok(mut ctx) => { + ctx.mark_offer_answered(token); + let Some(job) = ctx.uploads.get(&token) else { + return; + }; + ( + ctx.peer_address(&job.downloader), + job.downloader.clone(), + ) + } + Err(_) => return, + }; + if let Some((host, port)) = job_addr { + Self::spawn_serve( + client_context, + &own_username, + token, + host, + port, + ); + } else { + if let Ok(mut ctx) = client_context.write_safe() { + ctx.pending_serves + .entry(downloader.clone()) + .or_default() + .push(token); + } + if let Ok(ctx) = client_context.read_safe() + && let Some(sender) = ctx.server_sender.clone() + { + let _ = sender + .send(ServerMessage::GetPeerAddress(downloader)); + } + } + } + ClientOperation::QueueUpload { + requester_key, + filename, + } => { + // The peer served next may not be this one. + match client_context.write_safe() { + Ok(mut ctx) => { + let Some(file) = ctx.shares.get(&filename) else { + debug!( + "[client] QueueUpload for unknown file {}", + filename + ); + return; + }; + let size = file.size; + let real_path = file.real_path.clone(); + ctx.enqueue_upload( + &requester_key, + &filename, + real_path, + size, + ); + } + Err(e) => { + error!("[client] QueueUpload write: {}", e); + return; + } + } + Self::pump_upload_queue(client_context); + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/operations/tree.rs b/soulseek-rs-lib/src/client/operations/tree.rs new file mode 100644 index 0000000..68a4d36 --- /dev/null +++ b/soulseek-rs-lib/src/client/operations/tree.rs @@ -0,0 +1,103 @@ +//! Dispatch arms for our place in the distributed search tree. +//! +//! Lifted out of the dispatch loop whole, so the bodies read exactly as they +//! did inside it. + +use super::{Arc, Client, ClientContext, ClientOperation, RwLock, RwLockExt}; + +impl Client { + pub(super) fn on_tree( + operation: ClientOperation, + client_context: &Arc>, + ) { + match operation { + ClientOperation::ParentSearch { + parent, + link, + username, + token, + query, + } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = ctx.leaf.search_from(&parent, link) { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + if ctx.leaf.is_parent(&parent, link) { + // The tree's stream is not ours to throttle: every + // search goes down to our children. The budget + // below bounds only the share scan we do for our + // own reply. + ctx.children.broadcast_search(&username, token, &query); + if let Some(ops) = &ctx.operations { + let _ = ops.send(ClientOperation::IncomingSearch { + username, + token, + query, + from_parent: true, + }); + } + } + } + ClientOperation::ParentBranchLevel { + parent, + link, + level, + } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = + ctx.leaf.branch_level(&parent, link, level) + { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + } + ClientOperation::ParentBranchRoot { parent, link, root } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if let Some(branch) = ctx.leaf.branch_root(&parent, link, &root) + { + crate::client::distributed::announce_move( + &mut ctx, &branch, true, + ); + } + } + ClientOperation::ParentClosed { parent, link } => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + if ctx.leaf.closed(&parent, link) { + let branch = ctx.leaf.branch(); + crate::client::distributed::announce_move( + &mut ctx, &branch, false, + ); + } + } + // Also what a fresh login means: whatever tree we hung + // from belongs to the old session. + ClientOperation::ResetDistributed => { + let Ok(mut ctx) = client_context.write_safe() else { + return; + }; + ctx.leaf.reset(); + // A fresh session feeds us nothing until the tree (or + // the server) starts again, so the children we were + // carrying are let go rather than left starving. + ctx.set_fed_by(false); + let branch = ctx.leaf.branch(); + crate::client::distributed::announce_move( + &mut ctx, &branch, false, + ); + } + // The dispatch loop routes only the variants above to here. + _ => {} + } + } +} diff --git a/soulseek-rs-lib/src/client/rooms.rs b/soulseek-rs-lib/src/client/rooms.rs index 6ea95d9..d11da0c 100644 --- a/soulseek-rs-lib/src/client/rooms.rs +++ b/soulseek-rs-lib/src/client/rooms.rs @@ -234,9 +234,22 @@ impl Client { pub fn browse_user(&self, username: &str) -> Result<()> { let request = crate::message::server::MessageFactory::build_get_share_file_list(); + self.context.write_safe()?.mark_browse_pending(username); + self.send_to_peer_or_queue(username, request) + } + + /// Send `request` to `username` over their control connection, or hold it + /// until one exists โ€” asking the server for their address so it does. + /// + /// # Errors + /// [`SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub(super) fn send_to_peer_or_queue( + &self, + username: &str, + request: crate::message::Message, + ) -> Result<()> { let (connected, registry) = { - let mut ctx = self.context.write_safe()?; - ctx.mark_browse_pending(username); + let ctx = self.context.read_safe()?; ( ctx.peer_registry .as_ref() @@ -250,13 +263,17 @@ impl Client { .send_to_peer(username, PeerMessage::SendMessage(request)); } } else { + // Without a server there is nobody to resolve the peer's address, + // so the request would sit in the queue forever: say so instead. + let handle = self + .server_handle + .as_ref() + .ok_or(SoulseekRs::NotConnected)?; self.context .write_safe()? .queue_peer_message(username, request); - if let Some(handle) = &self.server_handle { - let _ = handle - .send(ServerMessage::GetPeerAddress(username.to_string())); - } + let _ = handle + .send(ServerMessage::GetPeerAddress(username.to_string())); } Ok(()) } diff --git a/soulseek-rs-lib/src/client/search.rs b/soulseek-rs-lib/src/client/search.rs index 215deb5..3b44d20 100644 --- a/soulseek-rs-lib/src/client/search.rs +++ b/soulseek-rs-lib/src/client/search.rs @@ -59,19 +59,10 @@ impl Client { fn send_search(&self, query: &str, wishlist: bool) -> Result<()> { info!("Searching for {}", query); + let token = self.register_search(query)?; let Some(handle) = &self.server_handle else { return Err(SoulseekRs::NotConnected); }; - let token = next_search_token(); - - self.context.write_safe()?.searches.insert( - query.to_string(), - Search { - token, - results: Vec::new(), - }, - ); - let query = query.to_string(); let _ = handle.send(if wishlist { ServerMessage::WishlistSearch { token, query } @@ -81,6 +72,66 @@ impl Client { Ok(()) } + /// Search one user's shares (server code 42) and return at once. + /// + /// Results arrive from that user alone and accumulate under `query`, read + /// back with [`Client::get_search_results`] โ€” the same place a plain + /// search's results land, since the replies are ordinary + /// `FileSearchResponse` frames. + /// + /// # Errors + /// [`SoulseekRs::NotConnected`] when there is no server connection. + pub fn search_user(&self, username: &str, query: &str) -> Result<()> { + let token = self.register_search(query)?; + self.send_server_message( + crate::message::server::MessageFactory::build_user_search( + username, token, query, + ), + ) + } + + /// Search the shares of everyone in `room` (server code 120), returning at + /// once. Results accumulate under `query` exactly as above. + /// + /// # Errors + /// [`SoulseekRs::NotConnected`] when there is no server connection. + pub fn search_room(&self, room: &str, query: &str) -> Result<()> { + let token = self.register_search(query)?; + self.send_server_message( + crate::message::server::MessageFactory::build_room_search( + room, token, query, + ), + ) + } + + /// Take a token for `query` and register it, so replies quoting that token + /// are filed under `query` when they arrive. + fn register_search(&self, query: &str) -> Result { + if self.server_handle.is_none() { + return Err(SoulseekRs::NotConnected); + } + let token = next_search_token(); + self.context.write_safe()?.searches.insert( + query.to_string(), + Search { + token, + results: Vec::new(), + }, + ); + Ok(token) + } + + /// The phrases the server excludes from the search network (code 160), as + /// last announced. Files whose path carries one are left out of the + /// replies this client sends. Empty until the server has said. + #[must_use] + pub fn excluded_search_phrases(&self) -> Vec { + self.context + .read_safe() + .map(|ctx| ctx.excluded_search_phrases()) + .unwrap_or_default() + } + /// Let responses accumulate for `timeout`, or until cancelled. /// /// Peers answer a search over their own connections whenever they get round diff --git a/soulseek-rs-lib/src/client/social.rs b/soulseek-rs-lib/src/client/social.rs new file mode 100644 index 0000000..89b1322 --- /dev/null +++ b/soulseek-rs-lib/src/client/social.rs @@ -0,0 +1,542 @@ +//! The social side of the protocol: room tickers (codes 113-116), the global +//! room feed (150-152), interests and the recommendation queries built on them +//! (51/52/54/56/57/110/111/112/117/118), and the odds and ends a client owes +//! the server โ€” a keepalive ping (32), gifting privileges (123) and changing +//! our password (142). + +use super::{ + Client, Recommendation, Result, RoomTicker, RwLockExt, SimilarUser, + UserInterests, error, +}; +use crate::message::server::MessageFactory; + +impl Client { + /// Send a keepalive ping (server code 32). The server does not reply; the + /// point is that a connection with nothing to say still has traffic on it, + /// so a NAT or the server's idle reaper does not drop it. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn ping_server(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_server_ping()) + } + + /// Join a private room, creating it (and owning it) if the name is free. + /// + /// A name already taken by someone else's private room comes back as a + /// [`crate::RoomEvent::CantCreate`] rather than a join. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn join_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_join_room(room, true)) + } + + /// Invite `username` into the private room we own (code 134). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_room_member(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_room_member( + room, username, + )) + } + + /// Remove `username` from the private room we own (code 135). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_room_member(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_room_member( + room, username, + )) + } + + /// Give up our own membership of a private room (code 136). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn leave_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_cancel_room_membership( + room, + )) + } + + /// Give up ownership of a private room, which deletes it (code 137). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn disband_private_room(&self, room: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_cancel_room_ownership( + room, + )) + } + + /// Say whether we accept private-room invitations (code 141). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn set_room_invitations_enabled(&self, enabled: bool) -> Result<()> { + self.send_server_message(MessageFactory::build_enable_room_invitations( + enabled, + )) + } + + /// Make `username` an operator of our private room (code 143). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_room_operator(&self, room: &str, username: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_add_room_operator( + room, username, + )) + } + + /// Take operator status away from `username` (code 144). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_room_operator( + &self, + room: &str, + username: &str, + ) -> Result<()> { + self.send_server_message(MessageFactory::build_remove_room_operator( + room, username, + )) + } + + /// Give up our own operator status in `room` (code 147). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn resign_room_operatorship(&self, room: &str) -> Result<()> { + self.send_server_message( + MessageFactory::build_cancel_room_operatorship(room), + ) + } + + /// Who may enter the private room `room`, as the server last reported. + #[must_use] + pub fn private_room_members(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_room_members(room), + Err(e) => { + error!("[client] private_room_members: {}", e); + Vec::new() + } + } + } + + /// Who runs the private room `room`. + #[must_use] + pub fn private_room_operators(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_room_operators(room), + Err(e) => { + error!("[client] private_room_operators: {}", e); + Vec::new() + } + } + } + + /// The private rooms we belong to. + #[must_use] + pub fn private_rooms(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.private_rooms(), + Err(e) => { + error!("[client] private_rooms: {}", e); + Vec::new() + } + } + } + + /// Set our online status (server code 28): away, or back to online. + /// + /// Other clients see it in their `GetUserStatus` and watch replies, and + /// the server uses it when it decides who to hand a search to. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn set_away(&self, away: bool) -> Result<()> { + let status = u32::from(!away) + 1; // away = 1, online = 2 + self.send_server_message(MessageFactory::build_set_status_message( + status, + )) + } + + /// Ask `username` what it says about itself (peer code 15): its + /// description, upload slots and queue length โ€” a user's profile as other + /// clients show it. + /// + /// Any previous answer is dropped first, so [`Client::peer_info`] reports + /// the reply to this request rather than a stale one. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection, + /// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub fn request_peer_info(&self, username: &str) -> Result<()> { + self.context.write_safe()?.invalidate_peer_info(username); + let request = crate::message::peer::build_user_info_request(); + self.send_to_peer_or_queue(username, request) + } + + /// What `username` last said about itself. + #[must_use] + pub fn peer_info( + &self, + username: &str, + ) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.peer_info(username)) + } + + /// Ask `username` for the contents of one folder of their shares (peer + /// code 36) โ€” what "download folder" in other clients asks for, without + /// pulling their whole listing. + /// + /// The answer arrives asynchronously; take it with + /// [`Client::take_folder_contents`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection, + /// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub fn request_folder_contents( + &self, + username: &str, + folder: &str, + ) -> Result<()> { + let request = crate::message::peer::build_folder_contents_request( + crate::client::next_search_token(), + folder, + ); + self.send_to_peer_or_queue(username, request) + } + + /// Remove and return the listing of `folder` from `username`, once their + /// answer to [`Client::request_folder_contents`] has arrived. + #[must_use] + pub fn take_folder_contents( + &self, + username: &str, + folder: &str, + ) -> Option> { + self.context + .write_safe() + .ok() + .and_then(|mut ctx| ctx.take_folder_contents(username, folder)) + } + + /// Ask `username` where the file we queued with them sits (peer code 51). + /// + /// The answer arrives asynchronously and lands on the download, readable + /// through [`Client::downloads`] as its queue position. A peer we have no + /// connection to is resolved and the request delivered once it is up, the + /// same way a browse request is. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection, + /// or [`crate::SoulseekRs::LockPoisoned`] if the client state is poisoned. + pub fn request_place_in_queue( + &self, + username: &str, + filename: &str, + ) -> Result<()> { + let request = MessageFactory::build_place_in_queue_request(filename); + self.send_to_peer_or_queue(username, request) + } + + /// Set our ticker in `room` โ€” the one-line message other members see. + /// Passing an empty `ticker` clears ours. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn set_room_ticker(&self, room: &str, ticker: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_set_room_ticker( + room, ticker, + )) + } + + /// The ticker board of `room`: what the server sent on join (code 113), + /// kept current by the later add/remove events. + #[must_use] + pub fn room_tickers(&self, room: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.room_tickers(room), + Err(e) => { + error!("[client] room_tickers: {}", e); + Vec::new() + } + } + } + + /// Subscribe to the global room feed (code 150): every public room message + /// on the server arrives as a [`crate::RoomEvent::GlobalMessage`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn join_global_room(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_join_global_room()) + } + + /// Stop the global room feed (code 151). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn leave_global_room(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_leave_global_room()) + } + + /// Add an interest (code 51). Interests are what the recommendation and + /// similar-user queries below are computed from. + /// + /// The server holds interests only for the session that set them, so the + /// list is kept here and sent again after every login. An interest added + /// before there is a connection is kept all the same โ€” the error says + /// only that it could not go out yet, not that it was dropped. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_interest(&self, item: &str) -> Result<()> { + let Some(item) = + self.context.write_safe()?.add_own_interest(item, true) + else { + return Ok(()); // nothing to like or unlike + }; + self.send_server_message(MessageFactory::build_add_thing_i_like(&item)) + } + + /// Drop an interest (code 52). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_interest(&self, item: &str) -> Result<()> { + let Some(item) = + self.context.write_safe()?.remove_own_interest(item, true) + else { + return Ok(()); // nothing to like or unlike + }; + self.send_server_message(MessageFactory::build_remove_thing_i_like( + &item, + )) + } + + /// Add a dislike (code 117). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn add_dislike(&self, item: &str) -> Result<()> { + let Some(item) = + self.context.write_safe()?.add_own_interest(item, false) + else { + return Ok(()); // nothing to like or unlike + }; + self.send_server_message(MessageFactory::build_add_thing_i_hate(&item)) + } + + /// Drop a dislike (code 118). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn remove_dislike(&self, item: &str) -> Result<()> { + let Some(item) = + self.context.write_safe()?.remove_own_interest(item, false) + else { + return Ok(()); // nothing to like or unlike + }; + self.send_server_message(MessageFactory::build_remove_thing_i_hate( + &item, + )) + } + + /// Ask for recommendations drawn from our own interests (code 54). The + /// reply arrives asynchronously; read it with + /// [`Client::recommendations`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_recommendations(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_get_recommendations()) + } + + /// Ask for the server-wide recommendations (code 56); read the reply with + /// [`Client::global_recommendations`]. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_global_recommendations(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_global_recommendations()) + } + + /// The upload speed the server records for us, once it has reported it. + /// It is what the distributed child limit is derived from, and it only + /// becomes known after an upload has finished and been reported. + #[must_use] + pub fn own_average_speed(&self) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.own_average_speed()) + } + + /// What we ourselves like and hate, as set through this client. The + /// server forgets these when the session ends, so they are re-sent after + /// each login. + #[must_use] + pub fn own_interests(&self) -> UserInterests { + match self.context.read_safe() { + Ok(ctx) => ctx.own_interests(), + Err(e) => { + error!("[client] own_interests: {}", e); + UserInterests::default() + } + } + } + + /// The last reply to [`Client::request_recommendations`] as + /// (recommended, recommended-against), or `None` until one arrives. + #[must_use] + pub fn recommendations( + &self, + ) -> Option<(Vec, Vec)> { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.recommendations(false)) + } + + /// The last reply to [`Client::request_global_recommendations`]. + #[must_use] + pub fn global_recommendations( + &self, + ) -> Option<(Vec, Vec)> { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.recommendations(true)) + } + + /// Ask what else people who like `item` like (code 111). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_item_recommendations(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_item_recommendations( + item, + )) + } + + /// The last per-item recommendations received for `item`. + #[must_use] + pub fn item_recommendations(&self, item: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.item_recommendations(item), + Err(e) => { + error!("[client] item_recommendations: {}", e); + Vec::new() + } + } + } + + /// Ask which users share our interests (code 110). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_similar_users(&self) -> Result<()> { + self.send_server_message(MessageFactory::build_similar_users()) + } + + /// The last similar-user answer, weighted by how much we have in common. + #[must_use] + pub fn similar_users(&self) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.similar_users(), + Err(e) => { + error!("[client] similar_users: {}", e); + Vec::new() + } + } + } + + /// Ask which users like `item` (code 112). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_item_similar_users(&self, item: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_item_similar_users(item)) + } + + /// The last answer to [`Client::request_item_similar_users`] for `item`. + #[must_use] + pub fn item_similar_users(&self, item: &str) -> Vec { + match self.context.read_safe() { + Ok(ctx) => ctx.item_similar_users(item), + Err(e) => { + error!("[client] item_similar_users: {}", e); + Vec::new() + } + } + } + + /// Ask what `username` likes and hates (code 57). + /// + /// Any previous answer for `username` is dropped first, so + /// [`Client::user_interests`] reports the reply to *this* request rather + /// than a stale one. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn request_user_interests(&self, username: &str) -> Result<()> { + self.context + .write_safe()? + .invalidate_user_interests(username); + self.send_server_message(MessageFactory::build_user_interests(username)) + } + + /// What the server last told us `username` likes and hates. + #[must_use] + pub fn user_interests(&self, username: &str) -> Option { + self.context + .read_safe() + .ok() + .and_then(|ctx| ctx.user_interests(username)) + } + + /// Give `days` of our own privileges to `username` (code 123). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn give_privileges(&self, username: &str, days: u32) -> Result<()> { + self.send_server_message(MessageFactory::build_give_privileges( + username, days, + )) + } + + /// Change our account password (code 142). + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn change_password(&self, password: &str) -> Result<()> { + self.send_server_message(MessageFactory::build_change_password( + password, + )) + } + + /// Send one private message to several users at once (code 149). Each + /// recipient receives it as an ordinary private message. + /// + /// # Errors + /// [`crate::SoulseekRs::NotConnected`] when there is no server connection. + pub fn send_private_message_to_many( + &self, + usernames: &[String], + message: &str, + ) -> Result<()> { + self.send_server_message(MessageFactory::build_message_users( + usernames, message, + )) + } +} diff --git a/soulseek-rs-lib/src/client/tests.rs b/soulseek-rs-lib/src/client/tests.rs deleted file mode 100644 index 1c16b42..0000000 --- a/soulseek-rs-lib/src/client/tests.rs +++ /dev/null @@ -1,838 +0,0 @@ -use super::*; - -fn download( - username: &str, - filename: &str, - token: u32, - status: DownloadStatus, - sender: Sender, -) -> Download { - Download { - username: username.to_string(), - filename: filename.to_string(), - token, - size: 100, - download_directory: "test".to_string(), - status, - sender, - queue_position: None, - metadata: DownloadMetadata::default(), - } -} - -#[test] -fn test_client_context_downloads() { - let mut context = ClientContext::new(); - let token = 123; - let new_token = 1234; - context.add_download(download( - "test", - "test.txt", - token, - DownloadStatus::Queued, - mpsc::channel().0, - )); - assert!(context.get_download_by_token(123).is_some()); - assert_eq!(context.get_download_tokens(), vec![123]); - assert_eq!(context.get_downloads().len(), 1); - if let Some(download) = context.get_download_by_token_mut(token) { - assert_eq!(download.token, token); - download.token = new_token; - } - assert!(context.get_download_by_token(new_token).is_some()); - assert_eq!(context.get_download_tokens(), vec![new_token]); - context.remove_download(new_token); - assert_eq!(context.get_downloads().len(), 0); - assert!(context.get_download_by_token(1234).is_none()); -} - -#[test] -fn test_client_pause_and_resume_download() { - let client = Client::new("test-user", "test-password"); - let (download_sender, download_receiver) = mpsc::channel(); - - client.context.write().unwrap().add_download(download( - "peer", - "song.mp3", - 123, - DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 10.0, - }, - download_sender, - )); - - assert!(client.pause_download("peer", "song.mp3")); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(123) - .unwrap() - .status, - DownloadStatus::Paused { - bytes_downloaded: 25, - total_bytes: 100 - } - )); - assert!(matches!( - download_receiver.try_recv().unwrap(), - DownloadStatus::Paused { - bytes_downloaded: 25, - total_bytes: 100 - } - )); - - assert!(client.resume_download("peer", "song.mp3")); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(123) - .unwrap() - .status, - DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 0.0 - } - )); -} - -#[test] -fn download_without_a_connection_resolves_failed() { - // A client that never connected has no server handle and no peer registry, - // so it cannot open a connection to the peer: the download must resolve to - // Failed rather than hang Queued forever. - let client = Client::new("test-user", "test-password"); - let (_download, receiver) = client - .download( - "song.mp3".to_string(), - "peer".to_string(), - 100, - "test".to_string(), - ) - .expect("download() should return a handle"); - assert!(matches!( - receiver.recv_timeout(Duration::from_secs(1)), - Ok(DownloadStatus::Failed(_)) - )); -} - -#[test] -fn fail_queued_downloads_notifies_receiver_and_store() { - // When a brokered connect times out, every Queued download for the peer - // must resolve to Failed both on its channel and in the store. - let client = Client::new("u", "p"); - let (sender, receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(download( - "peer", - "f.mp3", - 7, - DownloadStatus::Queued, - sender, - )); - - Client::fail_queued_downloads(&client.context, "peer"); - - assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_)))); - assert!(matches!( - client - .context - .read() - .unwrap() - .get_download_by_token(7) - .unwrap() - .status, - DownloadStatus::Failed(_) - )); -} - -#[test] -fn build_search_response_matches_shares_and_echoes_token() { - let dir = std::env::temp_dir() - .join(format!("soulseek-searchresp-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap(); - let shares = Shares::scan(&dir).unwrap(); - - let response = - build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0) - .expect("a matching share yields a response"); - let mut decoded = - crate::message::Message::new_with_data(response.get_buffer()); - decoded.set_pointer(8); - let result = SearchResult::new_from_message(&mut decoded).unwrap(); - assert_eq!(result.username, "me"); - assert_eq!(result.token, 99); - assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy"))); - - assert!( - build_search_response(&shares, "me", 1, "nomatch", true, 0, 0) - .is_none() - ); - let _ = std::fs::remove_dir_all(dir); -} - -fn peer_files(count: usize) -> SearchResult { - SearchResult { - token: 1, - files: (0..count) - .map(|i| crate::types::File { - username: "bob".to_string(), - name: format!("song-{i}.mp3"), - size: 1, - attribs: std::collections::HashMap::new(), - }) - .collect(), - slots: 1, - speed: 0, - username: "bob".to_string(), - } -} - -#[test] -fn search_file_counts_cover_every_search_without_the_results() { - // The daemon polls these counts many times a second; they must come from - // a walk of the cache, never a copy of it. - let client = Client::new("u", "p"); - { - let mut context = client.context.write().unwrap(); - context.searches.insert( - "aphex twin".to_string(), - Search { - token: 1, - results: vec![peer_files(2), peer_files(1)], - }, - ); - context.searches.insert( - "nothing yet".to_string(), - Search { - token: 2, - results: Vec::new(), - }, - ); - } - - let mut counts = client.search_file_counts(); - counts.sort(); - assert_eq!( - counts, - [ - ("aphex twin".to_string(), 3), - ("nothing yet".to_string(), 0) - ] - ); -} - -#[test] -fn a_search_stops_collecting_once_it_has_enough_responses() { - // A popular query on the live network draws answers for minutes โ€” - // nearly a million files and gigabytes of memory for one search. - // Surplus responders are dropped, not archived. - let mut search = Search { - token: 1, - results: Vec::new(), - }; - for _ in 0..(crate::types::MAX_SEARCH_RESPONSES + 50) { - search.accept(peer_files(1)); - } - assert_eq!(search.results.len(), crate::types::MAX_SEARCH_RESPONSES); -} - -#[test] -fn a_flood_of_files_fills_a_search_before_the_response_cap() { - // A handful of whales with huge matching collections must not add up - // to an unbounded set just because the responses are few. - let mut search = Search { - token: 1, - results: Vec::new(), - }; - for _ in 0..10 { - search.accept(peer_files(crate::types::MAX_SEARCH_FILES / 2)); - } - assert_eq!( - search.results.len(), - 2, - "two half-cap responses fill the search; the rest are dropped" - ); -} - -#[test] -fn test_client_removes_only_queued_downloads() { - let client = Client::new("test-user", "test-password"); - { - let mut context = client.context.write().unwrap(); - context.add_download(download( - "peer", - "queued.mp3", - 123, - DownloadStatus::Queued, - mpsc::channel().0, - )); - context.add_download(download( - "peer", - "active.mp3", - 456, - DownloadStatus::InProgress { - bytes_downloaded: 25, - total_bytes: 100, - speed_bytes_per_sec: 10.0, - }, - mpsc::channel().0, - )); - } - - assert!(client.remove_queued_download("peer", "queued.mp3")); - assert!(!client.remove_queued_download("peer", "active.mp3")); - let context = client.context.read().unwrap(); - assert!(context.get_download_by_token(123).is_none()); - assert!(context.get_download_by_token(456).is_some()); -} - -use crate::types::{RoomEvent, UploadStatus}; -use std::time::{Duration, Instant}; - -#[test] -fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() { - let mut context = ClientContext::new(); - - // Joining hands us the current membership in one event. - context.apply_room_event(RoomEvent::Joined { - room: "lobby".to_string(), - users: vec!["bob".to_string(), "alice".to_string()], - }); - assert_eq!(context.room_members("lobby"), ["alice", "bob"]); - - context.apply_room_event(RoomEvent::UserJoined { - room: "lobby".to_string(), - username: "carol".to_string(), - }); - context.apply_room_event(RoomEvent::UserLeft { - room: "lobby".to_string(), - username: "bob".to_string(), - }); - assert_eq!(context.room_members("lobby"), ["alice", "carol"]); - - // Events for other rooms do not leak into this one. - context.apply_room_event(RoomEvent::UserJoined { - room: "elsewhere".to_string(), - username: "dave".to_string(), - }); - assert_eq!(context.room_members("lobby"), ["alice", "carol"]); - - // A room we never joined has no roster, and leaving forgets it. - assert!(context.room_members("unknown").is_empty()); - context.apply_room_event(RoomEvent::Left { - room: "lobby".to_string(), - }); - assert!(context.room_members("lobby").is_empty()); -} - -#[test] -fn a_fresh_request_discards_the_previous_answer() { - // Without this, a poll after a second request returns the old - // snapshot immediately and the caller cannot tell stale from fresh. - let mut context = ClientContext::new(); - context.apply_user_status("alice".to_string(), 2, false); - context.apply_user_stats("alice".to_string(), 10, 20, 30); - assert!(context.user_info("alice").is_some_and(|i| i.is_complete())); - - context.invalidate_user_info("alice"); - assert!( - context.user_info("alice").is_none(), - "a new request must not be answerable from the old reply" - ); -} - -#[test] -fn each_reply_fills_only_its_own_half() { - let mut context = ClientContext::new(); - context.apply_user_status("bob".to_string(), 1, true); - - let info = context.user_info("bob").expect("a snapshot"); - assert!(!info.is_complete(), "stats have not arrived"); - assert_eq!( - info.presence.map(|p| p.status), - Some(crate::types::UserStatus::Away) - ); - assert!(info.stats.is_none(), "must not invent statistics"); - - context.apply_user_stats("bob".to_string(), 5, 6, 7); - let info = context.user_info("bob").expect("a snapshot"); - assert!(info.is_complete()); - assert_eq!(info.stats.map(|s| s.shared_files), Some(6)); - assert_eq!( - info.presence.map(|p| p.privileged), - Some(true), - "the earlier half must survive the merge" - ); -} - -#[test] -fn a_user_joining_twice_is_listed_once() { - let mut context = ClientContext::new(); - context.apply_room_event(RoomEvent::Joined { - room: "lobby".to_string(), - users: vec!["alice".to_string()], - }); - context.apply_room_event(RoomEvent::UserJoined { - room: "lobby".to_string(), - username: "alice".to_string(), - }); - assert_eq!(context.room_members("lobby"), ["alice"]); -} - -#[test] -fn upload_speed_is_reported_only_while_running() { - let two_seconds_ago = Instant::now() - .checked_sub(Duration::from_secs(2)) - .expect("clock supports a 2s offset"); - - // 1 KiB over ~2s is ~512 B/s. - let rate = upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago); - assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}"); - - // A finished upload reports no rate, exactly as a finished download - // does, so the Speed column renders "-" rather than a stale figure. - for status in [ - UploadStatus::Completed, - UploadStatus::Cancelled, - UploadStatus::Failed("nope".to_string()), - ] { - let rate = upload_speed(&status, 1024, two_seconds_ago); - assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}"); - } - - // A just-started upload must not divide by a zero elapsed time. - let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now()); - assert!( - rate.is_finite() && rate.abs() < f64::EPSILON, - "unexpected rate {rate}" - ); -} - -// A clean disconnect โ€” the idle reaper, or a remote client tidying an idle -// socket while it waits in our queue โ€” must keep the peer's queued uploads; -// only an error disconnect is evidence the peer is gone. -#[test] -fn a_clean_disconnect_keeps_queued_uploads_an_error_drops_them() { - let client = Client::new("test-user", "test-password"); - let (ops, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context.clone(), - "me".to_string(), - ); - - client.context.write().unwrap().enqueue_upload( - "amy", - "@@share\\f.mp3", - std::path::PathBuf::from("/tmp/f.mp3"), - 4096, - ); - - ops.send(ClientOperation::PeerDisconnected( - 1, - "amy".to_string(), - None, - )) - .unwrap(); - // The loop is serial, so once this fence op is visible the disconnect - // before it has been handled. - ops.send(ClientOperation::OwnPrivileges(7)).unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - while client.own_privilege_seconds() != Some(7) { - assert!(Instant::now() < deadline, "ops loop never caught up"); - thread::sleep(Duration::from_millis(10)); - } - assert_eq!( - client - .context - .read() - .unwrap() - .place_in_queue("amy", "@@share\\f.mp3"), - Some(1), - "a clean disconnect must keep the queue" - ); - - ops.send(ClientOperation::PeerDisconnected( - 1, - "amy".to_string(), - Some(crate::error::SoulseekRs::NotConnected), - )) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(5); - loop { - let place = client - .context - .read() - .unwrap() - .place_in_queue("amy", "@@share\\f.mp3"); - if place.is_none() { - break; - } - assert!( - Instant::now() < deadline, - "an error disconnect must drop the queue" - ); - thread::sleep(Duration::from_millis(10)); - } -} - -#[test] -fn a_pending_connect_expires_after_the_broker_timeout() { - let mut context = ClientContext::new(); - context.add_pending_connect(7, "ghost".to_string()); - - assert!(context.take_expired_connects(Instant::now()).is_empty()); - - let after_deadline = - Instant::now() + BROKER_CONNECT_TIMEOUT + Duration::from_secs(1); - assert_eq!( - context.take_expired_connects(after_deadline), - vec!["ghost".to_string()] - ); - assert!(context.take_pending_connect(7).is_none()); -} - -#[test] -fn protected_peers_covers_downloads_uploads_and_pending_serves() { - let mut context = ClientContext::new(); - context.add_download(download( - "downloader", - "song.mp3", - 1, - DownloadStatus::Queued, - mpsc::channel().0, - )); - context.add_download(download( - "done", - "old.mp3", - 2, - DownloadStatus::Completed, - mpsc::channel().0, - )); - context - .pending_serves - .insert("waiting".to_string(), vec![9]); - context.mark_browse_pending("browsed"); - - let protected = context.protected_peers(); - assert!(protected.contains("downloader")); - assert!(protected.contains("waiting")); - assert!(protected.contains("browsed")); - assert!(!protected.contains("done")); - - context.store_browse_result("browsed".to_string(), Vec::new()); - assert!(!context.protected_peers().contains("browsed")); -} - -#[test] -fn an_expired_broker_connect_fails_the_queued_downloads() { - let client = Client::new("u", "p"); - let (sender, receiver) = mpsc::channel(); - { - let mut ctx = client.context.write().unwrap(); - ctx.add_download(download( - "ghost", - "f.mp3", - 7, - DownloadStatus::Queued, - sender, - )); - ctx.pending_connect_tokens - .insert(7, ("ghost".to_string(), Instant::now())); - } - let (_ops_tx, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context, - "u".to_string(), - ); - - let status = receiver.recv_timeout(Duration::from_secs(5)); - assert!( - matches!(status, Ok(DownloadStatus::Failed(_))), - "the sweep must fail the queued download, got {status:?}" - ); -} - -#[test] -fn a_replayed_transfer_response_does_not_start_a_second_transfer() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.set_nonblocking(true).unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - - let client = Client::new("u", "p"); - let (sender, _receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(download( - "peer", - "f.mp3", - 9, - DownloadStatus::Queued, - sender, - )); - - let (ops_tx, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context, - "u".to_string(), - ); - - let peer = Peer::new( - "peer".to_string(), - ConnectionType::F, - "127.0.0.1".to_string(), - port, - None, - 0, - 0, - 0, - ); - ops_tx - .send(ClientOperation::DownloadFromPeer(9, peer.clone(), true)) - .unwrap(); - ops_tx - .send(ClientOperation::DownloadFromPeer(9, peer, true)) - .unwrap(); - - let deadline = Instant::now() + Duration::from_secs(5); - let mut connections = 0; - while Instant::now() < deadline { - match listener.accept() { - Ok(_) => { - connections += 1; - if connections == 1 { - thread::sleep(Duration::from_millis(500)); - } - } - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - if connections >= 1 { - break; - } - thread::sleep(Duration::from_millis(20)); - } - Err(_) => break, - } - } - assert_eq!( - connections, 1, - "a replayed TransferResponse must not dial the peer again" - ); -} - -#[test] -fn a_cancelled_download_is_not_started_when_the_peer_allows_it() { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.set_nonblocking(true).unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - - let client = Client::new("u", "p"); - let (sender, _receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(download( - "peer", - "f.mp3", - 9, - DownloadStatus::Cancelled, - sender, - )); - - let (ops_tx, ops_rx) = mpsc::channel(); - Client::listen_to_client_operations( - ops_rx, - client.context, - "u".to_string(), - ); - let peer = Peer::new( - "peer".to_string(), - ConnectionType::F, - "127.0.0.1".to_string(), - port, - None, - 0, - 0, - 0, - ); - ops_tx - .send(ClientOperation::DownloadFromPeer(9, peer, true)) - .unwrap(); - - thread::sleep(Duration::from_millis(500)); - assert!( - matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock), - "a cancelled download must not dial the peer" - ); -} - -#[test] -fn cancel_download_marks_the_store_and_answers_whether_it_matched() { - let client = Client::new("u", "p"); - let (sender, receiver) = mpsc::channel(); - client.context.write().unwrap().add_download(download( - "peer", - "f.mp3", - 9, - DownloadStatus::Queued, - sender, - )); - - assert!(client.cancel_download("peer", "f.mp3")); - assert!(!client.cancel_download("peer", "f.mp3")); - assert!(!client.cancel_download("peer", "other.mp3")); - assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Cancelled))); - assert!(matches!( - client.get_all_downloads()[0].status, - DownloadStatus::Cancelled - )); -} - -#[test] -fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() { - use crate::types::{RoomUserStats, UserStatus}; - let stat = |username: &str| RoomUserStats { - username: username.to_string(), - status: UserStatus::Online, - average_speed: 1, - shared_files: 2, - shared_folders: 3, - slots_full: true, - country: None, - }; - - let mut context = ClientContext::new(); - context.apply_room_member_stats( - "jazz".to_string(), - vec![stat("carol"), stat("alice")], - ); - - let names: Vec = context - .room_member_stats("jazz") - .into_iter() - .map(|s| s.username) - .collect(); - assert_eq!(names, vec!["alice", "carol"]); - assert!(context.room_member_stats("unjoined").is_empty()); -} - -#[test] -fn rejoining_replaces_the_previous_member_stats() { - use crate::types::{RoomUserStats, UserStatus}; - let stat = |username: &str| RoomUserStats { - username: username.to_string(), - status: UserStatus::Online, - average_speed: 1, - shared_files: 2, - shared_folders: 3, - slots_full: false, - country: None, - }; - - let mut context = ClientContext::new(); - context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]); - context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]); - - let stats = context.room_member_stats("jazz"); - assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot"); - assert_eq!(stats[0].username, "bob"); -} - -#[test] -fn a_watch_reply_fills_both_halves_of_the_snapshot() { - let mut context = ClientContext::new(); - context.add_watched_user("alice"); - context.apply_watched_user( - "alice".to_string(), - true, - Some(2), - Some(1024), - Some(20), - Some(3), - ); - - let info = context.user_info("alice").expect("a snapshot"); - assert!(info.is_complete(), "a watch reply carries status and stats"); - assert_eq!(info.stats.map(|s| s.average_speed), Some(1024)); - assert_eq!(context.watched_users(), vec!["alice".to_string()]); -} - -#[test] -fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() { - // WatchUser has no privileged field, so it must not overwrite what - // GetUserStatus already told us with a fabricated `false`. - let mut context = ClientContext::new(); - context.apply_user_status("bob".to_string(), 1, true); - context.apply_watched_user( - "bob".to_string(), - true, - Some(2), - Some(1), - Some(2), - Some(3), - ); - - let info = context.user_info("bob").expect("a snapshot"); - assert_eq!(info.presence.map(|p| p.privileged), Some(true)); -} - -#[test] -fn watching_an_unknown_user_drops_them_from_the_list() { - // The server will never push status for a name it does not know, so - // keeping it in the watch list would show a permanently blank row. - let mut context = ClientContext::new(); - context.add_watched_user("ghost"); - context.apply_watched_user( - "ghost".to_string(), - false, - None, - None, - None, - None, - ); - - assert!(context.watched_users().is_empty()); - assert!(context.user_info("ghost").is_none()); -} - -#[test] -fn unwatching_forgets_the_users_snapshot() { - let mut context = ClientContext::new(); - context.add_watched_user("alice"); - context.apply_user_status("alice".to_string(), 2, false); - - context.remove_watched_user("alice"); - assert!(context.watched_users().is_empty()); - assert!( - context.user_info("alice").is_none(), - "a later re-watch must report a fresh answer" - ); -} - -#[test] -fn watched_users_are_listed_in_a_stable_order() { - let mut context = ClientContext::new(); - context.add_watched_user("carol"); - context.add_watched_user("alice"); - context.add_watched_user("bob"); - assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]); -} - -// A reply queued for a searcher the server cannot place waits a minute, -// not forever: every such search would otherwise pin a map entry and a -// protected slot in the peer registry. -#[test] -fn a_peer_message_nobody_could_deliver_expires() { - let mut ctx = ClientContext::for_user("me"); - ctx.queue_peer_message("ghost", crate::message::Message::new()); - ctx.expire_pending_peer_messages(Instant::now()); - assert_eq!(ctx.take_peer_messages("ghost").len(), 1, "still fresh"); - - ctx.queue_peer_message("ghost", crate::message::Message::new()); - ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL); - assert!(ctx.take_peer_messages("ghost").is_empty(), "expired"); -} diff --git a/soulseek-rs-lib/src/client/tests/mod.rs b/soulseek-rs-lib/src/client/tests/mod.rs new file mode 100644 index 0000000..0862c05 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/mod.rs @@ -0,0 +1,52 @@ +//! The client's context, exercised directly. +//! +//! This file is the harness the topic modules below share. + +mod peers; +mod rooms; +mod search; +mod transfers; +mod users; + +use super::*; + +fn download( + username: &str, + filename: &str, + token: u32, + status: DownloadStatus, + sender: Sender, +) -> Download { + Download { + username: username.to_string(), + filename: filename.to_string(), + token, + size: 100, + download_directory: "test".to_string(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + } +} + +fn peer_files(count: usize) -> SearchResult { + SearchResult { + token: 1, + files: (0..count) + .map(|i| crate::types::File { + username: "bob".to_string(), + name: format!("song-{i}.mp3"), + size: 1, + attribs: std::collections::HashMap::new(), + }) + .collect(), + slots: 1, + speed: 0, + username: "bob".to_string(), + } +} + +use crate::types::{RoomEvent, UploadStatus}; + +use std::time::{Duration, Instant}; diff --git a/soulseek-rs-lib/src/client/tests/peers.rs b/soulseek-rs-lib/src/client/tests/peers.rs new file mode 100644 index 0000000..4f214bd --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/peers.rs @@ -0,0 +1,173 @@ +//! Uploads, broker connects, and the peers a disconnect may not drop. + +use super::*; + +#[test] +fn upload_speed_is_reported_only_while_running() { + let two_seconds_ago = Instant::now() + .checked_sub(Duration::from_secs(2)) + .expect("clock supports a 2s offset"); + + // 1 KiB over ~2s is ~512 B/s. + let rate = upload_speed(&UploadStatus::InProgress, 1024, two_seconds_ago); + assert!((rate - 512.0).abs() < 50.0, "unexpected rate {rate}"); + + // A finished upload reports no rate, exactly as a finished download + // does, so the Speed column renders "-" rather than a stale figure. + for status in [ + UploadStatus::Completed, + UploadStatus::Cancelled, + UploadStatus::Failed("nope".to_string()), + ] { + let rate = upload_speed(&status, 1024, two_seconds_ago); + assert!(rate.abs() < f64::EPSILON, "unexpected rate {rate}"); + } + + // A just-started upload must not divide by a zero elapsed time. + let rate = upload_speed(&UploadStatus::InProgress, 0, Instant::now()); + assert!( + rate.is_finite() && rate.abs() < f64::EPSILON, + "unexpected rate {rate}" + ); +} + +// A clean disconnect โ€” the idle reaper, or a remote client tidying an idle +// socket while it waits in our queue โ€” must keep the peer's queued uploads; +// only an error disconnect is evidence the peer is gone. + +#[test] +fn a_clean_disconnect_keeps_queued_uploads_an_error_drops_them() { + let client = Client::new("test-user", "test-password"); + let (ops, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "me".to_string(), + ); + + client.context.write().unwrap().enqueue_upload( + "amy", + "@@share\\f.mp3", + std::path::PathBuf::from("/tmp/f.mp3"), + 4096, + ); + + ops.send(ClientOperation::PeerDisconnected( + 1, + "amy".to_string(), + None, + )) + .unwrap(); + // The loop is serial, so once this fence op is visible the disconnect + // before it has been handled. + ops.send(ClientOperation::OwnPrivileges(7)).unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while client.own_privilege_seconds() != Some(7) { + assert!(Instant::now() < deadline, "ops loop never caught up"); + thread::sleep(Duration::from_millis(10)); + } + assert_eq!( + client + .context + .read() + .unwrap() + .place_in_queue("amy", "@@share\\f.mp3"), + Some(1), + "a clean disconnect must keep the queue" + ); + + ops.send(ClientOperation::PeerDisconnected( + 1, + "amy".to_string(), + Some(crate::error::SoulseekRs::NotConnected), + )) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let place = client + .context + .read() + .unwrap() + .place_in_queue("amy", "@@share\\f.mp3"); + if place.is_none() { + break; + } + assert!( + Instant::now() < deadline, + "an error disconnect must drop the queue" + ); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn a_pending_connect_expires_after_the_broker_timeout() { + let mut context = ClientContext::new(); + context.add_pending_connect(7, "ghost".to_string()); + + assert!(context.take_expired_connects(Instant::now()).is_empty()); + + let after_deadline = + Instant::now() + BROKER_CONNECT_TIMEOUT + Duration::from_secs(1); + assert_eq!( + context.take_expired_connects(after_deadline), + vec!["ghost".to_string()] + ); + assert!(context.take_pending_connect(7).is_none()); +} + +#[test] +fn protected_peers_covers_downloads_uploads_and_pending_serves() { + let mut context = ClientContext::new(); + context.add_download(download( + "downloader", + "song.mp3", + 1, + DownloadStatus::Queued, + mpsc::channel().0, + )); + context.add_download(download( + "done", + "old.mp3", + 2, + DownloadStatus::Completed, + mpsc::channel().0, + )); + context + .pending_serves + .insert("waiting".to_string(), vec![9]); + context.mark_browse_pending("browsed"); + + let protected = context.protected_peers(); + assert!(protected.contains("downloader")); + assert!(protected.contains("waiting")); + assert!(protected.contains("browsed")); + assert!(!protected.contains("done")); + + context.store_browse_result("browsed".to_string(), Vec::new()); + assert!(!context.protected_peers().contains("browsed")); +} + +#[test] +fn a_peer_message_nobody_could_deliver_expires() { + let mut ctx = ClientContext::for_user("me"); + ctx.queue_peer_message("ghost", crate::message::Message::new()); + ctx.expire_pending_peer_messages(Instant::now()); + assert_eq!(ctx.take_peer_messages("ghost").len(), 1, "still fresh"); + + ctx.queue_peer_message("ghost", crate::message::Message::new()); + ctx.expire_pending_peer_messages(Instant::now() + PENDING_PEER_TTL); + assert!(ctx.take_peer_messages("ghost").is_empty(), "expired"); +} + +#[test] +fn the_server_is_told_our_child_capacity_only_when_it_changes() { + // AcceptChildren is a standing state on the server, not a heartbeat. + let mut ctx = ClientContext::new(); + assert_eq!( + ctx.accept_children_change(), + Some(false), + "the first answer is always worth sending" + ); + assert_eq!(ctx.accept_children_change(), None, "unchanged, so silent"); +} diff --git a/soulseek-rs-lib/src/client/tests/rooms.rs b/soulseek-rs-lib/src/client/tests/rooms.rs new file mode 100644 index 0000000..0af6784 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/rooms.rs @@ -0,0 +1,297 @@ +//! Room rosters, ticker boards, and the private rooms the server lets us +//! see. + +use super::*; + +#[test] +fn the_room_roster_follows_the_joins_and_leaves_the_server_reports() { + let mut context = ClientContext::new(); + + // Joining hands us the current membership in one event. + context.apply_room_event(RoomEvent::Joined { + room: "lobby".to_string(), + users: vec!["bob".to_string(), "alice".to_string()], + }); + assert_eq!(context.room_members("lobby"), ["alice", "bob"]); + + context.apply_room_event(RoomEvent::UserJoined { + room: "lobby".to_string(), + username: "carol".to_string(), + }); + context.apply_room_event(RoomEvent::UserLeft { + room: "lobby".to_string(), + username: "bob".to_string(), + }); + assert_eq!(context.room_members("lobby"), ["alice", "carol"]); + + // Events for other rooms do not leak into this one. + context.apply_room_event(RoomEvent::UserJoined { + room: "elsewhere".to_string(), + username: "dave".to_string(), + }); + assert_eq!(context.room_members("lobby"), ["alice", "carol"]); + + // A room we never joined has no roster, and leaving forgets it. + assert!(context.room_members("unknown").is_empty()); + context.apply_room_event(RoomEvent::Left { + room: "lobby".to_string(), + }); + assert!(context.room_members("lobby").is_empty()); +} + +#[test] +fn a_user_joining_twice_is_listed_once() { + let mut context = ClientContext::new(); + context.apply_room_event(RoomEvent::Joined { + room: "lobby".to_string(), + users: vec!["alice".to_string()], + }); + context.apply_room_event(RoomEvent::UserJoined { + room: "lobby".to_string(), + username: "alice".to_string(), + }); + assert_eq!(context.room_members("lobby"), ["alice"]); +} + +#[test] +fn room_member_stats_are_returned_sorted_and_scoped_to_their_room() { + use crate::types::{RoomUserStats, UserStatus}; + let stat = |username: &str| RoomUserStats { + username: username.to_string(), + status: UserStatus::Online, + average_speed: 1, + shared_files: 2, + shared_folders: 3, + slots_full: true, + country: None, + }; + + let mut context = ClientContext::new(); + context.apply_room_member_stats( + "jazz".to_string(), + vec![stat("carol"), stat("alice")], + ); + + let names: Vec = context + .room_member_stats("jazz") + .into_iter() + .map(|s| s.username) + .collect(); + assert_eq!(names, vec!["alice", "carol"]); + assert!(context.room_member_stats("unjoined").is_empty()); +} + +#[test] +fn rejoining_replaces_the_previous_member_stats() { + use crate::types::{RoomUserStats, UserStatus}; + let stat = |username: &str| RoomUserStats { + username: username.to_string(), + status: UserStatus::Online, + average_speed: 1, + shared_files: 2, + shared_folders: 3, + slots_full: false, + country: None, + }; + + let mut context = ClientContext::new(); + context.apply_room_member_stats("jazz".to_string(), vec![stat("alice")]); + context.apply_room_member_stats("jazz".to_string(), vec![stat("bob")]); + + let stats = context.room_member_stats("jazz"); + assert_eq!(stats.len(), 1, "a rejoin supersedes the old snapshot"); + assert_eq!(stats[0].username, "bob"); +} + +#[test] +fn a_new_ticker_replaces_that_users_previous_one() { + // The server treats a user's ticker as singular: a second one from the + // same user supersedes the first rather than stacking beside it. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "first".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "bobs".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerAdded { + room: "jazz".into(), + username: "alice".into(), + ticker: "second".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 2); + assert_eq!( + board.iter().find(|t| t.username == "alice").unwrap().ticker, + "second" + ); +} + +#[test] +fn a_removed_ticker_leaves_the_rest_of_the_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![ + RoomTicker { + username: "alice".into(), + ticker: "a".into(), + }, + RoomTicker { + username: "bob".into(), + ticker: "b".into(), + }, + ], + }); + ctx.apply_room_event(RoomEvent::TickerRemoved { + room: "jazz".into(), + username: "alice".into(), + }); + + let board = ctx.room_tickers("jazz"); + assert_eq!(board.len(), 1); + assert_eq!(board[0].username, "bob"); +} + +#[test] +fn a_global_message_is_queued_without_touching_room_membership() { + // The global feed carries messages from rooms we have not joined; they + // must not invent a roster for those rooms. + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::GlobalMessage { + room: "elsewhere".into(), + username: "alice".into(), + message: "hi".into(), + }); + assert!(ctx.room_members("elsewhere").is_empty()); + assert_eq!(ctx.take_room_events().len(), 1); +} + +#[test] +fn a_private_room_roster_tracks_who_is_added_and_removed() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["bob".into(), "alice".into(), "bob".into()], + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["alice".to_string(), "bob".to_string()], + "a roster is sorted and free of repeats" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "carol".into(), + members: true, + added: true, + }); + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: true, + added: false, + }); + assert_eq!( + ctx.private_room_members("club"), + vec!["bob".to_string(), "carol".to_string()] + ); + + // Operators are a separate roster in the same room. + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "bob".into(), + members: false, + added: true, + }); + assert_eq!(ctx.private_room_operators("club"), vec!["bob".to_string()]); + assert_eq!(ctx.private_rooms(), vec!["club".to_string()]); +} + +#[test] +fn revoked_membership_drops_the_room_we_can_no_longer_see() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: true, + granted: false, + }); + assert!(ctx.private_rooms().is_empty()); + assert!(ctx.private_room_operators("club").is_empty()); +} + +#[test] +fn losing_operatorship_keeps_the_roster_we_can_still_see() { + // Demotion does not blind us: we are still a member, and the room's other + // operators are still ours to show. The server narrates our own removal + // from that roster separately (code 144). + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::PrivateMembers { + room: "club".into(), + users: vec!["alice".into()], + }); + ctx.apply_room_event(RoomEvent::PrivateOperators { + room: "club".into(), + users: vec!["alice".into(), "bob".into()], + }); + + ctx.apply_room_event(RoomEvent::OwnStandingChanged { + room: "club".into(), + members: false, + granted: false, + }); + assert_eq!(ctx.private_room_members("club"), vec!["alice".to_string()]); + assert_eq!( + ctx.private_room_operators("club"), + vec!["alice".to_string(), "bob".to_string()], + "the other operators are still there to show" + ); + + ctx.apply_room_event(RoomEvent::PrivateRosterChanged { + room: "club".into(), + username: "alice".into(), + members: false, + added: false, + }); + assert_eq!( + ctx.private_room_operators("club"), + vec!["bob".to_string()], + "only the demotion the server narrates removes us" + ); +} + +#[test] +fn leaving_a_room_drops_its_ticker_board() { + let mut ctx = ClientContext::new(); + ctx.apply_room_event(RoomEvent::Tickers { + room: "jazz".into(), + tickers: vec![RoomTicker { + username: "alice".into(), + ticker: "hi".into(), + }], + }); + ctx.apply_room_event(RoomEvent::Left { + room: "jazz".into(), + }); + assert!( + ctx.room_tickers("jazz").is_empty(), + "a board for a room we left is stale" + ); +} diff --git a/soulseek-rs-lib/src/client/tests/search.rs b/soulseek-rs-lib/src/client/tests/search.rs new file mode 100644 index 0000000..f18fe93 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/search.rs @@ -0,0 +1,167 @@ +//! Answering a search against our shares, and what the client keeps of +//! the answers it gets back. + +use super::*; + +#[test] +fn build_search_response_matches_shares_and_echoes_token() { + let dir = std::env::temp_dir() + .join(format!("soulseek-searchresp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("probe_xyzzy.bin"), b"data").unwrap(); + let shares = Shares::scan(&dir).unwrap(); + + let response = + build_search_response(&shares, "me", 99, "xyzzy", true, 0, 0, &[]) + .expect("a matching share yields a response"); + let mut decoded = + crate::message::Message::new_with_data(response.get_buffer()); + decoded.set_pointer(8); + let result = SearchResult::new_from_message(&mut decoded).unwrap(); + assert_eq!(result.username, "me"); + assert_eq!(result.token, 99); + assert!(result.files.iter().any(|f| f.name.contains("probe_xyzzy"))); + + assert!( + build_search_response(&shares, "me", 1, "nomatch", true, 0, 0, &[]) + .is_none() + ); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn search_file_counts_cover_every_search_without_the_results() { + // The daemon polls these counts many times a second; they must come from + // a walk of the cache, never a copy of it. + let client = Client::new("u", "p"); + { + let mut context = client.context.write().unwrap(); + context.searches.insert( + "aphex twin".to_string(), + Search { + token: 1, + results: vec![peer_files(2), peer_files(1)], + }, + ); + context.searches.insert( + "nothing yet".to_string(), + Search { + token: 2, + results: Vec::new(), + }, + ); + } + + let mut counts = client.search_file_counts(); + counts.sort(); + assert_eq!( + counts, + [ + ("aphex twin".to_string(), 3), + ("nothing yet".to_string(), 0) + ] + ); +} + +#[test] +fn a_search_stops_collecting_once_it_has_enough_responses() { + // A popular query on the live network draws answers for minutes โ€” + // nearly a million files and gigabytes of memory for one search. + // Surplus responders are dropped, not archived. + let mut search = Search { + token: 1, + results: Vec::new(), + }; + for _ in 0..(crate::types::MAX_SEARCH_RESPONSES + 50) { + search.accept(peer_files(1)); + } + assert_eq!(search.results.len(), crate::types::MAX_SEARCH_RESPONSES); +} + +#[test] +fn a_flood_of_files_fills_a_search_before_the_response_cap() { + // A handful of whales with huge matching collections must not add up + // to an unbounded set just because the responses are few. + let mut search = Search { + token: 1, + results: Vec::new(), + }; + for _ in 0..10 { + search.accept(peer_files(crate::types::MAX_SEARCH_FILES / 2)); + } + assert_eq!( + search.results.len(), + 2, + "two half-cap responses fill the search; the rest are dropped" + ); +} + +#[test] +fn a_file_the_server_excludes_is_left_out_of_a_reply() { + // The server's excluded phrases (code 160) police what travels the search + // network: a matching file whose path carries one must not be offered, + // and a reply with nothing left is not sent at all. + let dir = std::env::temp_dir() + .join(format!("soulseek-excluded-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("Spam_Xyzzy.bin"), b"data").unwrap(); + let shares = Shares::scan(&dir).unwrap(); + + assert!( + build_search_response(&shares, "me", 1, "xyzzy", true, 0, 0, &[]) + .is_some(), + "with no exclusions the file is offered" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["spam".to_string()], + ) + .is_none(), + "an excluded phrase in the path, matched case-insensitively, \ + withholds the file โ€” and with no files there is no reply" + ); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &["unrelated".to_string()], + ) + .is_some(), + "an exclusion the path does not carry changes nothing" + ); + + // The phrases are lowercased where they arrive, so a server that sends + // one capitalised still matches a path. + let mut ctx = ClientContext::new(); + ctx.set_excluded_search_phrases(vec!["SPAM".to_string()]); + assert!( + build_search_response( + &shares, + "me", + 1, + "xyzzy", + true, + 0, + 0, + &ctx.excluded_search_phrases(), + ) + .is_none(), + "a capitalised phrase from the server still withholds the file" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/soulseek-rs-lib/src/client/tests/transfers.rs b/soulseek-rs-lib/src/client/tests/transfers.rs new file mode 100644 index 0000000..dbabdd3 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/transfers.rs @@ -0,0 +1,329 @@ +//! Downloads through the context: what is queued, paused, cancelled, +//! and what a peer's answer is allowed to start. + +use super::*; + +#[test] +fn test_client_context_downloads() { + let mut context = ClientContext::new(); + let token = 123; + let new_token = 1234; + context.add_download(download( + "test", + "test.txt", + token, + DownloadStatus::Queued, + mpsc::channel().0, + )); + assert!(context.get_download_by_token(123).is_some()); + assert_eq!(context.get_download_tokens(), vec![123]); + assert_eq!(context.get_downloads().len(), 1); + if let Some(download) = context.get_download_by_token_mut(token) { + assert_eq!(download.token, token); + download.token = new_token; + } + assert!(context.get_download_by_token(new_token).is_some()); + assert_eq!(context.get_download_tokens(), vec![new_token]); + context.remove_download(new_token); + assert_eq!(context.get_downloads().len(), 0); + assert!(context.get_download_by_token(1234).is_none()); +} + +#[test] +fn test_client_pause_and_resume_download() { + let client = Client::new("test-user", "test-password"); + let (download_sender, download_receiver) = mpsc::channel(); + + client.context.write().unwrap().add_download(download( + "peer", + "song.mp3", + 123, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + download_sender, + )); + + assert!(client.pause_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + assert!(matches!( + download_receiver.try_recv().unwrap(), + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + + assert!(client.resume_download("peer", "song.mp3")); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(123) + .unwrap() + .status, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 0.0 + } + )); +} + +#[test] +fn download_without_a_connection_resolves_failed() { + // A client that never connected has no server handle and no peer registry, + // so it cannot open a connection to the peer: the download must resolve to + // Failed rather than hang Queued forever. + let client = Client::new("test-user", "test-password"); + let (_download, receiver) = client + .download( + "song.mp3".to_string(), + "peer".to_string(), + 100, + "test".to_string(), + ) + .expect("download() should return a handle"); + assert!(matches!( + receiver.recv_timeout(Duration::from_secs(1)), + Ok(DownloadStatus::Failed(_)) + )); +} + +#[test] +fn fail_queued_downloads_notifies_receiver_and_store() { + // When a brokered connect times out, every Queued download for the peer + // must resolve to Failed both on its channel and in the store. + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 7, + DownloadStatus::Queued, + sender, + )); + + Client::fail_queued_downloads(&client.context, "peer"); + + assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Failed(_)))); + assert!(matches!( + client + .context + .read() + .unwrap() + .get_download_by_token(7) + .unwrap() + .status, + DownloadStatus::Failed(_) + )); +} + +#[test] +fn test_client_removes_only_queued_downloads() { + let client = Client::new("test-user", "test-password"); + { + let mut context = client.context.write().unwrap(); + context.add_download(download( + "peer", + "queued.mp3", + 123, + DownloadStatus::Queued, + mpsc::channel().0, + )); + context.add_download(download( + "peer", + "active.mp3", + 456, + DownloadStatus::InProgress { + bytes_downloaded: 25, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + mpsc::channel().0, + )); + } + + assert!(client.remove_queued_download("peer", "queued.mp3")); + assert!(!client.remove_queued_download("peer", "active.mp3")); + let context = client.context.read().unwrap(); + assert!(context.get_download_by_token(123).is_none()); + assert!(context.get_download_by_token(456).is_some()); +} + +#[test] +fn an_expired_broker_connect_fails_the_queued_downloads() { + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + { + let mut ctx = client.context.write().unwrap(); + ctx.add_download(download( + "ghost", + "f.mp3", + 7, + DownloadStatus::Queued, + sender, + )); + ctx.pending_connect_tokens + .insert(7, ("ghost".to_string(), Instant::now())); + } + let (_ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + + let status = receiver.recv_timeout(Duration::from_secs(5)); + assert!( + matches!(status, Ok(DownloadStatus::Failed(_))), + "the sweep must fail the queued download, got {status:?}" + ); +} + +#[test] +fn a_replayed_transfer_response_does_not_start_a_second_transfer() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = u32::from(listener.local_addr().unwrap().port()); + let download_dir = std::env::temp_dir() + .join(format!("soulseek-replayed-transfer-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&download_dir); + std::fs::create_dir_all(&download_dir).unwrap(); + + let client = Client::new("u", "p"); + let (sender, _receiver) = mpsc::channel(); + let mut queued = + download("peer", "f.mp3", 9, DownloadStatus::Queued, sender); + queued.download_directory = download_dir.display().to_string(); + client.context.write().unwrap().add_download(queued); + + let (ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + + let peer = Peer::new( + "peer".to_string(), + ConnectionType::F, + "127.0.0.1".to_string(), + port, + None, + 0, + 0, + 0, + ); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer.clone(), true)) + .unwrap(); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer, true)) + .unwrap(); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut connections = 0; + while Instant::now() < deadline { + match listener.accept() { + Ok(_) => { + connections += 1; + if connections == 1 { + thread::sleep(Duration::from_millis(500)); + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if connections >= 1 { + break; + } + thread::sleep(Duration::from_millis(20)); + } + Err(_) => break, + } + } + assert_eq!( + connections, 1, + "a replayed TransferResponse must not dial the peer again" + ); + let _ = std::fs::remove_dir_all(download_dir); +} + +#[test] +fn a_cancelled_download_is_not_started_when_the_peer_allows_it() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let port = u32::from(listener.local_addr().unwrap().port()); + + let client = Client::new("u", "p"); + let (sender, _receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 9, + DownloadStatus::Cancelled, + sender, + )); + + let (ops_tx, ops_rx) = mpsc::channel(); + Client::listen_to_client_operations( + ops_rx, + client.context.clone(), + "u".to_string(), + ); + let peer = Peer::new( + "peer".to_string(), + ConnectionType::F, + "127.0.0.1".to_string(), + port, + None, + 0, + 0, + 0, + ); + ops_tx + .send(ClientOperation::DownloadFromPeer(9, peer, true)) + .unwrap(); + + thread::sleep(Duration::from_millis(500)); + assert!( + matches!(listener.accept(), Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock), + "a cancelled download must not dial the peer" + ); +} + +#[test] +fn cancel_download_marks_the_store_and_answers_whether_it_matched() { + let client = Client::new("u", "p"); + let (sender, receiver) = mpsc::channel(); + client.context.write().unwrap().add_download(download( + "peer", + "f.mp3", + 9, + DownloadStatus::Queued, + sender, + )); + + assert!(client.cancel_download("peer", "f.mp3")); + assert!(!client.cancel_download("peer", "f.mp3")); + assert!(!client.cancel_download("peer", "other.mp3")); + assert!(matches!(receiver.try_recv(), Ok(DownloadStatus::Cancelled))); + assert!(matches!( + client.get_all_downloads()[0].status, + DownloadStatus::Cancelled + )); +} diff --git a/soulseek-rs-lib/src/client/tests/users.rs b/soulseek-rs-lib/src/client/tests/users.rs new file mode 100644 index 0000000..4094b04 --- /dev/null +++ b/soulseek-rs-lib/src/client/tests/users.rs @@ -0,0 +1,200 @@ +//! What the server tells us about people: watches, interests, and the +//! two halves of a user snapshot. + +use super::*; + +#[test] +fn a_fresh_request_discards_the_previous_answer() { + // Without this, a poll after a second request returns the old + // snapshot immediately and the caller cannot tell stale from fresh. + let mut context = ClientContext::new(); + context.apply_user_status("alice".to_string(), 2, false); + context.apply_user_stats("alice".to_string(), 10, 20, 30); + assert!(context.user_info("alice").is_some_and(|i| i.is_complete())); + + context.invalidate_user_info("alice"); + assert!( + context.user_info("alice").is_none(), + "a new request must not be answerable from the old reply" + ); +} + +#[test] +fn each_reply_fills_only_its_own_half() { + let mut context = ClientContext::new(); + context.apply_user_status("bob".to_string(), 1, true); + + let info = context.user_info("bob").expect("a snapshot"); + assert!(!info.is_complete(), "stats have not arrived"); + assert_eq!( + info.presence.map(|p| p.status), + Some(crate::types::UserStatus::Away) + ); + assert!(info.stats.is_none(), "must not invent statistics"); + + context.apply_user_stats("bob".to_string(), 5, 6, 7); + let info = context.user_info("bob").expect("a snapshot"); + assert!(info.is_complete()); + assert_eq!(info.stats.map(|s| s.shared_files), Some(6)); + assert_eq!( + info.presence.map(|p| p.privileged), + Some(true), + "the earlier half must survive the merge" + ); +} + +#[test] +fn a_watch_reply_fills_both_halves_of_the_snapshot() { + let mut context = ClientContext::new(); + context.add_watched_user("alice"); + context.apply_watched_user( + "alice".to_string(), + true, + Some(2), + Some(1024), + Some(20), + Some(3), + ); + + let info = context.user_info("alice").expect("a snapshot"); + assert!(info.is_complete(), "a watch reply carries status and stats"); + assert_eq!(info.stats.map(|s| s.average_speed), Some(1024)); + assert_eq!(context.watched_users(), vec!["alice".to_string()]); +} + +#[test] +fn a_watch_reply_keeps_a_privileged_flag_it_cannot_carry() { + // WatchUser has no privileged field, so it must not overwrite what + // GetUserStatus already told us with a fabricated `false`. + let mut context = ClientContext::new(); + context.apply_user_status("bob".to_string(), 1, true); + context.apply_watched_user( + "bob".to_string(), + true, + Some(2), + Some(1), + Some(2), + Some(3), + ); + + let info = context.user_info("bob").expect("a snapshot"); + assert_eq!(info.presence.map(|p| p.privileged), Some(true)); +} + +#[test] +fn watching_an_unknown_user_drops_them_from_the_list() { + // The server will never push status for a name it does not know, so + // keeping it in the watch list would show a permanently blank row. + let mut context = ClientContext::new(); + context.add_watched_user("ghost"); + context.apply_watched_user( + "ghost".to_string(), + false, + None, + None, + None, + None, + ); + + assert!(context.watched_users().is_empty()); + assert!(context.user_info("ghost").is_none()); +} + +#[test] +fn unwatching_forgets_the_users_snapshot() { + let mut context = ClientContext::new(); + context.add_watched_user("alice"); + context.apply_user_status("alice".to_string(), 2, false); + + context.remove_watched_user("alice"); + assert!(context.watched_users().is_empty()); + assert!( + context.user_info("alice").is_none(), + "a later re-watch must report a fresh answer" + ); +} + +#[test] +fn watched_users_are_listed_in_a_stable_order() { + let mut context = ClientContext::new(); + context.add_watched_user("carol"); + context.add_watched_user("alice"); + context.add_watched_user("bob"); + assert_eq!(context.watched_users(), vec!["alice", "bob", "carol"]); +} + +// A reply queued for a searcher the server cannot place waits a minute, +// not forever: every such search would otherwise pin a map entry and a +// protected slot in the peer registry. + +#[test] +fn own_and_global_recommendations_are_kept_apart() { + let mut ctx = ClientContext::new(); + ctx.apply_recommendations( + false, + vec![Recommendation { + item: "jazz".into(), + rating: 2, + }], + Vec::new(), + ); + ctx.apply_recommendations( + true, + vec![Recommendation { + item: "pop".into(), + rating: 9, + }], + Vec::new(), + ); + + assert_eq!(ctx.recommendations(false).unwrap().0[0].item, "jazz"); + assert_eq!(ctx.recommendations(true).unwrap().0[0].item, "pop"); +} + +#[test] +fn asking_again_about_a_users_interests_drops_the_previous_answer() { + let mut ctx = ClientContext::new(); + ctx.apply_user_interests(UserInterests { + username: "alice".into(), + likes: vec!["jazz".into()], + hates: Vec::new(), + }); + assert!(ctx.user_interests("alice").is_some()); + + ctx.invalidate_user_interests("alice"); + assert!( + ctx.user_interests("alice").is_none(), + "a stale answer must not be mistaken for the next one" + ); +} + +#[test] +fn our_own_interests_are_kept_lowercased_and_deduplicated() { + // The server matches interests case-insensitively and forgets them when + // the session ends, so they are held here in one spelling to be sent + // again next login. + let mut ctx = ClientContext::new(); + assert_eq!( + ctx.add_own_interest(" Krautrock ", true).as_deref(), + Some("krautrock") + ); + assert_eq!( + ctx.add_own_interest(" ", true), + None, + "an empty interest is not stored, and is not sent either" + ); + ctx.add_own_interest("KRAUTROCK", true); + ctx.add_own_interest("Muzak", false); + + let interests = ctx.own_interests(); + assert_eq!(interests.likes, ["krautrock"]); + assert_eq!(interests.hates, ["muzak"]); + + ctx.remove_own_interest("KrautRock", true); + assert!(ctx.own_interests().likes.is_empty()); + assert_eq!( + ctx.own_interests().hates, + ["muzak"], + "the other list stands" + ); +} diff --git a/soulseek-rs-lib/src/download_store.rs b/soulseek-rs-lib/src/download_store.rs index 3cd9699..02f274a 100644 --- a/soulseek-rs-lib/src/download_store.rs +++ b/soulseek-rs-lib/src/download_store.rs @@ -61,13 +61,45 @@ impl DownloadStore { let Some(download) = self.get_by_token_mut(token) else { return false; }; - if matches!(download.status, DownloadStatus::Cancelled) { + if download.status.is_terminal() + || matches!( + (&download.status, &status), + ( + DownloadStatus::Paused { .. }, + DownloadStatus::InProgress { .. } + ) + ) + { return false; } download.status = status; true } + /// Atomically reserve a queued download for the peer that owns it. + /// + /// Every file-connection direction uses this before opening a partial, so + /// two sockets cannot both observe `Queued` and write the same target. + pub fn claim_for_peer( + &mut self, + token: u32, + username: &str, + ) -> Option { + let download = self.get_by_token_mut(token)?; + if download.username != username + || !matches!(download.status, DownloadStatus::Queued) + { + return None; + } + + download.status = DownloadStatus::InProgress { + bytes_downloaded: 0, + total_bytes: download.size, + speed_bytes_per_sec: 0.0, + }; + Some(download.clone()) + } + pub fn cancel_by_file( &mut self, username: &str, @@ -373,6 +405,52 @@ mod tests { )); } + #[test] + fn progress_cannot_implicitly_resume_a_paused_download() { + let mut store = DownloadStore::new(); + store.add(make_download( + 1, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100, + }, + )); + + assert!(!store.update_status( + 1, + DownloadStatus::InProgress { + bytes_downloaded: 50, + total_bytes: 100, + speed_bytes_per_sec: 10.0, + }, + )); + assert!(matches!( + store.get_by_token(1).unwrap().status, + DownloadStatus::Paused { + bytes_downloaded: 25, + total_bytes: 100 + } + )); + } + + #[test] + fn a_download_can_only_be_claimed_once_and_by_its_peer() { + let mut store = DownloadStore::new(); + store.add(make_download(1, DownloadStatus::Queued)); + + assert!(store.claim_for_peer(1, "other").is_none()); + let claimed = store.claim_for_peer(1, "peer").unwrap(); + assert!(matches!( + claimed.status, + DownloadStatus::InProgress { + bytes_downloaded: 0, + total_bytes: 100, + speed_bytes_per_sec: 0.0 + } + )); + assert!(store.claim_for_peer(1, "peer").is_none()); + } + #[test] fn remove_queued_skips_active_downloads() { let mut store = DownloadStore::new(); diff --git a/soulseek-rs-lib/src/lib.rs b/soulseek-rs-lib/src/lib.rs index f9474a5..a174fbd 100644 --- a/soulseek-rs-lib/src/lib.rs +++ b/soulseek-rs-lib/src/lib.rs @@ -22,9 +22,10 @@ pub mod utils; pub mod prelude { pub use crate::actor::server_actor::PeerAddress; pub use crate::types::{ - ClientVersion, DownloadStatus, File, RoomEvent, RoomInfo, Search, - SearchResult, Transfer, UploadInfo, UploadStatus, UserInfo, - UserPresence, UserStats, UserStatus, + ClientVersion, DownloadStatus, File, Recommendation, RoomEvent, + RoomInfo, RoomTicker, Search, SearchResult, SimilarUser, Transfer, + UploadInfo, UploadStatus, UserInfo, UserInterests, UserPresence, + UserStats, UserStatus, }; pub use crate::{debug, error, info, trace, warn}; } @@ -36,7 +37,8 @@ pub use error::{Result, SoulseekRs}; pub use message::peer::{SharedDirectory, SharedFileEntry}; pub use peer::{ConnectionType, ParseConnectionTypeError}; pub use types::{ - ClientVersion, DownloadStatus, File, RoomEvent, RoomInfo, Search, - SearchResult, SessionLoss, Transfer, UploadInfo, UploadStatus, UserInfo, - UserPresence, UserStats, UserStatus, + ClientVersion, DownloadStatus, File, Recommendation, RoomEvent, RoomInfo, + RoomTicker, Search, SearchResult, SessionLoss, SimilarUser, Transfer, + UploadInfo, UploadStatus, UserInfo, UserInterests, UserPresence, UserStats, + UserStatus, }; diff --git a/soulseek-rs-lib/src/message/distributed.rs b/soulseek-rs-lib/src/message/distributed.rs index 2ae015c..9917ce6 100644 --- a/soulseek-rs-lib/src/message/distributed.rs +++ b/soulseek-rs-lib/src/message/distributed.rs @@ -108,12 +108,17 @@ pub fn build_branch_level(level: i32) -> Message { /// What we tell the server about our place in the tree: the four messages /// Nicotine+ sends together whenever it changes. #[must_use] -pub fn stance(root: &str, level: u32, has_parent: bool) -> Vec { +pub fn stance( + root: &str, + level: u32, + has_parent: bool, + accept_children: bool, +) -> Vec { vec![ MessageFactory::build_have_no_parent(!has_parent), MessageFactory::build_branch_root(root), MessageFactory::build_branch_level(level), - MessageFactory::build_accept_children(false), + MessageFactory::build_accept_children(accept_children), ] } diff --git a/soulseek-rs-lib/src/message/peer/folder_contents.rs b/soulseek-rs-lib/src/message/peer/folder_contents.rs index cf0f6f8..48a4bf9 100644 --- a/soulseek-rs-lib/src/message/peer/folder_contents.rs +++ b/soulseek-rs-lib/src/message/peer/folder_contents.rs @@ -26,6 +26,37 @@ impl MessageHandler for FolderContentsRequest { } } +/// `FolderContentsResponse` (peer code 37): the reply to a folder we asked +/// for. The token is the one we quoted, so a client with two folders in +/// flight can tell the answers apart. +pub struct FolderContentsResponseHandler; +impl MessageHandler for FolderContentsResponseHandler { + fn get_code(&self) -> u32 { + 37 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let Some((token, folder, directories)) = parse_folder_contents(message) + else { + return; + }; + let _ = sender.send(PeerMessage::FolderContentsReceived { + token, + folder, + directories, + }); + } +} + +/// Build a `FolderContentsRequest` (peer code 36). +#[must_use] +pub fn build_folder_contents_request(token: u32, folder: &str) -> Message { + Message::new() + .write_int32(36) + .write_int32(token) + .write_string(folder) + .clone() +} + /// Build a `FolderContentsResponse` (peer code 37). #[must_use] pub fn build_folder_contents( @@ -48,7 +79,7 @@ pub fn build_folder_contents( pub fn parse_folder_contents( message: &mut Message, ) -> Option<(u32, String, Vec)> { - let mut body = decompress_body(message)?; + let mut body = decompress_body(message, crate::utils::zlib::MAX_INFLATED)?; let token = body.read_int32(); let folder = body.read_string(); Some((token, folder, read_directories(&mut body))) diff --git a/soulseek-rs-lib/src/message/peer/mod.rs b/soulseek-rs-lib/src/message/peer/mod.rs index b3bba95..6ea8a40 100644 --- a/soulseek-rs-lib/src/message/peer/mod.rs +++ b/soulseek-rs-lib/src/message/peer/mod.rs @@ -17,7 +17,9 @@ pub use file_search_response::{ FileEntry, FileSearchResponse, build_file_search_response, }; pub use folder_contents::{ - FolderContentsRequest, build_folder_contents, parse_folder_contents, + FolderContentsRequest, FolderContentsResponseHandler, + build_folder_contents, build_folder_contents_request, + parse_folder_contents, }; pub use get_share_file_list::GetShareFileList; pub use peer_init::PeerInit; @@ -32,4 +34,7 @@ pub use transfer_request::TransferRequest; pub use transfer_response::TransferResponse; pub use upload_denied::UploadDeniedHandler; pub use upload_failed::UploadFailedHandler; -pub use user_info::{UserInfoRequest, build_user_info}; +pub use user_info::{ + PeerInfo, UserInfoRequest, UserInfoResponseHandler, build_user_info, + build_user_info_request, +}; diff --git a/soulseek-rs-lib/src/message/peer/shared_file_list.rs b/soulseek-rs-lib/src/message/peer/shared_file_list.rs index cc60817..60084e5 100644 --- a/soulseek-rs-lib/src/message/peer/shared_file_list.rs +++ b/soulseek-rs-lib/src/message/peer/shared_file_list.rs @@ -4,7 +4,7 @@ use crate::message::{Message, MessageHandler}; use crate::peer::PeerMessage; -use crate::utils::zlib::{deflate, inflate}; +use crate::utils::zlib::{MAX_INFLATED_LISTING, deflate, inflate_limited}; use std::sync::mpsc::Sender; /// One file in a shared directory: its basename, size and the `(code, value)` @@ -68,7 +68,8 @@ pub fn build_shared_file_list(dirs: &[SharedDirectory]) -> Message { /// Returns an empty listing if the payload is malformed. #[must_use] pub fn parse_shared_file_list(message: &mut Message) -> Vec { - decompress_body(message) + // A whole share listing is the one payload that legitimately runs large. + decompress_body(message, MAX_INFLATED_LISTING) .map_or_else(Vec::new, |mut body| read_directories(&mut body)) } @@ -135,11 +136,15 @@ pub fn read_directories(body: &mut Message) -> Vec { dirs } -/// Inflate a compressed peer payload positioned at the blob. -pub fn decompress_body(message: &mut Message) -> Option { +/// Inflate a compressed peer payload positioned at the blob, refusing one +/// that expands past `max_out` โ€” a peer we have never met chose these bytes. +pub fn decompress_body( + message: &mut Message, + max_out: usize, +) -> Option { let pointer = message.get_pointer(); let size = message.get_size(); - inflate(&message.get_slice(pointer, size)) + inflate_limited(&message.get_slice(pointer, size), max_out) .ok() .map(Message::new_with_data) } diff --git a/soulseek-rs-lib/src/message/peer/user_info.rs b/soulseek-rs-lib/src/message/peer/user_info.rs index 0887ad8..ee246fd 100644 --- a/soulseek-rs-lib/src/message/peer/user_info.rs +++ b/soulseek-rs-lib/src/message/peer/user_info.rs @@ -15,6 +15,71 @@ impl MessageHandler for UserInfoRequest { } } +/// Build a `UserInfoRequest` (peer code 15, no body): ask a peer what it says +/// about itself. +#[must_use] +pub fn build_user_info_request() -> Message { + Message::new().write_int32(15).clone() +} + +/// What a peer says about itself, from its `UserInfoResponse` (code 16). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PeerInfo { + pub description: String, + /// Whether a profile picture came with it, and how many bytes it was. The + /// image itself is not kept: no caller has a use for it yet, and a peer + /// can make it arbitrarily large. + pub picture_bytes: Option, + /// Uploads the peer says it has done in total. + pub total_uploads: u32, + /// How many transfers are waiting in its queue. + pub queue_size: u32, + /// Whether it has a free upload slot right now. + pub slots_free: bool, + /// Whether it would let us queue anything at all. Absent from older + /// clients, which stop the message one field early. + pub upload_allowed: Option, +} + +/// `UserInfoResponse` (peer code 16): a peer's answer about itself. +pub struct UserInfoResponseHandler; +impl MessageHandler for UserInfoResponseHandler { + fn get_code(&self) -> u32 { + 16 + } + fn handle(&self, message: &mut Message, sender: Sender) { + let description = message.read_string(); + let has_picture = message.read_bool(); + let picture_bytes = has_picture.then(|| { + let length = message.read_int32() as usize; + let available = + message.get_size().saturating_sub(message.get_pointer()); + let length = length.min(available); + let at = message.get_pointer() + length; + message.set_pointer(at); + length + }); + let total_uploads = message.read_int32(); + let queue_size = message.read_int32(); + let slots_free = message.read_bool(); + // Museek+ writes `slotsavail` as an integer, leaving three bytes of + // rubbish where the next field starts; Nicotine+ guards the read the + // same way, by only taking a field there is room for. + let upload_allowed = + (message.get_size().saturating_sub(message.get_pointer()) >= 4) + .then(|| message.read_int32()); + + let _ = sender.send(PeerMessage::UserInfoReceived(PeerInfo { + description, + picture_bytes, + total_uploads, + queue_size, + slots_free, + upload_allowed, + })); + } +} + /// Build a `UserInfoResponse` (peer code 16) with no description or picture. #[must_use] pub fn build_user_info( @@ -31,3 +96,86 @@ pub fn build_user_info( .write_bool(slots_free) .clone() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + fn parse(build: impl FnOnce(&mut Message)) -> PeerInfo { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(build); + UserInfoResponseHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(PeerMessage::UserInfoReceived(info)) => info, + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_peers_answer_carries_its_slots_and_queue() { + let info = parse(|m| { + m.write_string("hello, I share flacs"); + m.write_bool(false); + m.write_int32(42); // total uploads + m.write_int32(3); // queue size + m.write_bool(true); // a slot is free + m.write_int32(1); // uploads allowed + }); + + assert_eq!(info.description, "hello, I share flacs"); + assert_eq!(info.picture_bytes, None); + assert_eq!(info.total_uploads, 42); + assert_eq!(info.queue_size, 3); + assert!(info.slots_free); + assert_eq!(info.upload_allowed, Some(1)); + } + + #[test] + fn a_picture_is_skipped_over_rather_than_kept() { + let info = parse(|m| { + m.write_string("with a picture"); + m.write_bool(true); + m.write_int32(4); + m.write_raw_bytes(vec![1, 2, 3, 4]); + m.write_int32(7); + m.write_int32(0); + m.write_bool(false); + m.write_int32(0); + }); + + assert_eq!(info.picture_bytes, Some(4)); + assert_eq!(info.total_uploads, 7, "the fields after it still line up"); + } + + #[test] + fn a_message_that_stops_early_still_parses() { + // Older clients omit the trailing field, and Museek+ leaves rubbish + // where it would be; neither may cost us the fields before it. + let info = parse(|m| { + m.write_string("older client"); + m.write_bool(false); + m.write_int32(5); + m.write_int32(1); + m.write_bool(true); + }); + + assert_eq!(info.total_uploads, 5); + assert_eq!(info.queue_size, 1); + assert!(info.slots_free); + assert_eq!(info.upload_allowed, None); + } + + #[test] + fn a_picture_longer_than_the_message_does_not_run_off_the_end() { + let info = parse(|m| { + m.write_string("hostile"); + m.write_bool(true); + m.write_int32(u32::MAX); + m.write_raw_bytes(vec![9, 9]); + }); + + assert_eq!(info.picture_bytes, Some(2)); + assert_eq!(info.total_uploads, 0, "nothing left to read reads as zero"); + } +} diff --git a/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs b/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs new file mode 100644 index 0000000..184e1f2 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/cant_connect_to_peer.rs @@ -0,0 +1,49 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// `CantConnectToPeer` (code 1001): a brokered peer gave up reaching us. +/// +/// The token is the one we quoted in our own `ConnectToPeer` (code 18), and it +/// is the only field the server relays โ€” the username is not repeated, so the +/// token is what a client matches the pending attempt on. +/// +/// Without this the download sits in "connecting" until a timeout; with it the +/// wait ends the moment the peer gives up. +pub struct CantConnectToPeerHandler; + +impl MessageHandler for CantConnectToPeerHandler { + fn get_code(&self) -> u32 { + 1001 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let token = message.read_int32(); + let _ = sender.send(ServerMessage::CantConnectToPeer { token }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_token_of_the_failed_attempt_is_reported() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(4242); + }); + + CantConnectToPeerHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::CantConnectToPeer { token }) => { + assert_eq!(token, 4242); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs b/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs index 6779bc1..2233529 100644 --- a/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs +++ b/soulseek-rs-lib/src/message/server/excluded_search_phrases.rs @@ -12,7 +12,7 @@ impl MessageHandler for ExcludedSearchPhrasesHandler { 160 } - fn handle(&self, message: &mut Message, _sender: Sender) { + fn handle(&self, message: &mut Message, sender: Sender) { let item_count = message.read_int32(); let mut exluded_phrases: Vec = Vec::new(); @@ -25,6 +25,11 @@ impl MessageHandler for ExcludedSearchPhrasesHandler { exluded_phrases.push(phrase); } debug!("Excluded search phrases: {:?}", exluded_phrases); + // Kept, not just logged: the server rejects a search carrying one of + // these, so a client that forgets them spends its search allowance on + // queries that were never going to be answered. + let _ = + sender.send(ServerMessage::ExcludedSearchPhrases(exluded_phrases)); } } @@ -43,4 +48,22 @@ mod tests { }); ExcludedSearchPhrasesHandler.handle(&mut message, tx); } + + #[test] + fn the_phrases_are_reported_not_only_logged() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(2); + m.write_string("banned"); + m.write_string("blocked"); + }); + + ExcludedSearchPhrasesHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ExcludedSearchPhrases(phrases)) => { + assert_eq!(phrases, ["banned", "blocked"]); + } + other => panic!("unexpected: {other:?}"), + } + } } diff --git a/soulseek-rs-lib/src/message/server/global_room.rs b/soulseek-rs-lib/src/message/server/global_room.rs new file mode 100644 index 0000000..7e8f704 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/global_room.rs @@ -0,0 +1,58 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// `GlobalRoomMessage` (code 152): a message said in some public room, +/// delivered because we subscribed to the global feed (code 150). +pub struct GlobalRoomMessageHandler; + +impl MessageHandler for GlobalRoomMessageHandler { + fn get_code(&self) -> u32 { + 152 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let text = message.read_string(); + let _ = sender.send(ServerMessage::GlobalRoomMessageReceived { + room, + username, + message: text, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_global_message_names_the_room_it_was_said_in() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + m.write_string("anyone here?"); + }); + + GlobalRoomMessageHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::GlobalRoomMessageReceived { + room, + username, + message, + }) => { + assert_eq!( + (room.as_str(), username.as_str(), message.as_str()), + ("jazz", "alice", "anyone here?") + ); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/interests.rs b/soulseek-rs-lib/src/message/server/interests.rs new file mode 100644 index 0000000..6d623a6 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/interests.rs @@ -0,0 +1,346 @@ +//! Interests and the queries built on them: recommendations (codes 54, 56, +//! 111), similar users (110, 112) and another user's interests (57). +//! +//! Ratings are signed on the wire: the server sends a descending list of +//! things it recommends and an ascending list of things it recommends +//! against, and the latter carry negative ratings. + +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, + types::{Recommendation, SimilarUser, UserInterests}, +}; + +/// Read a `[count][item][rating]...` vector, stopping if the payload runs out +/// before the count does. +fn read_recommendations(message: &mut Message) -> Vec { + let count = message.read_int32(); + let mut items = Vec::new(); + for _ in 0..count { + let item = message.read_string(); + if item.is_empty() { + break; + } + #[allow(clippy::cast_possible_wrap)] + let rating = message.read_int32() as i32; + items.push(Recommendation { item, rating }); + } + items +} + +/// Read a `[count][string]...` vector, stopping at the end of the payload. +fn read_strings(message: &mut Message) -> Vec { + let count = message.read_int32(); + let mut items = Vec::new(); + for _ in 0..count { + let item = message.read_string(); + if item.is_empty() { + break; + } + items.push(item); + } + items +} + +/// `GetRecommendations` (code 54): recommendations drawn from our own +/// interests. +pub struct RecommendationsHandler; + +impl MessageHandler for RecommendationsHandler { + fn get_code(&self) -> u32 { + 54 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let recommended = read_recommendations(message); + let unrecommended = read_recommendations(message); + let _ = sender.send(ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + }); + } +} + +/// `GlobalRecommendations` (code 56): the same shape, server-wide. +pub struct GlobalRecommendationsHandler; + +impl MessageHandler for GlobalRecommendationsHandler { + fn get_code(&self) -> u32 { + 56 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let recommended = read_recommendations(message); + let unrecommended = read_recommendations(message); + let _ = sender.send(ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + }); + } +} + +/// `ItemRecommendations` (code 111): what else people who like one item like. +pub struct ItemRecommendationsHandler; + +impl MessageHandler for ItemRecommendationsHandler { + fn get_code(&self) -> u32 { + 111 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let item = message.read_string(); + let recommendations = read_recommendations(message); + let _ = sender.send(ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + }); + } +} + +/// `SimilarUsers` (code 110): users whose interests overlap ours. +pub struct SimilarUsersHandler; + +impl MessageHandler for SimilarUsersHandler { + fn get_code(&self) -> u32 { + 110 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let count = message.read_int32(); + let mut users = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + if username.is_empty() { + break; + } + let weight = message.read_int32(); + users.push(SimilarUser { username, weight }); + } + let _ = sender.send(ServerMessage::SimilarUsersReceived { users }); + } +} + +/// `ItemSimilarUsers` (code 112): who likes one item. The server sends only +/// names here, so every weight reads as zero. +pub struct ItemSimilarUsersHandler; + +impl MessageHandler for ItemSimilarUsersHandler { + fn get_code(&self) -> u32 { + 112 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let item = message.read_string(); + let usernames = read_strings(message); + let _ = sender + .send(ServerMessage::ItemSimilarUsersReceived { item, usernames }); + } +} + +/// `UserInterests` (code 57): what one user likes and hates. +pub struct UserInterestsHandler; + +impl MessageHandler for UserInterestsHandler { + fn get_code(&self) -> u32 { + 57 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let username = message.read_string(); + let likes = read_strings(message); + let hates = read_strings(message); + let _ = + sender.send(ServerMessage::UserInterestsReceived(UserInterests { + username, + likes, + hates, + })); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn recommendations_split_into_recommended_and_not() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1); + m.write_string("jazz"); + m.write_int32(3); + m.write_int32(1); + m.write_string("polka"); + #[allow(clippy::cast_sign_loss)] + m.write_int32(-2i32 as u32); + }); + + RecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RecommendationsReceived { + recommended, + unrecommended, + }) => { + assert_eq!( + recommended, + vec![Recommendation { + item: "jazz".into(), + rating: 3 + }] + ); + assert_eq!( + unrecommended, + vec![Recommendation { + item: "polka".into(), + rating: -2 + }] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn global_recommendations_parse_the_same_shape() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1); + m.write_string("dub"); + m.write_int32(9); + m.write_int32(0); + }); + + GlobalRecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::GlobalRecommendationsReceived { + recommended, + unrecommended, + }) => { + assert_eq!(recommended.len(), 1); + assert_eq!(recommended[0].item, "dub"); + assert!(unrecommended.is_empty()); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn similar_users_carry_their_weight() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(2); + m.write_string("alice"); + m.write_int32(4); + m.write_string("bob"); + m.write_int32(1); + }); + + SimilarUsersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::SimilarUsersReceived { users }) => { + assert_eq!( + users, + vec![ + SimilarUser { + username: "alice".into(), + weight: 4 + }, + SimilarUser { + username: "bob".into(), + weight: 1 + }, + ] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn item_similar_users_report_the_item_they_answer_for() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1); + m.write_string("alice"); + }); + + ItemSimilarUsersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ItemSimilarUsersReceived { item, usernames }) => { + assert_eq!(item, "jazz"); + assert_eq!(usernames, vec!["alice".to_string()]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn item_recommendations_report_their_item() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1); + m.write_string("blues"); + m.write_int32(2); + }); + + ItemRecommendationsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ItemRecommendationsReceived { + item, + recommendations, + }) => { + assert_eq!(item, "jazz"); + assert_eq!(recommendations[0].item, "blues"); + assert_eq!(recommendations[0].rating, 2); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_users_likes_and_hates_arrive_as_two_lists() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("alice"); + m.write_int32(2); + m.write_string("jazz"); + m.write_string("dub"); + m.write_int32(1); + m.write_string("polka"); + }); + + UserInterestsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::UserInterestsReceived(interests)) => { + assert_eq!(interests.username, "alice"); + assert_eq!(interests.likes, ["jazz", "dub"]); + assert_eq!(interests.hates, ["polka"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_user_with_no_interests_parses_to_empty_lists() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("ghost"); + m.write_int32(0); + m.write_int32(0); + }); + + UserInterestsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::UserInterestsReceived(interests)) => { + assert!(interests.likes.is_empty()); + assert!(interests.hates.is_empty()); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/message_factory.rs b/soulseek-rs-lib/src/message/server/message_factory.rs index 620508a..4ea1ebe 100644 --- a/soulseek-rs-lib/src/message/server/message_factory.rs +++ b/soulseek-rs-lib/src/message/server/message_factory.rs @@ -301,6 +301,251 @@ impl MessageFactory { .clone() } + /// Ask a peer where the file we queued with them sits (peer code 51). + #[must_use] + pub fn build_place_in_queue_request(filename: &str) -> Message { + Message::new() + .write_int32(51) + .write_string(filename) + .clone() + } + + /// Invite `username` into the private room we own (server code 134). + #[must_use] + pub fn build_add_room_member(room: &str, username: &str) -> Message { + Message::new() + .write_int32(134) + .write_string(room) + .write_string(username) + .clone() + } + + /// Remove `username` from the private room we own (server code 135). + #[must_use] + pub fn build_remove_room_member(room: &str, username: &str) -> Message { + Message::new() + .write_int32(135) + .write_string(room) + .write_string(username) + .clone() + } + + /// Give up our own membership of a private room (server code 136). + #[must_use] + pub fn build_cancel_room_membership(room: &str) -> Message { + Message::new().write_int32(136).write_string(room).clone() + } + + /// Give up ownership of a private room, which deletes it (code 137). + #[must_use] + pub fn build_cancel_room_ownership(room: &str) -> Message { + Message::new().write_int32(137).write_string(room).clone() + } + + /// Say whether we accept private-room invitations (server code 141). + #[must_use] + pub fn build_enable_room_invitations(enabled: bool) -> Message { + Message::new() + .write_int32(141) + .write_int8(u8::from(enabled)) + .clone() + } + + /// Make `username` an operator of our private room (server code 143). + #[must_use] + pub fn build_add_room_operator(room: &str, username: &str) -> Message { + Message::new() + .write_int32(143) + .write_string(room) + .write_string(username) + .clone() + } + + /// Take operator status away from `username` (server code 144). + #[must_use] + pub fn build_remove_room_operator(room: &str, username: &str) -> Message { + Message::new() + .write_int32(144) + .write_string(room) + .write_string(username) + .clone() + } + + /// Give up our own operator status in a room (server code 147). + #[must_use] + pub fn build_cancel_room_operatorship(room: &str) -> Message { + Message::new().write_int32(147).write_string(room).clone() + } + + /// A keepalive ping (server code 32, no body). The server sends no reply; + /// its only job is to keep a quiet connection from being reaped by a NAT + /// or the server's own idle timeout, the way other clients ping. + #[must_use] + pub fn build_server_ping() -> Message { + Message::new().write_int32(32).clone() + } + + /// Search one user's shares (server code 42). Replies come back over a + /// peer connection as ordinary `FileSearchResponse` frames, so the token + /// is drawn from the same space as a plain `FileSearch`. + #[must_use] + pub fn build_user_search( + username: &str, + token: u32, + query: &str, + ) -> Message { + Message::new() + .write_int32(42) + .write_string(username) + .write_int32(token) + .write_string(query) + .clone() + } + + /// Search the shares of everyone in `room` (server code 120). + #[must_use] + pub fn build_room_search(room: &str, token: u32, query: &str) -> Message { + Message::new() + .write_int32(120) + .write_string(room) + .write_int32(token) + .write_string(query) + .clone() + } + + /// Tell the server (code 1001) that the peer it asked us to connect to, + /// quoting `token`, could not be reached. The server relays that to the + /// peer so it stops waiting on a connection that is never coming. + #[must_use] + pub fn build_cant_connect_to_peer(token: u32, username: &str) -> Message { + Message::new() + .write_int32(1001) + .write_int32(token) + .write_string(username) + .clone() + } + + /// Set our ticker (the scrolling one-line message) in `room`, server code + /// 116. An empty ticker removes ours. + #[must_use] + pub fn build_set_room_ticker(room: &str, ticker: &str) -> Message { + Message::new() + .write_int32(116) + .write_string(room) + .write_string(ticker) + .clone() + } + + /// Subscribe to the global room feed (server code 150): every public room + /// message on the server, tagged with the room it came from. + #[must_use] + pub fn build_join_global_room() -> Message { + Message::new().write_int32(150).clone() + } + + /// Stop the global room feed (server code 151). + #[must_use] + pub fn build_leave_global_room() -> Message { + Message::new().write_int32(151).clone() + } + + /// Add an interest (server code 51). Interests drive the recommendation + /// and similar-user queries below. + #[must_use] + pub fn build_add_thing_i_like(item: &str) -> Message { + Message::new().write_int32(51).write_string(item).clone() + } + + /// Drop an interest (server code 52). + #[must_use] + pub fn build_remove_thing_i_like(item: &str) -> Message { + Message::new().write_int32(52).write_string(item).clone() + } + + /// Add a dislike (server code 117). + #[must_use] + pub fn build_add_thing_i_hate(item: &str) -> Message { + Message::new().write_int32(117).write_string(item).clone() + } + + /// Drop a dislike (server code 118). + #[must_use] + pub fn build_remove_thing_i_hate(item: &str) -> Message { + Message::new().write_int32(118).write_string(item).clone() + } + + /// Ask for recommendations based on our own interests (server code 54). + #[must_use] + pub fn build_get_recommendations() -> Message { + Message::new().write_int32(54).clone() + } + + /// Ask for the server-wide recommendations (server code 56). + #[must_use] + pub fn build_global_recommendations() -> Message { + Message::new().write_int32(56).clone() + } + + /// Ask what `username` likes and hates (server code 57). + #[must_use] + pub fn build_user_interests(username: &str) -> Message { + Message::new() + .write_int32(57) + .write_string(username) + .clone() + } + + /// Ask for users whose interests overlap ours (server code 110). + #[must_use] + pub fn build_similar_users() -> Message { + Message::new().write_int32(110).clone() + } + + /// Ask what else people who like `item` like (server code 111). + #[must_use] + pub fn build_item_recommendations(item: &str) -> Message { + Message::new().write_int32(111).write_string(item).clone() + } + + /// Ask which users like `item` (server code 112). + #[must_use] + pub fn build_item_similar_users(item: &str) -> Message { + Message::new().write_int32(112).write_string(item).clone() + } + + /// Hand `days` of our own privileges to `username` (server code 123). + #[must_use] + pub fn build_give_privileges(username: &str, days: u32) -> Message { + Message::new() + .write_int32(123) + .write_string(username) + .write_int32(days) + .clone() + } + + /// Change our account password (server code 142). The server echoes the + /// accepted password back under the same code. + #[must_use] + pub fn build_change_password(password: &str) -> Message { + Message::new() + .write_int32(142) + .write_string(password) + .clone() + } + + /// Send one private message to several users at once (server code 149). + #[must_use] + pub fn build_message_users(usernames: &[String], message: &str) -> Message { + let mut msg = Message::new(); + msg.write_int32(149); + msg.write_int32(usernames.len() as u32); + for username in usernames { + msg.write_string(username); + } + msg.write_string(message); + msg + } + #[must_use] pub fn build_peer_init_message( own_username: &str, @@ -517,3 +762,189 @@ fn an_upload_speed_report_carries_the_rate() { decoded.set_pointer(8); assert_eq!(decoded.read_int32(), 900); } + +#[test] +fn a_place_in_queue_request_names_the_file() { + let message = MessageFactory::build_place_in_queue_request("a\\b.mp3"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 51); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "a\\b.mp3"); +} + +#[test] +fn a_ping_is_just_its_code() { + let message = MessageFactory::build_server_ping(); + assert_eq!(message.get_data(), [32, 0, 0, 0]); +} + +#[test] +fn a_user_search_names_the_user_before_its_token() { + let message = MessageFactory::build_user_search("bob", 99, "jazz"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 42); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "bob"); + assert_eq!(decoded.read_int32(), 99); + assert_eq!(decoded.read_string(), "jazz"); +} + +#[test] +fn a_room_search_names_the_room_before_its_token() { + let message = MessageFactory::build_room_search("nicotine", 7, "dub"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 120); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "nicotine"); + assert_eq!(decoded.read_int32(), 7); + assert_eq!(decoded.read_string(), "dub"); +} + +#[test] +fn a_cant_connect_carries_the_token_then_the_user() { + let message = MessageFactory::build_cant_connect_to_peer(4242, "bob"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 1001); + decoded.set_pointer(8); + assert_eq!(decoded.read_int32(), 4242); + assert_eq!(decoded.read_string(), "bob"); +} + +#[test] +fn a_room_ticker_carries_its_room_and_text() { + let message = MessageFactory::build_set_room_ticker("jazz", "hello"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 116); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "jazz"); + assert_eq!(decoded.read_string(), "hello"); +} + +#[test] +fn the_global_room_is_joined_and_left_by_code_alone() { + assert_eq!( + MessageFactory::build_join_global_room().get_data(), + [150, 0, 0, 0] + ); + assert_eq!( + MessageFactory::build_leave_global_room().get_data(), + [151, 0, 0, 0] + ); +} + +#[test] +fn each_interest_message_uses_its_own_code() { + for (message, code) in [ + (MessageFactory::build_add_thing_i_like("jazz"), 51), + (MessageFactory::build_remove_thing_i_like("jazz"), 52), + (MessageFactory::build_add_thing_i_hate("polka"), 117), + (MessageFactory::build_remove_thing_i_hate("polka"), 118), + (MessageFactory::build_user_interests("bob"), 57), + (MessageFactory::build_item_recommendations("jazz"), 111), + (MessageFactory::build_item_similar_users("jazz"), 112), + ] { + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), code); + decoded.set_pointer(8); + assert!(!decoded.read_string().is_empty()); + } +} + +#[test] +fn the_bodyless_queries_are_just_their_codes() { + assert_eq!( + MessageFactory::build_get_recommendations().get_data(), + [54, 0, 0, 0] + ); + assert_eq!( + MessageFactory::build_global_recommendations().get_data(), + [56, 0, 0, 0] + ); + assert_eq!( + MessageFactory::build_similar_users().get_data(), + [110, 0, 0, 0] + ); +} + +#[test] +fn a_privilege_gift_carries_the_recipient_and_days() { + let message = MessageFactory::build_give_privileges("bob", 3); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 123); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "bob"); + assert_eq!(decoded.read_int32(), 3); +} + +#[test] +fn a_password_change_carries_the_new_password() { + let message = MessageFactory::build_change_password("hunter2"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 142); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "hunter2"); +} + +#[test] +fn a_multi_user_message_counts_its_recipients_first() { + let recipients = vec!["alice".to_string(), "bob".to_string()]; + let message = MessageFactory::build_message_users(&recipients, "hi all"); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 149); + decoded.set_pointer(8); + assert_eq!(decoded.read_int32(), 2); + assert_eq!(decoded.read_string(), "alice"); + assert_eq!(decoded.read_string(), "bob"); + assert_eq!(decoded.read_string(), "hi all"); +} + +#[test] +fn the_private_room_roster_messages_carry_room_then_user() { + for (message, code) in [ + (MessageFactory::build_add_room_member("club", "bob"), 134), + (MessageFactory::build_remove_room_member("club", "bob"), 135), + (MessageFactory::build_add_room_operator("club", "bob"), 143), + ( + MessageFactory::build_remove_room_operator("club", "bob"), + 144, + ), + ] { + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), code); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + assert_eq!(decoded.read_string(), "bob"); + } +} + +#[test] +fn giving_up_a_room_standing_names_only_the_room() { + for (message, code) in [ + (MessageFactory::build_cancel_room_membership("club"), 136), + (MessageFactory::build_cancel_room_ownership("club"), 137), + (MessageFactory::build_cancel_room_operatorship("club"), 147), + ] { + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), code); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + } +} + +#[test] +fn room_invitations_are_toggled_by_a_single_byte() { + let message = MessageFactory::build_enable_room_invitations(true); + assert_eq!(message.get_data(), [141, 0, 0, 0, 1]); + let message = MessageFactory::build_enable_room_invitations(false); + assert_eq!(message.get_data(), [141, 0, 0, 0, 0]); +} + +#[test] +fn a_private_join_sets_the_private_flag() { + let message = MessageFactory::build_join_room("club", true); + let mut decoded = Message::new_with_data(message.get_buffer()); + assert_eq!(decoded.get_message_code(), 14); + decoded.set_pointer(8); + assert_eq!(decoded.read_string(), "club"); + assert_eq!(decoded.read_int32(), 1); +} diff --git a/soulseek-rs-lib/src/message/server/message_user.rs b/soulseek-rs-lib/src/message/server/message_user.rs index 0520197..9817e2f 100644 --- a/soulseek-rs-lib/src/message/server/message_user.rs +++ b/soulseek-rs-lib/src/message/server/message_user.rs @@ -28,16 +28,73 @@ impl MessageHandler for MessageUser { info!("[MessageUser] User message received:{:?}", user_message); - // Acknowledge freshly delivered messages so the server does not keep - // re-sending them on every reconnect. - if new_message { - let _ = sender.send(ServerMessage::SendMessage( - MessageFactory::build_message_acked(id), - )); - } + // Acknowledge every message, not only the ones flagged new. The flag + // marks a message delivered as it was sent; one the server stored + // while we were offline arrives with it false, and those are exactly + // the messages the server keeps re-sending at every login until they + // are acknowledged. + let _ = sender.send(ServerMessage::SendMessage( + MessageFactory::build_message_acked(id), + )); // Surface the message to the client so it can be read via the API. let _ = sender.send(ServerMessage::PrivateMessageReceived(user_message)); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + fn deliver(new_message: bool) -> Vec { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(7); + m.write_int32(1_700_000_000); + m.write_string("alice"); + m.write_string("hello"); + m.write_int8(u8::from(new_message)); + }); + MessageUser.handle(&mut message, tx); + rx.try_iter().collect() + } + + fn acked_id(messages: &[ServerMessage]) -> Option { + messages.iter().find_map(|message| match message { + ServerMessage::SendMessage(m) => { + let mut decoded = Message::new_with_data(m.get_buffer()); + (decoded.get_message_code() == 23).then(|| { + decoded.set_pointer(8); + decoded.read_int32() + }) + } + _ => None, + }) + } + + #[test] + fn a_freshly_delivered_message_is_acknowledged() { + let sent = deliver(true); + assert_eq!(acked_id(&sent), Some(7)); + } + + #[test] + fn a_message_stored_while_we_were_offline_is_acknowledged_too() { + // Stored messages arrive with new_message false; without an ack the + // server re-delivers them at every login, forever. + let sent = deliver(false); + assert_eq!(acked_id(&sent), Some(7)); + } + + #[test] + fn the_message_itself_is_surfaced_to_the_client() { + let sent = deliver(false); + assert!(sent.iter().any(|message| matches!( + message, + ServerMessage::PrivateMessageReceived(m) + if m.message() == "hello" && m.username() == "alice" + ))); + } +} diff --git a/soulseek-rs-lib/src/message/server/mod.rs b/soulseek-rs-lib/src/message/server/mod.rs index 35fcc3b..41a4f77 100644 --- a/soulseek-rs-lib/src/message/server/mod.rs +++ b/soulseek-rs-lib/src/message/server/mod.rs @@ -1,9 +1,12 @@ mod admin_message; +mod cant_connect_to_peer; mod connect_to_peer; mod distributed; mod excluded_search_phrases; mod file_search; mod get_peer_address; +mod global_room; +mod interests; mod join_room; mod leave_room; mod login; @@ -11,9 +14,11 @@ mod message_factory; mod message_user; mod parent_min_speed; mod parent_speed_ratio; +mod private_rooms; mod privileged_users; mod relogged; mod room_list; +mod room_tickers; mod say_chatroom; mod user_info; mod user_joined_room; @@ -22,6 +27,7 @@ mod watch_user; mod wish_list_interval; pub use admin_message::AdminMessageHandler; +pub use cant_connect_to_peer::CantConnectToPeerHandler; pub use connect_to_peer::ConnectToPeerHandler; pub use distributed::{ EmbeddedMessageHandler, PossibleParentsHandler, ResetDistributedHandler, @@ -29,6 +35,12 @@ pub use distributed::{ pub use excluded_search_phrases::ExcludedSearchPhrasesHandler; pub use file_search::FileSearchHandler; pub use get_peer_address::GetPeerAddressHandler; +pub use global_room::GlobalRoomMessageHandler; +pub use interests::{ + GlobalRecommendationsHandler, ItemRecommendationsHandler, + ItemSimilarUsersHandler, RecommendationsHandler, SimilarUsersHandler, + UserInterestsHandler, +}; pub use join_room::JoinRoomHandler; pub use leave_room::LeaveRoomHandler; pub use login::LoginHandler; @@ -36,9 +48,16 @@ pub use message_factory::MessageFactory; pub use message_user::MessageUser; pub use parent_min_speed::ParentMinSpeedHandler; pub use parent_speed_ratio::ParentSpeedRatioHandler; +pub use private_rooms::{ + CantCreateRoomHandler, OwnRoomStandingHandler, RoomMembersHandler, + RoomOperatorsHandler, RoomRosterChangeHandler, +}; pub use privileged_users::{CheckPrivilegesHandler, PrivilegedUsersHandler}; pub use relogged::ReloggedHandler; pub use room_list::RoomListHandler; +pub use room_tickers::{ + RoomTickerAddedHandler, RoomTickerRemovedHandler, RoomTickersHandler, +}; pub use say_chatroom::SayChatroomHandler; pub use user_info::{GetUserStatsHandler, GetUserStatusHandler}; pub use user_joined_room::UserJoinedRoomHandler; diff --git a/soulseek-rs-lib/src/message/server/parent_min_speed.rs b/soulseek-rs-lib/src/message/server/parent_min_speed.rs index b93ef6c..e783421 100644 --- a/soulseek-rs-lib/src/message/server/parent_min_speed.rs +++ b/soulseek-rs-lib/src/message/server/parent_min_speed.rs @@ -14,8 +14,32 @@ impl MessageHandler for ParentMinSpeedHandler { } fn handle(&self, message: &mut Message, sender: Sender) { - let _ = sender; let number = message.read_int32(); debug!("Parent min speed: {}", number); + // Kept, not just logged: with the ratio below it decides how many + // children this client may carry. + let _ = sender.send(ServerMessage::ParentMinSpeed(number)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_minimum_speed_is_reported_not_discarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(1024); + }); + + ParentMinSpeedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ParentMinSpeed(speed)) => { + assert_eq!(speed, 1024); + } + other => panic!("unexpected: {other:?}"), + } } } diff --git a/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs b/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs index 744c6ea..e171927 100644 --- a/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs +++ b/soulseek-rs-lib/src/message/server/parent_speed_ratio.rs @@ -14,8 +14,31 @@ impl MessageHandler for ParentSpeedRatioHandler { 84 } - fn handle(&self, message: &mut Message, _sender: Sender) { + fn handle(&self, message: &mut Message, sender: Sender) { let number = message.read_int32(); debug!("Parent speed ratio: {}", number); + let _ = sender.send(ServerMessage::ParentSpeedRatio(number)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn the_ratio_is_reported_not_discarded() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_int32(50); + }); + + ParentSpeedRatioHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::ParentSpeedRatio(ratio)) => { + assert_eq!(ratio, 50); + } + other => panic!("unexpected: {other:?}"), + } } } diff --git a/soulseek-rs-lib/src/message/server/private_rooms.rs b/soulseek-rs-lib/src/message/server/private_rooms.rs new file mode 100644 index 0000000..7936014 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/private_rooms.rs @@ -0,0 +1,234 @@ +//! Private rooms: who may enter one and who runs it. +//! +//! The server pushes the membership and operator rosters of every private +//! room we belong to right after login (codes 133 and 148), then narrates +//! changes to them: a member added or removed (134/135), our own membership +//! or operatorship granted or revoked (139/140, 145/146), and a room we asked +//! for that could not be created (1003). + +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, +}; + +/// Read a `[room][count][username]...` roster, stopping at the payload's end. +fn read_roster(message: &mut Message) -> (String, Vec) { + let room = message.read_string(); + let count = message.read_int32(); + let mut users = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + if username.is_empty() { + break; + } + users.push(username); + } + (room, users) +} + +/// `RoomMembers` (code 133): who may enter a private room. +pub struct RoomMembersHandler; + +impl MessageHandler for RoomMembersHandler { + fn get_code(&self) -> u32 { + 133 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let (room, users) = read_roster(message); + let _ = sender.send(ServerMessage::PrivateRoomMembers { room, users }); + } +} + +/// `RoomOperators` (code 148): who runs a private room. +pub struct RoomOperatorsHandler; + +impl MessageHandler for RoomOperatorsHandler { + fn get_code(&self) -> u32 { + 148 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let (room, users) = read_roster(message); + let _ = + sender.send(ServerMessage::PrivateRoomOperators { room, users }); + } +} + +/// One user added to or removed from a private room's roster, by the code it +/// arrived under: members added (134) or removed (135), operators added (143) +/// or removed (144). +pub struct RoomRosterChangeHandler(pub u32); + +impl MessageHandler for RoomRosterChangeHandler { + fn get_code(&self) -> u32 { + self.0 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let _ = sender.send(ServerMessage::PrivateRoomRosterChanged { + room, + username, + members: matches!(self.0, 134 | 135), + added: matches!(self.0, 134 | 143), + }); + } +} + +/// Our own standing in a private room changed: membership granted (139) or +/// revoked (140), operatorship granted (145) or revoked (146). +pub struct OwnRoomStandingHandler(pub u32); + +impl MessageHandler for OwnRoomStandingHandler { + fn get_code(&self) -> u32 { + self.0 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let _ = sender.send(ServerMessage::OwnRoomStandingChanged { + room, + members: matches!(self.0, 139 | 140), + granted: matches!(self.0, 139 | 145), + }); + } +} + +/// `CantCreateRoom` (code 1003): the room we asked to join could not be +/// created โ€” the name is taken by a private room we are not a member of, or +/// it is otherwise refused. +pub struct CantCreateRoomHandler; + +impl MessageHandler for CantCreateRoomHandler { + fn get_code(&self) -> u32 { + 1003 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let _ = sender.send(ServerMessage::CantCreateRoom { room }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_member_roster_lists_the_rooms_members() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_int32(2); + m.write_string("alice"); + m.write_string("bob"); + }); + + RoomMembersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomMembers { room, users }) => { + assert_eq!(room, "club"); + assert_eq!(users, ["alice", "bob"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_roster_count_beyond_the_payload_stops_at_the_data() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_int32(u32::MAX); + m.write_string("alice"); + }); + + RoomOperatorsHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomOperators { users, .. }) => { + assert_eq!(users, ["alice"]); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn each_roster_change_reports_which_roster_and_which_way() { + for (handler, members, added) in [ + (RoomRosterChangeHandler(134), true, true), + (RoomRosterChangeHandler(135), true, false), + (RoomRosterChangeHandler(143), false, true), + (RoomRosterChangeHandler(144), false, false), + ] { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + m.write_string("alice"); + }); + + handler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::PrivateRoomRosterChanged { + room, + username, + members: got_members, + added: got_added, + }) => { + assert_eq!(room, "club"); + assert_eq!(username, "alice"); + assert_eq!((got_members, got_added), (members, added)); + } + other => panic!("unexpected: {other:?}"), + } + } + } + + #[test] + fn our_own_standing_reports_the_room_and_the_change() { + for (handler, members, granted) in [ + (OwnRoomStandingHandler(139), true, true), + (OwnRoomStandingHandler(140), true, false), + (OwnRoomStandingHandler(145), false, true), + (OwnRoomStandingHandler(146), false, false), + ] { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("club"); + }); + + handler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::OwnRoomStandingChanged { + room, + members: got_members, + granted: got_granted, + }) => { + assert_eq!(room, "club"); + assert_eq!((got_members, got_granted), (members, granted)); + } + other => panic!("unexpected: {other:?}"), + } + } + } + + #[test] + fn a_refused_room_names_itself() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("taken"); + }); + + CantCreateRoomHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::CantCreateRoom { room }) => { + assert_eq!(room, "taken"); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/message/server/room_tickers.rs b/soulseek-rs-lib/src/message/server/room_tickers.rs new file mode 100644 index 0000000..d9ab1c9 --- /dev/null +++ b/soulseek-rs-lib/src/message/server/room_tickers.rs @@ -0,0 +1,174 @@ +use std::sync::mpsc::Sender; + +use crate::{ + actor::server_actor::ServerMessage, + message::{Message, MessageHandler}, + types::RoomTicker, +}; + +/// `RoomTickers` (code 113): the whole ticker board of a room, sent when we +/// join it. +pub struct RoomTickersHandler; + +impl MessageHandler for RoomTickersHandler { + fn get_code(&self) -> u32 { + 113 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let count = message.read_int32(); + // A truncated or hostile count must not preallocate: grow as entries + // actually parse, and stop at the first one that runs off the end. + let mut tickers = Vec::new(); + for _ in 0..count { + let username = message.read_string(); + let ticker = message.read_string(); + if username.is_empty() { + break; + } + tickers.push(RoomTicker { username, ticker }); + } + let _ = sender.send(ServerMessage::RoomTickers { room, tickers }); + } +} + +/// `RoomTickerAdded` (code 114): one user set or replaced their ticker. +pub struct RoomTickerAddedHandler; + +impl MessageHandler for RoomTickerAddedHandler { + fn get_code(&self) -> u32 { + 114 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let ticker = message.read_string(); + let _ = sender.send(ServerMessage::RoomTickerAdded { + room, + username, + ticker, + }); + } +} + +/// `RoomTickerRemoved` (code 115): one user cleared their ticker. +pub struct RoomTickerRemovedHandler; + +impl MessageHandler for RoomTickerRemovedHandler { + fn get_code(&self) -> u32 { + 115 + } + + fn handle(&self, message: &mut Message, sender: Sender) { + let room = message.read_string(); + let username = message.read_string(); + let _ = + sender.send(ServerMessage::RoomTickerRemoved { room, username }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::message::framed; + + #[test] + fn a_ticker_board_carries_every_entry_in_order() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(2); + m.write_string("alice"); + m.write_string("hello there"); + m.write_string("bob"); + m.write_string("back later"); + }); + + RoomTickersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickers { room, tickers }) => { + assert_eq!(room, "jazz"); + assert_eq!( + tickers, + vec![ + RoomTicker { + username: "alice".into(), + ticker: "hello there".into() + }, + RoomTicker { + username: "bob".into(), + ticker: "back later".into() + }, + ] + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_count_larger_than_the_payload_stops_at_the_data() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_int32(1_000_000); + m.write_string("alice"); + m.write_string("hi"); + }); + + RoomTickersHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickers { tickers, .. }) => { + assert_eq!(tickers.len(), 1); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn an_added_ticker_reports_its_room_user_and_text() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + m.write_string("new ticker"); + }); + + RoomTickerAddedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickerAdded { + room, + username, + ticker, + }) => { + assert_eq!( + (room.as_str(), username.as_str(), ticker.as_str()), + ("jazz", "alice", "new ticker") + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn a_removed_ticker_reports_its_room_and_user() { + let (tx, rx) = std::sync::mpsc::channel(); + let mut message = framed(|m| { + m.write_string("jazz"); + m.write_string("alice"); + }); + + RoomTickerRemovedHandler.handle(&mut message, tx); + match rx.try_recv() { + Ok(ServerMessage::RoomTickerRemoved { room, username }) => { + assert_eq!( + (room.as_str(), username.as_str()), + ("jazz", "alice") + ); + } + other => panic!("unexpected: {other:?}"), + } + } +} diff --git a/soulseek-rs-lib/src/peer/download_peer.rs b/soulseek-rs-lib/src/peer/download_peer.rs index b941d32..034e54f 100644 --- a/soulseek-rs-lib/src/peer/download_peer.rs +++ b/soulseek-rs-lib/src/peer/download_peer.rs @@ -34,10 +34,9 @@ pub enum DownloadError { DownloadInfoMissing(u32), FileWriteError(io::Error), PathResolutionError(String), - InvalidTokenBytes, LockPoisoned, Cancelled, - IncompleteDownload { received: usize, expected: usize }, + IncompleteDownload { received: u64, expected: u64 }, } impl std::fmt::Display for DownloadError { @@ -60,9 +59,6 @@ impl std::fmt::Display for DownloadError { Self::PathResolutionError(msg) => { write!(f, "Path resolution error: {msg}") } - Self::InvalidTokenBytes => { - write!(f, "Invalid token bytes received") - } Self::LockPoisoned => write!(f, "Lock poisoned"), Self::Cancelled => write!(f, "Download cancelled"), Self::IncompleteDownload { received, expected } => write!( @@ -147,8 +143,9 @@ impl PartFile { .map_err(DownloadError::FileWriteError)?; } - // A `.part` that already meets or exceeds the expected size is left over - // from a different (or corrupted) transfer, not a resume point. + // A `.part` at or beyond the advertised size is restarted. We do not + // persist its username, full remote path, or prior expected size, so + // equality alone cannot prove that its bytes belong to this transfer. let on_disk = fs::metadata(&path).map_or(0, |m| m.len()); let written = if on_disk < download.size { on_disk } else { 0 }; @@ -170,8 +167,9 @@ impl PartFile { /// Peers can coalesce trailing bytes into the last chunk, so overshoot past /// the expected size is dropped rather than written. fn write(&mut self, data: &[u8]) -> Result<(), DownloadError> { - let remaining = (self.expected - self.written) as usize; - let data = &data[..data.len().min(remaining)]; + let remaining = self.expected - self.written; + let write_len = remaining.min(data.len() as u64) as usize; + let data = &data[..write_len]; self.file .write_all(data) .map_err(DownloadError::FileWriteError)?; @@ -193,8 +191,8 @@ impl PartFile { fn finish(self) -> Result { if !self.is_complete() { return Err(DownloadError::IncompleteDownload { - received: self.written as usize, - expected: self.expected as usize, + received: self.written, + expected: self.expected, }); } drop(self.file); @@ -319,77 +317,73 @@ impl DownloadPeer { Ok((download, part)) } - fn begin_pierced_download( - &self, - data: &[u8], - stream: &mut TcpStream, - client_context: &Arc>, - ) -> Result<(Download, PartFile), DownloadError> { - let token_bytes = - data.get(0..4).ok_or(DownloadError::InvalidTokenBytes)?; - let token_array: [u8; 4] = token_bytes - .try_into() - .map_err(|_| DownloadError::InvalidTokenBytes)?; - let token_u32 = u32::from_le_bytes(token_array); - - trace!( - "[download_peer:{}] got token: {} from data chunk", - self.username, token_u32 - ); - - let client_guard = client_context - .read() - .map_err(|_| DownloadError::LockPoisoned)?; - let download_info = - client_guard.get_download_by_token(token_u32).cloned(); - drop(client_guard); - - let download = - download_info.ok_or(DownloadError::TokenNotFound(token_u32))?; - - Self::start_transfer(download, stream, client_context) - } - fn read_download_stream( &self, stream: &mut TcpStream, client_context: &Arc>, download: Option, + matched_download: &mut Option, ) -> Result<(Download, String), DownloadError> { let mut read_buffer = vec![0u8; READ_BUFFER_SIZE]; let mut bytes_since_last_update = 0usize; let mut last_update_time = Instant::now(); - let mut last_data = Instant::now(); - stream - .set_read_timeout(Some(CANCEL_POLL)) - .map_err(DownloadError::ConnectionFailed)?; trace!( "[download_peer:{}] Starting to read data from peer", self.username ); - let mut transfer = match download { - Some(dl) => Some(Self::start_transfer(dl, stream, client_context)?), - None => None, + let (download, mut part) = match download { + Some(download) => { + Self::start_transfer(download, stream, client_context)? + } + None if !self.no_pierce => { + let mut token = [0u8; 4]; + stream + .read_exact(&mut token) + .map_err(DownloadError::StreamReadError)?; + let token = u32::from_le_bytes(token); + trace!( + "[download_peer:{}] got token: {} from data chunk", + self.username, token + ); + let download = client_context + .write() + .map_err(|_| DownloadError::LockPoisoned)? + .downloads + .claim_for_peer(token, &self.username) + .ok_or(DownloadError::TokenNotFound(token))?; + *matched_download = Some(download.clone()); + Self::start_transfer(download, stream, client_context)? + } + None => { + return Err(DownloadError::DownloadInfoMissing(self.token)); + } }; + stream + .set_read_timeout(Some(CANCEL_POLL)) + .map_err(DownloadError::ConnectionFailed)?; + let mut last_data = Instant::now(); loop { - if let Some((dl, _)) = &transfer { - match Self::wait_while_paused(client_context, dl) { - Ok(true) => last_data = Instant::now(), - Ok(false) => {} - Err(e) => { - if matches!(e, DownloadError::Cancelled) - && let Some((_, part)) = transfer.take() - { - part.discard(); - } - return Err(e); + match Self::wait_while_paused(client_context, &download) { + Ok(true) => last_data = Instant::now(), + Ok(false) => {} + Err(error) => { + if matches!(error, DownloadError::Cancelled) { + part.discard(); } + return Err(error); } } + // A zero-byte transfer needs no payload bytes. There is no offset + // acknowledgement in Soulseek's transfer protocol, so the uploader + // waits for us to close and we must finalize without another read. + if part.is_complete() { + break; + } + match stream.read(&mut read_buffer) { Ok(0) => { trace!( @@ -400,24 +394,7 @@ impl DownloadPeer { } Ok(bytes_read) => { last_data = Instant::now(); - let data = &read_buffer[..bytes_read]; - - if transfer.is_none() && !self.no_pierce { - transfer = Some(self.begin_pierced_download( - data, - stream, - client_context, - )?); - continue; - } - - let Some((dl, part)) = transfer.as_mut() else { - return Err(DownloadError::DownloadInfoMissing( - self.token, - )); - }; - - part.write(data)?; + part.write(&read_buffer[..bytes_read])?; bytes_since_last_update += bytes_read; if bytes_since_last_update >= PROGRESS_INTERVAL_BYTES { @@ -429,7 +406,7 @@ impl DownloadPeer { }; Self::report_progress( client_context, - dl, + &download, part.written, speed, ); @@ -457,11 +434,9 @@ impl DownloadPeer { self.username ); - let Some((download, part)) = transfer else { - return Err(DownloadError::DownloadInfoMissing(self.token)); - }; - - Ok((download, part.finish()?)) + let final_path = + Self::finish_transfer(client_context, &download, part)?; + Ok((download, final_path)) } fn report_progress( @@ -481,7 +456,7 @@ impl DownloadPeer { ); } - fn send_download_status( + pub(crate) fn send_download_status( client_context: &Arc>, download: &Download, status: DownloadStatus, @@ -496,6 +471,56 @@ impl DownloadPeer { } } + /// Promote a complete partial and publish `Completed` as one operation with + /// respect to cancellation. Holding the store write lock across the rename + /// gives both possible races deterministic outcomes: cancellation marks the + /// row first and discards the partial, or completion lands first and a later + /// cancellation is refused. + fn finish_transfer( + client_context: &Arc>, + download: &Download, + part: PartFile, + ) -> Result { + loop { + let mut context = client_context + .write() + .map_err(|_| DownloadError::LockPoisoned)?; + let status = context + .get_download_by_token(download.token) + .map(|download| download.status.clone()) + .ok_or(DownloadError::TokenNotFound(download.token))?; + + if matches!(status, DownloadStatus::Cancelled) { + drop(context); + part.discard(); + return Err(DownloadError::Cancelled); + } + if matches!(status, DownloadStatus::Paused { .. }) { + drop(context); + if let Err(error) = + Self::wait_while_paused(client_context, download) + { + if matches!(error, DownloadError::Cancelled) { + part.discard(); + } + return Err(error); + } + continue; + } + + let final_path = part.finish()?; + if !context + .downloads + .update_status(download.token, DownloadStatus::Completed) + { + return Err(DownloadError::TokenNotFound(download.token)); + } + drop(context); + let _ = download.sender.send(DownloadStatus::Completed); + return Ok(final_path); + } + } + fn wait_while_paused( client_context: &Arc>, download: &Download, @@ -527,36 +552,54 @@ impl DownloadPeer { download: Option, stream: Option, ) -> Result<(Download, String), DownloadError> { - trace!( - "[download_peer:{}] download_file: download is present?: {:?}, stream is present?: {:?}, no_pierce: {}", - self.username, - download.is_some(), - stream.is_some(), - self.no_pierce - ); + let mut matched_download = download.clone(); + let result: Result<_, DownloadError> = (|| { + trace!( + "[download_peer:{}] download_file: download is present?: {:?}, stream is present?: {:?}, no_pierce: {}", + self.username, + download.is_some(), + stream.is_some(), + self.no_pierce + ); - let mut stream = match stream { - Some(s) => { - Self::apply_transfer_deadlines(&s)?; - s - } - None => self.establish_connection()?, - }; + let mut stream = match stream { + Some(s) => { + Self::apply_transfer_deadlines(&s)?; + s + } + None => self.establish_connection()?, + }; - trace!("[download_peer:{}] connected", self.username); + trace!("[download_peer:{}] connected", self.username); - self.perform_handshake(&mut stream)?; - trace!("[download_peer:{}] handshake completed", self.username); + self.perform_handshake(&mut stream)?; + trace!("[download_peer:{}] handshake completed", self.username); - let (download, final_path) = - self.read_download_stream(&mut stream, &client_context, download)?; + let (download, final_path) = self.read_download_stream( + &mut stream, + &client_context, + download, + &mut matched_download, + )?; - trace!( - "[download_peer:{}] download completed successfully: {} bytes, saved to: {}", - self.username, download.size, final_path - ); + trace!( + "[download_peer:{}] download completed successfully: {} bytes, saved to: {}", + self.username, download.size, final_path + ); - Ok((download, final_path)) + Ok((download, final_path)) + })(); + if let Err(error) = &result + && !matches!(error, DownloadError::Cancelled) + && let Some(download) = matched_download + { + Self::send_download_status( + &client_context, + &download, + DownloadStatus::Failed(Some(error.to_string())), + ); + } + result } } @@ -580,7 +623,7 @@ mod tests { fn download_into(dir: &std::path::Path, size: u64) -> Download { Download { username: "peer".to_string(), - filename: "song.mp3".to_string(), + filename: "file.bin".to_string(), token: 1, size, download_directory: dir.display().to_string(), @@ -596,13 +639,13 @@ mod tests { let dir = scratch_dir("discard"); let mut part = PartFile::open(&download_into(&dir, 8)).unwrap(); part.write(b"data").unwrap(); - let path = dir.join("song.mp3.part"); + let path = dir.join("file.bin.part"); assert!(path.exists()); part.discard(); assert!(!path.exists()); - assert!(!dir.join("song.mp3").exists()); + assert!(!dir.join("file.bin").exists()); let _ = std::fs::remove_dir_all(&dir); } @@ -625,7 +668,7 @@ mod tests { .write() .unwrap() .downloads - .resume_by_file("peer", "song.mp3") + .resume_by_file("peer", "file.bin") ); }) }; @@ -681,6 +724,131 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn a_pierced_short_transfer_reports_failure() { + let dir = scratch_dir("pierced-short-transfer"); + let (sender, receiver) = mpsc::channel(); + let mut download = download_into(&dir, 10); + download.sender = sender; + let context = Arc::new(RwLock::new(ClientContext::new())); + context.write().unwrap().add_download(download.clone()); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = + std::net::TcpStream::connect(listener.local_addr().unwrap()) + .unwrap(); + let (mut uploader, _) = listener.accept().unwrap(); + uploader + .set_read_timeout(Some(std::time::Duration::from_secs(2))) + .unwrap(); + let peer = DownloadPeer::new( + download.username.clone(), + "127.0.0.1".into(), + 0, + 99, + false, + "me".into(), + ); + let worker_context = context.clone(); + let worker = std::thread::spawn(move || { + peer.download_file(worker_context, None, Some(client_stream)) + }); + + // Drain the framed PierceFirewall request before sending the raw token. + let mut frame_len = [0u8; 4]; + std::io::Read::read_exact(&mut uploader, &mut frame_len).unwrap(); + let mut frame = vec![0; u32::from_le_bytes(frame_len) as usize]; + std::io::Read::read_exact(&mut uploader, &mut frame).unwrap(); + + std::io::Write::write_all(&mut uploader, &download.token.to_le_bytes()) + .unwrap(); + + let mut offset = [0u8; 8]; + std::io::Read::read_exact(&mut uploader, &mut offset).unwrap(); + assert_eq!(u64::from_le_bytes(offset), 0); + std::io::Write::write_all(&mut uploader, b"0123").unwrap(); + std::net::TcpStream::shutdown(&uploader, std::net::Shutdown::Write) + .unwrap(); + drop(uploader); + + assert!(matches!( + worker.join().unwrap(), + Err(DownloadError::IncompleteDownload { + received: 4, + expected: 10 + }) + )); + assert!(matches!( + receiver.recv_timeout(std::time::Duration::from_secs(2)), + Ok(DownloadStatus::InProgress { .. }) + )); + assert!(matches!( + receiver.recv_timeout(std::time::Duration::from_secs(2)), + Ok(DownloadStatus::Failed(_)) + )); + assert!(matches!( + context + .read() + .unwrap() + .get_download_by_token(download.token) + .unwrap() + .status, + DownloadStatus::Failed(_) + )); + assert_eq!(std::fs::read(dir.join("file.bin.part")).unwrap(), b"0123"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_pierced_token_cannot_claim_another_peers_download() { + let dir = scratch_dir("token-peer-mismatch"); + let mut download = download_into(&dir, 10); + download.username = "victim".into(); + let context = Arc::new(RwLock::new(ClientContext::new())); + context.write().unwrap().add_download(download.clone()); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let client_stream = + std::net::TcpStream::connect(listener.local_addr().unwrap()) + .unwrap(); + let (mut attacker, _) = listener.accept().unwrap(); + let peer = DownloadPeer::new( + "attacker".into(), + "127.0.0.1".into(), + 0, + 100, + false, + "me".into(), + ); + let worker_context = context.clone(); + let worker = std::thread::spawn(move || { + peer.download_file(worker_context, None, Some(client_stream)) + }); + + let mut frame_len = [0u8; 4]; + std::io::Read::read_exact(&mut attacker, &mut frame_len).unwrap(); + let mut frame = vec![0; u32::from_le_bytes(frame_len) as usize]; + std::io::Read::read_exact(&mut attacker, &mut frame).unwrap(); + std::io::Write::write_all(&mut attacker, &download.token.to_le_bytes()) + .unwrap(); + + assert!(matches!( + worker.join().unwrap(), + Err(DownloadError::TokenNotFound(1)) + )); + assert!(!dir.join("file.bin.part").exists()); + assert!(matches!( + context + .read() + .unwrap() + .get_download_by_token(download.token) + .unwrap() + .status, + DownloadStatus::Queued + )); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn start_transfer_refuses_a_cancelled_download() { let dir = scratch_dir("refuse"); @@ -698,7 +866,7 @@ mod tests { DownloadPeer::start_transfer(download, &mut stream, &context); assert!(matches!(outcome, Err(DownloadError::Cancelled))); - assert!(!dir.join("song.mp3.part").exists(), "no .part is opened"); + assert!(!dir.join("file.bin.part").exists(), "no .part is opened"); let _ = std::fs::remove_dir_all(&dir); } @@ -722,7 +890,7 @@ mod tests { #[test] fn a_partial_file_sets_the_resume_offset() { let dir = scratch_dir("resume"); - std::fs::write(dir.join("song.mp3.part"), b"0123").unwrap(); + std::fs::write(dir.join("file.bin.part"), b"0123").unwrap(); let mut part = PartFile::open(&download_into(&dir, 10)).unwrap(); assert_eq!(part.written, 4, "resume from what is already on disk"); @@ -732,17 +900,76 @@ mod tests { assert_eq!(std::fs::read(&final_path).unwrap(), b"0123456789"); assert!( - !dir.join("song.mp3.part").exists(), + !dir.join("file.bin.part").exists(), "the .part is renamed, not left behind" ); let _ = std::fs::remove_dir_all(dir); } + #[test] + fn an_unidentified_exact_size_partial_restarts_from_zero() { + // With no persisted transfer identity, length alone cannot distinguish + // a final write awaiting rename from unrelated same-sized content. + let dir = scratch_dir("complete"); + std::fs::write(dir.join("file.bin.part"), b"0123456789").unwrap(); + + let mut part = PartFile::open(&download_into(&dir, 10)).unwrap(); + assert_eq!(part.written, 0); + assert!(!part.is_complete()); + + part.write(b"abcdefghij").unwrap(); + let final_path = part.finish().unwrap(); + assert_eq!(std::fs::read(final_path).unwrap(), b"abcdefghij"); + assert!(!dir.join("file.bin.part").exists()); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn cancellation_while_a_complete_transfer_is_paused_discards_its_part() { + let dir = scratch_dir("cancel-paused-finish"); + let mut download = download_into(&dir, 10); + let mut part = PartFile::open(&download).unwrap(); + part.write(b"0123456789").unwrap(); + download.status = DownloadStatus::Paused { + bytes_downloaded: 10, + total_bytes: 10, + }; + let context = Arc::new(RwLock::new(ClientContext::new())); + context.write().unwrap().add_download(download.clone()); + let worker_context = context.clone(); + let worker_download = download.clone(); + + let worker = std::thread::spawn(move || { + DownloadPeer::finish_transfer( + &worker_context, + &worker_download, + part, + ) + }); + + std::thread::sleep(std::time::Duration::from_millis(50)); + assert!( + context + .write() + .unwrap() + .downloads + .cancel_by_file(&download.username, &download.filename) + .is_some() + ); + + assert!(matches!( + worker.join().unwrap(), + Err(DownloadError::Cancelled) + )); + assert!(!dir.join("file.bin.part").exists()); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn an_oversized_partial_file_restarts_from_zero() { // Left over from a different transfer โ€” not a valid resume point. let dir = scratch_dir("stale"); - std::fs::write(dir.join("song.mp3.part"), vec![9u8; 40]).unwrap(); + std::fs::write(dir.join("file.bin.part"), vec![9u8; 40]).unwrap(); let mut part = PartFile::open(&download_into(&dir, 10)).unwrap(); assert_eq!(part.written, 0); @@ -770,11 +997,11 @@ mod tests { }) )); assert_eq!( - std::fs::read(dir.join("song.mp3.part")).unwrap(), + std::fs::read(dir.join("file.bin.part")).unwrap(), b"01234", "the partial stays on disk for the next attempt" ); - assert!(!dir.join("song.mp3").exists()); + assert!(!dir.join("file.bin").exists()); let _ = std::fs::remove_dir_all(dir); } diff --git a/soulseek-rs-lib/src/peer/listen.rs b/soulseek-rs-lib/src/peer/listen.rs index 20e747d..beb1f64 100644 --- a/soulseek-rs-lib/src/peer/listen.rs +++ b/soulseek-rs-lib/src/peer/listen.rs @@ -1,5 +1,6 @@ use std::io; use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::Sender; use std::sync::{Arc, RwLock}; use std::thread; @@ -12,7 +13,7 @@ use crate::peer::{ConnectionType, DownloadError, DownloadPeer, Peer}; use crate::types::Download; use crate::utils::lock::RwLockExt; use crate::utils::semaphore::{Permit, Semaphore}; -use crate::{DownloadStatus, debug, error, info, trace}; +use crate::{debug, error, info, trace}; /// How long to wait before accepting again after a failure. /// @@ -130,20 +131,10 @@ fn wait_for_download_registration( deadline: Instant, ) -> Option { loop { - let download = match client_context.read_safe() { - Ok(context) => context - .get_download_by_token(token) - .filter(|d| { - d.username == username - && !matches!( - d.status, - DownloadStatus::InProgress { .. } - | DownloadStatus::Paused { .. } - | DownloadStatus::Completed - | DownloadStatus::Cancelled - ) - }) - .cloned(), + let download = match client_context.write_safe() { + Ok(mut context) => { + context.downloads.claim_for_peer(token, username) + } Err(e) => { error!("[listener] client context lock: {}", e); return None; @@ -224,8 +215,6 @@ fn handle_file_connection( Instant::now() + TOKEN_REGISTRATION_TIMEOUT, ) }); - let failure_token = download.as_ref().map(|d| d.token); - let download_peer = DownloadPeer::new( peer.username.clone(), peer.host.clone(), @@ -241,16 +230,6 @@ fn handle_file_connection( Some(stream), ) { Ok((download, filename)) => { - let _ = download.sender.send(DownloadStatus::Completed); - match context.client_context.write_safe() { - Ok(mut ctx) => ctx.update_download_with_status( - download.token, - DownloadStatus::Completed, - ), - Err(e) => { - error!("[listener] handle_file_connection write: {}", e); - } - } info!( "Successfully downloaded {} bytes to {}", download.size, filename @@ -262,22 +241,6 @@ fn handle_file_connection( "Failed to download file from {}:{} (token: {}) - Error: {}", peer.host, peer.port, token, e ); - // A failed incoming transfer (e.g. a truncated/incomplete download) - // must not leave the download stuck as Queued/InProgress forever. - if let Some(failure_token) = failure_token { - match context.client_context.write_safe() { - Ok(mut ctx) => ctx.update_download_with_status( - failure_token, - DownloadStatus::Failed(Some(e.to_string())), - ), - Err(e) => { - error!( - "[listener] handle_file_connection fail write: {}", - e - ); - } - } - } } } } @@ -416,9 +379,17 @@ fn handle_incoming_connection( deadline, ), ConnectionType::D => { + // A child asking to hang from us. The client decides whether it + // serves children at all; here we only hand the socket over. debug!( - "[listener:{peer_ip}:{peer_port}] connection type is D, not supported yet, closing connection. " + "[listener:{peer_ip}:{peer_port}] distributed child {} offered", + init_data.username ); + let _ = + context.client_sender.send(ClientOperation::ChildConnected { + username: init_data.username, + stream, + }); } } } @@ -452,6 +423,7 @@ impl Listen { client_sender: Sender, client_context: Arc>, own_username: String, + stopped: Arc, ) { info!("[listener] listening on {:?}", listener.local_addr()); @@ -463,6 +435,10 @@ impl Listen { let handshakes = Arc::new(Semaphore::new(MAX_HANDSHAKES)); for stream in listener.incoming() { + if stopped.load(Ordering::Relaxed) { + debug!("[listener] stopping, releasing the port"); + return; + } let Ok(stream) = stream else { error!( "[listener] Failed to accept connection: {}", @@ -491,7 +467,7 @@ impl Listen { #[cfg(test)] mod tests { use super::*; - use crate::types::DownloadMetadata; + use crate::types::{DownloadMetadata, DownloadStatus}; use std::io::Write; use std::net::TcpListener; use std::sync::mpsc; diff --git a/soulseek-rs-lib/src/peer/mod.rs b/soulseek-rs-lib/src/peer/mod.rs index 301603c..15494ba 100644 --- a/soulseek-rs-lib/src/peer/mod.rs +++ b/soulseek-rs-lib/src/peer/mod.rs @@ -64,6 +64,10 @@ pub struct Peer { pub privileged: Option, pub unknown: Option, pub obfuscated_port: Option, + /// True when the server asked us to make this connection (`ConnectToPeer`, + /// code 18). Then `token` is the far peer's correlation token, and a dial + /// that fails owes them a `CantConnectToPeer` quoting it. + pub brokered: bool, } impl Peer { #[allow(clippy::too_many_arguments)] @@ -87,6 +91,7 @@ impl Peer { privileged: Some(privileged), unknown: Some(unknown), obfuscated_port: Some(obfuscated_port), + brokered: false, } } pub fn new_from_message(message: &mut Message) -> Option { @@ -120,6 +125,7 @@ impl Peer { privileged: Some(privileged), unknown: Some(unknown), obfuscated_port: Some(u16::from(obfuscated_port)), + brokered: true, }) } } diff --git a/soulseek-rs-lib/src/peer/upload_peer.rs b/soulseek-rs-lib/src/peer/upload_peer.rs index 57138d4..02c0986 100644 --- a/soulseek-rs-lib/src/peer/upload_peer.rs +++ b/soulseek-rs-lib/src/peer/upload_peer.rs @@ -73,19 +73,21 @@ pub fn serve_file( let mut offset = [0u8; 8]; stream.read_exact(&mut offset)?; let offset = u64::from_le_bytes(offset); - if offset > 0 { - file.seek(SeekFrom::Start(offset))?; - bytes_sent.fetch_add(offset, Ordering::Relaxed); + let file_size = file.metadata()?.len(); + if offset > file_size { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("download offset {offset} exceeds file size {file_size}"), + )); } + file.seek(SeekFrom::Start(offset))?; + bytes_sent.fetch_add(offset, Ordering::Relaxed); let mut buffer = vec![0u8; 64 * 1024]; let mut streamed = 0u64; loop { if cancel.load(Ordering::Relaxed) { - return Err(io::Error::new( - io::ErrorKind::Interrupted, - "upload cancelled", - )); + return Err(io::ErrorKind::Interrupted.into()); } let read = file.read(&mut buffer)?; if read == 0 { @@ -97,11 +99,11 @@ pub fn serve_file( } stream.flush()?; - // Half-close instead of lingering: the FIN tells the downloader it has the - // whole file, and TCP still delivers everything written before it. Sleeping - // here held an upload slot for an extra half-second per transfer, which with - // a slot cap is time no other peer can use. - stream.shutdown(std::net::Shutdown::Write).ok(); + // The file is delivered. Soulseek leaves closing to the downloader, but + // waiting for that costs the upload slot the next peer is queued for โ€” + // and a peer that leaves the socket open, or whose FIN a NAT swallows, + // would hold it for the whole wait. Nicotine+ finishes the transfer at + // delivery for the same reason; the close below is ours to make. trace!("[upload] served {} to {}:{}", path.display(), host, port); Ok(streamed) } @@ -111,8 +113,9 @@ mod tests { use super::serve_file; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; - use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::sync::{Arc, mpsc}; + use std::time::Duration; /// Play the downloader: accept the F connection, drain the PeerInit frame /// and the raw token, then reply with the 8-byte START_DOWNLOAD offset. @@ -143,77 +146,150 @@ mod tests { path } + fn spawn_uploader( + listener: &TcpListener, + path: std::path::PathBuf, + token: u32, + cancel: Arc, + ) -> ( + Arc, + std::thread::JoinHandle>, + ) { + let port = u32::from(listener.local_addr().unwrap().port()); + let bytes_sent = Arc::new(AtomicU64::new(0)); + let sent_counter = bytes_sent.clone(); + let uploader = std::thread::spawn(move || { + serve_file( + "127.0.0.1", + port, + "me", + token, + &path, + &sent_counter, + &cancel, + ) + }); + (bytes_sent, uploader) + } + #[test] fn serve_file_streams_the_file_over_an_f_connection() { let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); let path = scratch_file("stream", &content); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - - let bytes_sent = Arc::new(AtomicU64::new(0)); - let sent_counter = bytes_sent.clone(); let dir = path.parent().unwrap().to_path_buf(); - let uploader = std::thread::spawn(move || { - serve_file( - "127.0.0.1", - port, - "me", - 777, - &path, - &sent_counter, - &AtomicBool::new(false), - ) - }); + let (bytes_sent, uploader) = + spawn_uploader(&listener, path, 777, Arc::default()); let (mut stream, token) = accept_and_send_offset(&listener, 0); assert_eq!(token, 777); - let mut received = Vec::new(); - stream.read_to_end(&mut received).unwrap(); + let mut received = vec![0; content.len()]; + stream.read_exact(&mut received).unwrap(); assert_eq!(received, content); - let _ = uploader.join(); + drop(stream); + assert_eq!(uploader.join().unwrap().unwrap(), 4096); assert_eq!(bytes_sent.load(Ordering::Relaxed), 4096); let _ = std::fs::remove_dir_all(dir); } #[test] - fn serve_file_resumes_from_a_non_zero_offset() { + fn serve_file_accepts_valid_resume_offsets() { let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); - let path = scratch_file("resume", &content); + for offset in [1000, content.len()] { + let path = + scratch_file(&format!("valid-offset-{offset}"), &content); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let dir = path.parent().unwrap().to_path_buf(); + let (bytes_sent, uploader) = + spawn_uploader(&listener, path, 779, Arc::default()); + + let (mut stream, _) = + accept_and_send_offset(&listener, offset as u64); + let mut received = vec![0; content.len() - offset]; + stream.read_exact(&mut received).unwrap(); + assert_eq!(received, content[offset..]); + drop(stream); + + assert_eq!( + uploader.join().unwrap().unwrap(), + (content.len() - offset) as u64 + ); + assert_eq!( + bytes_sent.load(Ordering::Relaxed), + content.len() as u64 + ); + let _ = std::fs::remove_dir_all(dir); + } + } + + #[test] + fn serve_file_rejects_an_offset_past_end_of_file() { + let content: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + let path = scratch_file("invalid-offset", &content); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - - let bytes_sent = Arc::new(AtomicU64::new(0)); - let sent_counter = bytes_sent.clone(); let dir = path.parent().unwrap().to_path_buf(); + let (bytes_sent, uploader) = + spawn_uploader(&listener, path, 780, Arc::default()); + + let (mut stream, _) = accept_and_send_offset(&listener, 4097); + let mut received = Vec::new(); + stream.read_to_end(&mut received).unwrap(); + assert!(received.is_empty()); + + let error = uploader.join().unwrap().unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert_eq!( + bytes_sent.load(Ordering::Relaxed), + 0, + "an invalid prefix must not be counted as uploaded" + ); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn a_downloader_that_never_closes_still_leaves_the_upload_delivered() { + // The file is on the wire; whether the peer closes, or its FIN is + // eaten by a NAT, it has had every byte. Waiting for a close that may + // never come used to fail the transfer and hold its upload slot, + // which is what starved a queue of everyone behind it. + let content: Vec = (0..2048u32).map(|i| (i % 251) as u8).collect(); + let path = scratch_file("no-close", &content); + let dir = path.parent().unwrap().to_path_buf(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = u32::from(listener.local_addr().unwrap().port()); + let (result_tx, result_rx) = mpsc::channel(); + let uploader = std::thread::spawn(move || { - serve_file( + let result = serve_file( "127.0.0.1", port, "me", - 779, + 783, &path, - &sent_counter, + &AtomicU64::new(0), &AtomicBool::new(false), - ) + ); + let _ = result_tx.send(result); }); - // The downloader already holds the first 1000 bytes. - let (mut stream, _) = accept_and_send_offset(&listener, 1000); + let (mut stream, _) = accept_and_send_offset(&listener, 0); + let mut received = vec![0; content.len()]; + stream.read_exact(&mut received).unwrap(); + assert_eq!(received, content); - let mut received = Vec::new(); - stream.read_to_end(&mut received).unwrap(); - assert_eq!(received, content[1000..], "only the missing tail is sent"); + // Hold the socket open past the grace period and say nothing. + let served = result_rx + .recv_timeout(Duration::from_secs(5)) + .expect("delivery must finish the upload, not a wait for a close") + .expect("a delivered file is a finished upload"); + assert_eq!(served, content.len() as u64); + drop(stream); let _ = uploader.join(); - assert_eq!( - bytes_sent.load(Ordering::Relaxed), - 4096, - "progress counts the resumed prefix" - ); let _ = std::fs::remove_dir_all(dir); } @@ -223,23 +299,10 @@ mod tests { let dir = path.parent().unwrap().to_path_buf(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = u32::from(listener.local_addr().unwrap().port()); - // Cancelled before the copy loop starts: the stream must abort with // Interrupted instead of serving the whole file. let cancel = Arc::new(AtomicBool::new(true)); - let cancel_flag = cancel; - let uploader = std::thread::spawn(move || { - serve_file( - "127.0.0.1", - port, - "me", - 778, - &path, - &AtomicU64::new(0), - &cancel_flag, - ) - }); + let (_, uploader) = spawn_uploader(&listener, path, 778, cancel); let (mut stream, _) = accept_and_send_offset(&listener, 0); diff --git a/soulseek-rs-lib/src/shares/mod.rs b/soulseek-rs-lib/src/shares/mod.rs index fb85657..807fbe3 100644 --- a/soulseek-rs-lib/src/shares/mod.rs +++ b/soulseek-rs-lib/src/shares/mod.rs @@ -146,14 +146,18 @@ impl Shares { .virtual_path .rsplit_once('\\') .unwrap_or(("", file.virtual_path.as_str())); - by_dir - .entry(dir.to_string()) - .or_default() - .push(SharedFileEntry { - name: base.to_string(), - size: file.size, - attributes: file.attributes.clone(), - }); + let entry = SharedFileEntry { + name: base.to_string(), + size: file.size, + attributes: file.attributes.clone(), + }; + // Looked up before inserting: `entry()` would allocate the key + // for every file, and a folder holds many. + if let Some(files) = by_dir.get_mut(dir) { + files.push(entry); + } else { + by_dir.insert(dir.to_string(), vec![entry]); + } } by_dir .into_iter() diff --git a/soulseek-rs-lib/src/types.rs b/soulseek-rs-lib/src/types.rs index 1f07ea5..8fa98e1 100644 --- a/soulseek-rs-lib/src/types.rs +++ b/soulseek-rs-lib/src/types.rs @@ -393,6 +393,80 @@ pub enum RoomEvent { UserJoined { room: String, username: String }, /// `username` left `room`. UserLeft { room: String, username: String }, + /// The full ticker board of `room`, sent when we join it (code 113). + Tickers { + room: String, + tickers: Vec, + }, + /// `username` set their ticker in `room` (code 114). It replaces any + /// previous ticker of theirs. + TickerAdded { + room: String, + username: String, + ticker: String, + }, + /// `username` cleared their ticker in `room` (code 115). + TickerRemoved { room: String, username: String }, + /// A message said in *any* public room, delivered because we joined the + /// global room feed (code 152). `room` names where it was said. + GlobalMessage { + room: String, + username: String, + message: String, + }, + /// Who may enter a private room (code 133). + PrivateMembers { room: String, users: Vec }, + /// Who runs a private room (code 148). + PrivateOperators { room: String, users: Vec }, + /// One user joined or left a private room's member roster (`members` + /// true, codes 134/135) or its operator roster (143/144). + PrivateRosterChanged { + room: String, + username: String, + members: bool, + added: bool, + }, + /// Our own membership (`members` true, codes 139/140) or operatorship + /// (145/146) of a private room was granted or revoked. + OwnStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created (code 1003) โ€” the name + /// belongs to a private room we are not a member of. + CantCreate { room: String }, +} + +/// One user's ticker (the scrolling one-line message) in a room. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RoomTicker { + pub username: String, + pub ticker: String, +} + +/// One recommended item and how strongly the server recommends it. The rating +/// is signed: a negative rating means the item is recommended *against*. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recommendation { + pub item: String, + pub rating: i32, +} + +/// A user the server considers similar to us, with the weight (the number of +/// interests shared) it scored them by. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SimilarUser { + pub username: String, + pub weight: u32, +} + +/// What a user likes and hates (`UserInterests`, code 57). +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UserInterests { + pub username: String, + pub likes: Vec, + pub hates: Vec, } impl Transfer { diff --git a/soulseek-rs-lib/src/utils/zlib.rs b/soulseek-rs-lib/src/utils/zlib.rs index 46a6bc8..bace9ae 100644 --- a/soulseek-rs-lib/src/utils/zlib.rs +++ b/soulseek-rs-lib/src/utils/zlib.rs @@ -59,8 +59,33 @@ impl BitReader { use crate::error::{Result, SoulseekRs}; +/// The most a peer payload may inflate to unless the caller says otherwise. +/// +/// Nicotine+ passes this ceiling to its decompressor for a search or folder +/// reply, and a larger one only for a browse listing. +pub const MAX_INFLATED: usize = 128 * 1024 * 1024; + +/// The ceiling for a whole browse listing, which a large sharer legitimately +/// fills. The same number Nicotine+ allows. +pub const MAX_INFLATED_LISTING: usize = 2 * 1024 * 1024 * 1024; + +/// What a payload that outgrows its ceiling is refused with. +const TOO_BIG: &str = "compressed payload expands past its limit"; + /// Inflate a zlib stream, checking its adler32 trailer. +/// +/// Bounded by [`MAX_INFLATED`]; use [`inflate_limited`] for a payload with a +/// different ceiling. pub fn inflate(input: &[u8]) -> Result> { + inflate_limited(input, MAX_INFLATED) +} + +/// Inflate a zlib stream, refusing to grow the output past `max_out`. +/// +/// The ceiling is the point: deflate reaches roughly 1000:1, so a few +/// kilobytes from a peer we have never met can otherwise expand into +/// gigabytes and take the process with it. +pub fn inflate_limited(input: &[u8], max_out: usize) -> Result> { let mut r = BitReader::new(input.to_vec()); let cmf = r.read_byte()?; let cm = cmf & 15; // Compression method @@ -84,7 +109,8 @@ pub fn inflate(input: &[u8]) -> Result> { "preset dictionary not supported".to_string(), )); } - let out = inflate_blocks(&mut r).map_err(SoulseekRs::CompressionError)?; + let out = inflate_blocks(&mut r, max_out) + .map_err(SoulseekRs::CompressionError)?; // `read_bytes` assembles little-endian; the zlib trailer is big-endian. if r.read_bytes(4)?.swap_bytes() != adler32(&out) { return Err(SoulseekRs::CompressionError( @@ -105,18 +131,24 @@ fn adler32(data: &[u8]) -> u32 { (b << 16) | a } -fn inflate_blocks(r: &mut BitReader) -> std::result::Result, String> { +fn inflate_blocks( + r: &mut BitReader, + max_out: usize, +) -> std::result::Result, String> { let mut bfinal = 0; let mut out = Vec::new(); while bfinal == 0 { bfinal = r.read_bit()?; let btype = r.read_bits(2)?; match btype { - 0 => inflate_block_no_compression(r, &mut out)?, - 1 => inflate_block_fixed(r, &mut out)?, - 2 => inflate_block_dynamic(r, &mut out)?, + 0 => inflate_block_no_compression(r, &mut out, max_out)?, + 1 => inflate_block_fixed(r, &mut out, max_out)?, + 2 => inflate_block_dynamic(r, &mut out, max_out)?, _ => return Err("invalid BTYPE".to_string()), } + if out.len() > max_out { + return Err(TOO_BIG.to_string()); + } } Ok(out) } @@ -124,9 +156,13 @@ fn inflate_blocks(r: &mut BitReader) -> std::result::Result, String> { fn inflate_block_no_compression( r: &mut BitReader, o: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { let len = r.read_bytes(2)?; let _nlen = r.read_bytes(2)?; + if o.len().saturating_add(len as usize) > max_out { + return Err(TOO_BIG.to_string()); + } for _ in 0..len { o.push(r.read_byte()?); } @@ -214,8 +250,14 @@ fn inflate_block_data( literal_length_tree: &HuffmanTree, distance_tree: &HuffmanTree, out: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { loop { + // Checked per symbol, not per block: a single block can emit output + // without bound, which is exactly how a bomb is built. + if out.len() > max_out { + return Err(TOO_BIG.to_string()); + } let sym = decode_symbol(r, literal_length_tree)?; if sym <= 255 { // Literal byte @@ -347,14 +389,16 @@ fn decode_trees( fn inflate_block_dynamic( r: &mut BitReader, o: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { let (literal_length_tree, distance_tree) = decode_trees(r)?; - inflate_block_data(r, &literal_length_tree, &distance_tree, o) + inflate_block_data(r, &literal_length_tree, &distance_tree, o, max_out) } fn inflate_block_fixed( r: &mut BitReader, o: &mut Vec, + max_out: usize, ) -> std::result::Result<(), String> { let mut bl = Vec::new(); bl.extend(vec![8; 144]); // 0-143: 8 bits @@ -369,7 +413,7 @@ fn inflate_block_fixed( let distance_alphabet: Vec = (0..30).collect(); let distance_tree = bl_list_to_tree(&bl_dist, &distance_alphabet); - inflate_block_data(r, &literal_length_tree, &distance_tree, o) + inflate_block_data(r, &literal_length_tree, &distance_tree, o, max_out) } struct BitWriter { @@ -533,6 +577,31 @@ mod tests { use super::*; + #[test] + fn a_payload_that_expands_past_its_ceiling_is_refused() { + // Deflate reaches ~1000:1, so a few kilobytes from an unknown peer can + // become gigabytes. The ceiling is what stops that being our problem. + let bomb = deflate(&vec![0u8; 1024 * 1024]); + assert!( + bomb.len() < 8 * 1024, + "the test needs a payload that expands hugely, got {} bytes", + bomb.len() + ); + + let error = inflate_limited(&bomb, 64 * 1024) + .expect_err("a payload past the ceiling must be refused"); + assert!( + format!("{error}").contains("expands past its limit"), + "unexpected error: {error}" + ); + + // The same bytes are fine when they fit. + assert_eq!( + inflate_limited(&bomb, 2 * 1024 * 1024).unwrap().len(), + 1024 * 1024 + ); + } + #[test] fn a_corrupt_adler32_trailer_is_rejected() { let mut stream = deflate(b"the bytes a peer sent"); diff --git a/soulseek-rs-lib/tests/e2e.rs b/soulseek-rs-lib/tests/e2e.rs index f4e7725..5727b75 100644 --- a/soulseek-rs-lib/tests/e2e.rs +++ b/soulseek-rs-lib/tests/e2e.rs @@ -118,6 +118,7 @@ impl TestServer { enable_listen: false, listen_port: 0, shared_directories: Vec::new(), + accept_children: false, version: ClientVersion::default(), } } @@ -1182,7 +1183,7 @@ fn an_interrupted_download_resumes_from_its_partial_file() { client.connect().expect("connect"); assert!(client.login().expect("login")); - let filename = "resumed_song.mp3"; + let filename = "resumed_file.bin"; let content: Vec = (0..2000u32).map(|i| (i % 251) as u8).collect(); let size = content.len() as u64; let already_have = 800usize; @@ -3768,3 +3769,1730 @@ fn a_leaf_adopts_a_parent_and_answers_the_searches_it_passes_down() { let _ = std::fs::remove_dir_all(share_dir); } + +// --------------------------------------------------------------------------- +// Room tickers, the global room feed, interests and the targeted searches. +// +// Every one of these is a message the client can now speak; the point of +// driving them through soulfind is that the wire shapes are right in both +// directions, not just that they parse. +// --------------------------------------------------------------------------- + +/// Poll `client`'s room events until `pick` matches one, or time out. +fn await_room_event( + client: &Client, + timeout: Duration, + mut pick: impl FnMut(&soulseek_rs::types::RoomEvent) -> Option, +) -> Option { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + for event in client.take_room_events() { + if let Some(found) = pick(&event) { + return Some(found); + } + } + std::thread::sleep(Duration::from_millis(100)); + } + None +} + +#[test] +fn a_ticker_set_in_a_room_reaches_the_other_members() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_ticker_room"; + let mut alice = + Client::with_settings(server.settings("e2e_tick_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_tick_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + alice.join_room(room).expect("alice joins"); + bob.join_room(room).expect("bob joins"); + std::thread::sleep(Duration::from_millis(500)); + let _ = bob.take_room_events(); + + alice + .set_room_ticker(room, "alice was here") + .expect("set ticker"); + + let seen = + await_room_event(&bob, Duration::from_secs(5), |event| match event { + RoomEvent::TickerAdded { + room: r, + username, + ticker, + } if r == room => Some((username.clone(), ticker.clone())), + _ => None, + }) + .expect("bob should see alice's ticker"); + assert_eq!( + seen, + ("e2e_tick_alice".to_string(), "alice was here".into()) + ); + + // The board is kept, not just the event: a UI reads it back from state. + let board = bob.room_tickers(room); + assert_eq!( + board + .iter() + .find(|t| t.username == "e2e_tick_alice") + .map(|t| t.ticker.as_str()), + Some("alice was here") + ); +} + +#[test] +fn a_client_joining_later_receives_the_whole_ticker_board() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_ticker_board"; + let mut alice = + Client::with_settings(server.settings("e2e_board_alice", "pw")); + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + alice.join_room(room).expect("alice joins"); + std::thread::sleep(Duration::from_millis(300)); + alice + .set_room_ticker(room, "standing message") + .expect("set ticker"); + std::thread::sleep(Duration::from_millis(500)); + + // Carol joins afterwards and must be handed the board that already exists. + let mut carol = + Client::with_settings(server.settings("e2e_board_carol", "pw")); + carol.connect().expect("carol connect"); + assert!(carol.login().expect("carol login")); + carol.join_room(room).expect("carol joins"); + + let tickers = + await_room_event(&carol, Duration::from_secs(5), |event| match event { + RoomEvent::Tickers { room: r, tickers } if r == room => { + Some(tickers.clone()) + } + _ => None, + }) + .expect("carol should receive the ticker board on join"); + assert!( + tickers.iter().any(|t| t.username == "e2e_board_alice" + && t.ticker == "standing message"), + "the board should carry alice's standing ticker, got {tickers:?}" + ); +} + +#[test] +fn the_global_room_feed_carries_a_room_we_never_joined() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_global_source"; + let mut watcher = + Client::with_settings(server.settings("e2e_global_watch", "pw")); + let mut talker = + Client::with_settings(server.settings("e2e_global_talk", "pw")); + watcher.connect().expect("watcher connect"); + talker.connect().expect("talker connect"); + assert!(watcher.login().expect("watcher login")); + assert!(talker.login().expect("talker login")); + + watcher.join_global_room().expect("join global room"); + talker.join_room(room).expect("talker joins a room"); + std::thread::sleep(Duration::from_millis(500)); + let _ = watcher.take_room_events(); + + let body = "spoken where nobody is watching"; + talker.say_in_room(room, body).expect("say in room"); + + let got = + await_room_event( + &watcher, + Duration::from_secs(5), + |event| match event { + RoomEvent::GlobalMessage { + room: r, + username, + message, + } if message == body => Some((r.clone(), username.clone())), + _ => None, + }, + ) + .expect("the global feed should carry the message"); + assert_eq!(got, (room.to_string(), "e2e_global_talk".to_string())); + + // And leaving the feed stops it: the next message must not arrive. + watcher.leave_global_room().expect("leave global room"); + std::thread::sleep(Duration::from_millis(500)); + let _ = watcher.take_room_events(); + talker + .say_in_room(room, "after leaving") + .expect("say again"); + let after = await_room_event(&watcher, Duration::from_secs(2), |event| { + matches!(event, RoomEvent::GlobalMessage { message, .. } + if message == "after leaving") + .then_some(()) + }); + assert!(after.is_none(), "the feed should stop once left"); +} + +#[test] +fn a_user_search_is_answered_by_the_user_it_names() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_usersearch_grail.bin"), &content) + .unwrap(); + + let (_sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_us_sharer", + "e2e_us_seeker", + ); + + let query = "grail"; + searcher + .search_user("e2e_us_sharer", query) + .expect("user search"); + + let reply = reply_from(&searcher, query, "e2e_us_sharer") + .expect("the named user should answer a user search"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_usersearch_grail")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn a_room_search_is_answered_by_the_rooms_members() { + let server = server_or_skip!(); + + let room = "e2e_search_room"; + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_roomsearch_relic.bin"), &content) + .unwrap(); + + let (sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_rs_sharer", + "e2e_rs_seeker", + ); + sharer.join_room(room).expect("sharer joins"); + searcher.join_room(room).expect("searcher joins"); + std::thread::sleep(Duration::from_millis(750)); + + let query = "relic"; + searcher.search_room(room, query).expect("room search"); + + let reply = reply_from(&searcher, query, "e2e_rs_sharer") + .expect("a member of the room should answer a room search"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_roomsearch_relic")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn shared_interests_make_two_users_similar() { + let server = server_or_skip!(); + + let mut alice = + Client::with_settings(server.settings("e2e_like_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_like_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + let item = "e2e_interest_krautrock"; + alice.add_interest(item).expect("alice likes it"); + bob.add_interest(item).expect("bob likes it too"); + alice + .add_dislike("e2e_interest_muzak") + .expect("alice dislikes something"); + std::thread::sleep(Duration::from_millis(500)); + + // Who else likes this item (code 112). + let deadline = Instant::now() + Duration::from_secs(5); + let mut likers = Vec::new(); + while Instant::now() < deadline + && !likers.iter().any(|u| u == "e2e_like_bob") + { + alice + .request_item_similar_users(item) + .expect("ask who likes it"); + std::thread::sleep(Duration::from_millis(250)); + likers = alice.item_similar_users(item); + } + assert!( + likers.iter().any(|u| u == "e2e_like_bob"), + "bob likes the same item, got {likers:?}" + ); + + // The overlap also makes bob a similar user (code 110). + let deadline = Instant::now() + Duration::from_secs(5); + let mut similar = Vec::new(); + while Instant::now() < deadline + && !similar + .iter() + .any(|u: &soulseek_rs::SimilarUser| u.username == "e2e_like_bob") + { + alice + .request_similar_users() + .expect("ask for similar users"); + std::thread::sleep(Duration::from_millis(250)); + similar = alice.similar_users(); + } + assert!( + similar + .iter() + .any(|u| u.username == "e2e_like_bob" && u.weight > 0), + "bob should be similar with a non-zero weight, got {similar:?}" + ); + + // And alice's own likes and hates read back over code 57. + let deadline = Instant::now() + Duration::from_secs(5); + let mut interests = None; + while Instant::now() < deadline && interests.is_none() { + bob.request_user_interests("e2e_like_alice") + .expect("ask about alice"); + std::thread::sleep(Duration::from_millis(250)); + interests = bob.user_interests("e2e_like_alice"); + } + let interests = interests.expect("bob should learn alice's interests"); + assert!(interests.likes.iter().any(|i| i == item)); + assert!( + interests.hates.iter().any(|i| i == "e2e_interest_muzak"), + "hates should come back too, got {interests:?}" + ); + + // A dropped interest stops being reported. + alice.remove_interest(item).expect("alice drops it"); + let deadline = Instant::now() + Duration::from_secs(5); + let mut still_liked = true; + while Instant::now() < deadline && still_liked { + bob.request_user_interests("e2e_like_alice") + .expect("ask again"); + std::thread::sleep(Duration::from_millis(250)); + still_liked = bob + .user_interests("e2e_like_alice") + .is_none_or(|i| i.likes.iter().any(|l| l == item)); + } + assert!( + !still_liked, + "a removed interest should stop being reported" + ); +} + +#[test] +fn recommendations_are_returned_for_our_interests() { + let server = server_or_skip!(); + + // Recommendations come from what *other* users who share an interest also + // like: bob likes both items, so alice, who likes only the first, should + // be recommended the second. + let mut alice = + Client::with_settings(server.settings("e2e_rec_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_rec_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + let shared = "e2e_rec_shared"; + let other = "e2e_rec_other"; + alice.add_interest(shared).expect("alice likes shared"); + bob.add_interest(shared).expect("bob likes shared"); + bob.add_interest(other).expect("bob likes other"); + std::thread::sleep(Duration::from_millis(500)); + + let deadline = Instant::now() + Duration::from_secs(8); + let mut recommended = Vec::new(); + while Instant::now() < deadline + && !recommended + .iter() + .any(|r: &soulseek_rs::Recommendation| r.item == other) + { + alice + .request_recommendations() + .expect("ask for recommendations"); + std::thread::sleep(Duration::from_millis(250)); + recommended = + alice.recommendations().map(|(r, _)| r).unwrap_or_default(); + } + assert!( + recommended.iter().any(|r| r.item == other), + "the other item bob likes should be recommended, got {recommended:?}" + ); + + // The server-wide list (code 56) answers too, with the same shape. + let deadline = Instant::now() + Duration::from_secs(8); + let mut global = None; + while Instant::now() < deadline && global.is_none() { + alice + .request_global_recommendations() + .expect("ask for global recommendations"); + std::thread::sleep(Duration::from_millis(250)); + global = alice.global_recommendations(); + } + let (global_items, _) = global.expect("the server should answer code 56"); + assert!( + global_items.iter().any(|r| r.item == shared), + "an item two users like should show server-wide, got {global_items:?}" + ); + + // And per-item recommendations (code 111) answer for a named item. + let deadline = Instant::now() + Duration::from_secs(8); + let mut item_recs = Vec::new(); + while Instant::now() < deadline && item_recs.is_empty() { + alice + .request_item_recommendations(shared) + .expect("ask about one item"); + std::thread::sleep(Duration::from_millis(250)); + item_recs = alice.item_recommendations(shared); + } + assert!( + item_recs.iter().any(|r| r.item == other), + "people who like {shared} also like {other}, got {item_recs:?}" + ); +} + +#[test] +fn a_ping_leaves_the_session_usable() { + let server = server_or_skip!(); + + // A ping has no reply, so what it must not do is upset the session: the + // very next request has to still be answered. + let mut client = + Client::with_settings(server.settings("e2e_ping_user", "pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + for _ in 0..3 { + client.ping_server().expect("ping"); + std::thread::sleep(Duration::from_millis(100)); + } + + let deadline = Instant::now() + Duration::from_secs(5); + let mut listed = false; + while Instant::now() < deadline && !listed { + client.request_room_list().expect("request room list"); + std::thread::sleep(Duration::from_millis(250)); + listed = !client.room_list().is_empty() + || client.session_loss().is_none() && Instant::now() > deadline; + } + assert!( + client.session_loss().is_none(), + "pings must not cost us the session" + ); +} + +#[test] +fn one_message_reaches_several_users_at_once() { + let server = server_or_skip!(); + + let mut sender = + Client::with_settings(server.settings("e2e_many_sender", "pw")); + let mut first = + Client::with_settings(server.settings("e2e_many_first", "pw")); + let mut second = + Client::with_settings(server.settings("e2e_many_second", "pw")); + for client in [&mut sender, &mut first, &mut second] { + client.connect().expect("connect"); + assert!(client.login().expect("login")); + } + + let body = "one message, two recipients"; + sender + .send_private_message_to_many( + &["e2e_many_first".to_string(), "e2e_many_second".to_string()], + body, + ) + .expect("send to many"); + + for client in [&first, &second] { + let deadline = Instant::now() + Duration::from_secs(5); + let mut got = false; + while Instant::now() < deadline && !got { + got = client + .take_private_messages() + .iter() + .any(|m| m.message() == body); + if !got { + std::thread::sleep(Duration::from_millis(100)); + } + } + assert!(got, "every named recipient should receive the message"); + } +} + +#[test] +fn a_changed_password_is_what_the_next_login_needs() { + let server = server_or_skip!(); + + let user = "e2e_pwchange_user"; + let mut client = Client::with_settings(server.settings(user, "first-pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login registers the account")); + + client + .change_password("second-pw") + .expect("change password"); + std::thread::sleep(Duration::from_millis(500)); + drop(client); + + let mut stale = Client::with_settings(server.settings(user, "first-pw")); + stale.connect().expect("connect with the old password"); + assert!( + !matches!(stale.login(), Ok(true)), + "the old password must stop working" + ); + drop(stale); + + let mut fresh = Client::with_settings(server.settings(user, "second-pw")); + fresh.connect().expect("connect with the new password"); + assert!( + fresh.login().expect("login with the new password"), + "the new password must be accepted" + ); +} + +#[test] +fn a_brokered_dial_we_cannot_complete_tells_the_waiting_peer() { + // The peer asks the server to broker a connection to us but is itself + // unreachable โ€” it advertises a port nothing listens on. Our dial fails, + // and the peer must be told (CantConnectToPeer, code 1001) rather than + // left waiting for a connection that is never coming. + let server = server_or_skip!(); + let addr = format!("{}:{}", server.host, server.port); + + let mut client = + Client::with_settings(server.settings("e2e_cantconn_us", "pw")); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let mut peer = + login_raw(&addr, "e2e_cantconn_peer", "pw").expect("peer login"); + // A port that is free is a port nothing answers on: the dial is refused. + let dead_port = free_port().expect("free port"); + peer.write_all( + &MessageFactory::build_set_wait_port_message(dead_port).get_buffer(), + ) + .expect("set wait port"); + peer.flush().expect("flush wait port"); + + let token = 424_242_u32; + peer.write_all( + &MessageFactory::build_connect_to_peer( + token, + "e2e_cantconn_us", + ConnectionType::P, + ) + .get_buffer(), + ) + .expect("ask the server to broker"); + peer.flush().expect("flush broker request"); + + let mut reply = read_until_code(&mut peer, 1001, Duration::from_secs(20)) + .expect("the peer should be told the connection failed"); + reply.set_pointer(8); + assert_eq!( + reply.read_int32(), + token, + "the reply should quote the token the peer asked with" + ); +} + +// --------------------------------------------------------------------------- +// Features that were implemented but never driven end to end: the away +// status, the phrases the server refuses to search for, and asking a peer +// where our queued file sits. +// --------------------------------------------------------------------------- + +#[test] +fn going_away_is_visible_to_another_user() { + use soulseek_rs::types::UserStatus; + let server = server_or_skip!(); + + let mut alice = + Client::with_settings(server.settings("e2e_away_alice", "pw")); + let mut bob = Client::with_settings(server.settings("e2e_away_bob", "pw")); + alice.connect().expect("alice connect"); + bob.connect().expect("bob connect"); + assert!(alice.login().expect("alice login")); + assert!(bob.login().expect("bob login")); + + // Online to begin with, as the post-login handshake announces. + let status_of = |client: &Client| -> Option { + client + .user_info("e2e_away_alice") + .and_then(|info| info.presence) + .map(|presence| presence.status) + }; + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && status_of(&bob) != Some(UserStatus::Online) + { + bob.request_user_info("e2e_away_alice").expect("ask"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!(status_of(&bob), Some(UserStatus::Online)); + + alice.set_away(true).expect("alice goes away"); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline && status_of(&bob) != Some(UserStatus::Away) + { + bob.request_user_info("e2e_away_alice").expect("ask again"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!( + status_of(&bob), + Some(UserStatus::Away), + "the away status should reach another user" + ); + + // And coming back is visible too. + alice.set_away(false).expect("alice comes back"); + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && status_of(&bob) != Some(UserStatus::Online) + { + bob.request_user_info("e2e_away_alice") + .expect("ask once more"); + std::thread::sleep(Duration::from_millis(250)); + } + assert_eq!(status_of(&bob), Some(UserStatus::Online)); +} + +/// A minimal server that answers a login and then sends `after_login`. +/// +/// soulfind keeps its search filters in its database, which the suite has no +/// way to seed, so the excluded-phrase path is driven from a stub that speaks +/// just enough of the protocol to deliver code 160 on the wire. +fn stub_server_sending( + after_login: Vec, +) -> (String, std::thread::JoinHandle<()>) { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("bind stub server"); + let addr = listener.local_addr().expect("stub addr"); + let handle = std::thread::spawn(move || { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("stub read timeout"); + // Wait for the login (code 1) before answering it. + loop { + match read_framed(&mut stream) { + Ok(msg) if msg.get_message_code() == 1 => break, + Ok(_) => {} + Err(_) => return, + } + } + let mut ok = Message::new(); + ok.write_int32(1) + .write_int8(1) + .write_string("stub greeting") + .write_int32(0) + .write_string("") + .write_int8(0); + if stream.write_all(&ok.get_buffer()).is_err() { + return; + } + for message in after_login { + if stream.write_all(&message.get_buffer()).is_err() { + return; + } + } + let _ = stream.flush(); + // Hold the connection open so the client stays logged in. + std::thread::sleep(Duration::from_secs(10)); + }); + (format!("{}:{}", addr.ip(), addr.port()), handle) +} + +#[test] +fn phrases_the_server_excludes_are_kept_for_our_replies() { + // The server tells clients which phrases are excluded from the search + // network (code 160). Nicotine+ applies them to the files it offers in a + // search reply, not to the searches it sends, and so do we โ€” this pins + // that the announced list arrives and is kept for that use. + let mut phrases = Message::new(); + phrases + .write_int32(160) + .write_int32(2) + .write_string("banned-phrase") + .write_string("blocked"); + let (addr, stub) = stub_server_sending(vec![phrases]); + let (host, port) = addr.rsplit_once(':').expect("stub addr"); + + let mut client = Client::with_settings(ClientSettings { + username: "e2e_excluded".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new( + host.to_string(), + port.parse().expect("stub port"), + ), + enable_listen: false, + listen_port: 0, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }); + client.connect().expect("connect to stub"); + assert!(client.login().expect("login to stub")); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && client.excluded_search_phrases().is_empty() + { + std::thread::sleep(Duration::from_millis(100)); + } + assert_eq!( + client.excluded_search_phrases(), + ["banned-phrase", "blocked"], + "the announced phrases should be kept" + ); + + // Our own searches are not policed by them: that is the server's job, and + // no reference client refuses a query locally. + assert!( + client + .search("a banned-phrase query", Duration::from_millis(100)) + .is_ok() + ); + + drop(client); + let _ = stub.join(); +} + +#[test] +fn a_queued_download_can_ask_where_it_sits() { + // We queue a file with a peer that never starts it, then ask where it + // sits (peer code 51). The peer's answer (code 44) must land on the + // download as its queue position. + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut client = Client::with_settings(server.listening_settings( + "e2e_place_asker", + "pw", + listen_port, + )); + client.connect().expect("connect"); + assert!(client.login().expect("login")); + + let filename = "waiting_in_line.mp3"; + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (asked_tx, asked_rx) = std::sync::mpsc::channel(); + let listen_addr = format!("127.0.0.1:{listen_port}"); + let peer = std::thread::spawn(move || -> std::io::Result<()> { + let mut p = connect_retry(&listen_addr, Duration::from_secs(5))?; + p.set_read_timeout(Some(Duration::from_secs(15)))?; + p.write_all(&peer_init_bytes("e2e_place_peer", "P", 0))?; + p.flush()?; + let _ = ready_tx.send(()); + + // Sit on the queue request, then answer the place request with 7. + loop { + let mut msg = read_framed(&mut p)?; + // A QueueUpload (43) is accepted but never started: the file + // stays in line, which is the state being asked about. + if msg.get_message_code() == 51 { + msg.set_pointer(8); + let asked = msg.read_string(); + let mut reply = Message::new(); + reply.write_int32(44).write_string(&asked).write_int32(7); + p.write_all(&reply.get_buffer())?; + p.flush()?; + let _ = asked_tx.send(asked); + break; + } + } + std::thread::sleep(Duration::from_secs(1)); + Ok(()) + }); + ready_rx + .recv_timeout(Duration::from_secs(5)) + .expect("mock peer P connection"); + std::thread::sleep(Duration::from_secs(1)); + + let download_dir = unique_download_dir(); + let (_download, _status_rx) = client + .download( + filename.to_string(), + "e2e_place_peer".to_string(), + 10, + download_dir.display().to_string(), + ) + .expect("start download"); + + // Ask, retrying until the control connection carries it. + let deadline = Instant::now() + Duration::from_secs(10); + let mut asked = None; + while Instant::now() < deadline && asked.is_none() { + client + .request_place_in_queue("e2e_place_peer", filename) + .expect("ask for our place"); + asked = asked_rx.recv_timeout(Duration::from_millis(500)).ok(); + } + assert_eq!( + asked.as_deref(), + Some(filename), + "the peer should be asked about the file we queued" + ); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut place = None; + while Instant::now() < deadline && place.is_none() { + place = client + .get_all_downloads() + .into_iter() + .find(|d| d.filename == filename) + .and_then(|d| d.queue_position); + if place.is_none() { + std::thread::sleep(Duration::from_millis(100)); + } + } + assert_eq!( + place, + Some(7), + "the peer's answer should land on the download" + ); + + let _ = peer.join(); + let _ = std::fs::remove_dir_all(download_dir); +} + +#[test] +fn a_private_room_is_owned_granted_and_revoked() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_club"; + let mut owner = + Client::with_settings(server.settings("e2e_priv_owner", "pw")); + let mut guest = + Client::with_settings(server.settings("e2e_priv_guest", "pw")); + owner.connect().expect("owner connect"); + guest.connect().expect("guest connect"); + assert!(owner.login().expect("owner login")); + assert!(guest.login().expect("guest login")); + + guest + .set_room_invitations_enabled(true) + .expect("guest accepts invitations"); + owner + .join_private_room(room) + .expect("owner creates the room"); + std::thread::sleep(Duration::from_millis(750)); + + // The guest is invited, and hears about it (code 139). + owner + .add_room_member(room, "e2e_priv_guest") + .expect("owner invites the guest"); + let granted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: true + } if r == room) + .then_some(()) + }); + assert!( + granted.is_some(), + "the guest should be told they are a member" + ); + + // The owner's own roster now carries the guest (code 134). + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && !owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "the member roster should carry the guest, got {:?}", + owner.private_room_members(room) + ); + + // A member can be made an operator (code 145 for them, 143 for the room). + owner + .add_room_operator(room, "e2e_priv_guest") + .expect("owner promotes the guest"); + let promoted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: false, granted: true + } if r == room) + .then_some(()) + }); + assert!( + promoted.is_some(), + "the guest should be told they run the room" + ); + + // The owner's operator roster carries them too (code 143). + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline + && !owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "the operator roster should carry the guest, got {:?}", + owner.private_room_operators(room) + ); + + // And an operator can stand down themselves (code 147), which the + // owner's roster reflects. + guest + .resign_room_operatorship(room) + .expect("guest stands down"); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest") + { + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + !owner + .private_room_operators(room) + .iter() + .any(|u| u == "e2e_priv_guest"), + "an operator who stood down should leave the roster, got {:?}", + owner.private_room_operators(room) + ); + + // And membership can be taken away again (code 140). + owner + .remove_room_member(room, "e2e_priv_guest") + .expect("owner removes the guest"); + let revoked = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: false + } if r == room) + .then_some(()) + }); + assert!(revoked.is_some(), "the guest should be told they are out"); +} + +#[test] +fn a_private_room_someone_else_owns_cannot_be_taken() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_taken"; + let mut owner = + Client::with_settings(server.settings("e2e_taken_owner", "pw")); + owner.connect().expect("owner connect"); + assert!(owner.login().expect("owner login")); + owner + .join_private_room(room) + .expect("owner creates the room"); + std::thread::sleep(Duration::from_millis(750)); + + // An outsider asking for the same name must be refused (code 1003), not + // handed the room. + let mut outsider = + Client::with_settings(server.settings("e2e_taken_outsider", "pw")); + outsider.connect().expect("outsider connect"); + assert!(outsider.login().expect("outsider login")); + outsider + .join_private_room(room) + .expect("outsider asks for the same room"); + + let refused = + await_room_event(&outsider, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::CantCreate { room: r } if r == room) + .then_some(()) + }); + assert!( + refused.is_some(), + "a private room owned by someone else must be refused" + ); + assert!( + outsider.private_rooms().is_empty(), + "a refused room must not appear as one we belong to" + ); +} + +#[test] +fn an_acknowledged_offline_message_is_not_delivered_twice() { + // Private messages sent to an offline user are stored by the server and + // handed over at the next login; the client acknowledges each one + // (MessageAcked, code 23) so the server drops it. Without that ack the + // same message arrives again on every login, which is what this pins. + let server = server_or_skip!(); + + // Register the recipient so the server will hold mail for them. + let mut recipient = + Client::with_settings(server.settings("e2e_ack_recipient", "pw")); + recipient.connect().expect("recipient connect"); + assert!(recipient.login().expect("recipient login")); + drop(recipient); + // The dropped session's socket must actually be gone before the mail is + // sent, or the server delivers it live to a connection nobody is reading. + std::thread::sleep(Duration::from_secs(2)); + + let mut sender = + Client::with_settings(server.settings("e2e_ack_sender", "pw")); + sender.connect().expect("sender connect"); + assert!(sender.login().expect("sender login")); + let body = "left while you were out"; + sender + .send_private_message("e2e_ack_recipient", body) + .expect("send to an offline user"); + std::thread::sleep(Duration::from_millis(500)); + + let received_once = |label: &str| -> bool { + let mut client = + Client::with_settings(server.settings("e2e_ack_recipient", "pw")); + client.connect().unwrap_or_else(|e| panic!("{label}: {e}")); + assert!(client.login().expect("recipient login"), "{label}"); + let deadline = Instant::now() + Duration::from_secs(3); + let mut got = false; + while Instant::now() < deadline && !got { + got = client + .take_private_messages() + .iter() + .any(|m| m.message() == body); + if !got { + std::thread::sleep(Duration::from_millis(100)); + } + } + std::thread::sleep(Duration::from_millis(500)); + got + }; + + assert!( + received_once("first login"), + "the stored message should arrive at the next login" + ); + assert!( + !received_once("second login"), + "an acknowledged message must not be delivered again" + ); +} + +#[test] +fn a_wishlist_search_is_answered_like_any_other() { + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..1024u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(share_dir.join("e2e_wishlist_talisman.bin"), &content) + .unwrap(); + + let (_sharer, searcher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_wish_sharer", + "e2e_wish_seeker", + ); + + // A wish returns at once; its results accumulate under the query. + let query = "talisman"; + searcher + .start_wishlist_search(query) + .expect("start a wishlist search"); + + let reply = reply_from(&searcher, query, "e2e_wish_sharer") + .expect("a wishlist search should be answered like a plain one"); + assert!( + reply + .files + .iter() + .any(|f| f.name.contains("e2e_wishlist_talisman")), + "the reply should carry the matching file, got {:?}", + reply.files + ); + + // The server also announces how often it will accept one. + assert!( + searcher.wishlist_interval() > Duration::ZERO, + "the wishlist interval should be a real wait" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +// --------------------------------------------------------------------------- +// The other half of the tree: serving children. +// +// A client that accepts children takes `D` connections from peers, tells each +// where our branch sits, and passes every search it receives down to them. +// --------------------------------------------------------------------------- + +/// Read distributed frames from a child link until `pick` matches or time runs +/// out. +fn read_distributed_until( + stream: &mut TcpStream, + timeout: Duration, + mut pick: impl FnMut(&distributed::Distributed) -> Option, +) -> Option { + stream + .set_read_timeout(Some(Duration::from_millis(500))) + .ok()?; + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + // read_framed already returns the frame with its length prefix, + // which is exactly what the parser expects. + let Ok(mut frame) = read_framed(stream) else { + continue; + }; + if let Some(parsed) = distributed::parse(&mut frame) + && let Some(found) = pick(&parsed) + { + return Some(found); + } + } + None +} + +/// Dial `port` as a distributed child called `username`. +fn dial_as_child(port: u16, username: &str) -> std::io::Result { + let mut child = + connect_retry(&format!("127.0.0.1:{port}"), Duration::from_secs(5))?; + child.write_all(&peer_init_bytes(username, "D", 0))?; + child.flush()?; + Ok(child) +} + +#[test] +fn a_parent_tells_a_child_where_it_sits_and_passes_searches_down() { + let server = server_or_skip!(); + + let listen_port = free_port().expect("free listen port"); + let mut parent = Client::with_settings(ClientSettings { + accept_children: true, + ..server.listening_settings("e2e_par_parent", "pw", listen_port) + }); + parent.connect().expect("parent connect"); + assert!(parent.login().expect("parent login")); + + // A client takes children only once something feeds it the search + // stream โ€” here the server, which relays every search to us. Until then + // a child would hang from a branch that receives nothing, which is why + // Nicotine+ refuses one too. + let addr = format!("{}:{}", server.host, server.port); + let mut searcher = + login_raw(&addr, "e2e_par_searcher", "pw").expect("searcher login"); + let search = |searcher: &mut TcpStream, token: u32| { + searcher + .write_all( + &MessageFactory::build_file_search_message( + token, + "e2e_child_relay_probe", + ) + .get_buffer(), + ) + .expect("send a search"); + searcher.flush().expect("flush the search"); + }; + search(&mut searcher, 515_150); + std::thread::sleep(Duration::from_secs(2)); + + let mut child = + dial_as_child(listen_port, "e2e_par_child").expect("child dials"); + + // The child is told our branch before anything else: without it, it has + // nothing to report to the server as its own place. + let root = read_distributed_until( + &mut child, + Duration::from_secs(10), + |f| match f { + distributed::Distributed::BranchRoot(root) => Some(root.clone()), + _ => None, + }, + ) + .expect("a child should be told our branch root"); + assert_eq!( + root, "e2e_par_parent", + "a parentless client is its own root" + ); + let level = read_distributed_until( + &mut child, + Duration::from_secs(10), + |f| match f { + distributed::Distributed::BranchLevel(level) => Some(*level), + _ => None, + }, + ) + .expect("a child should be told our branch level"); + assert_eq!(level, 0); + + let deadline = Instant::now() + Duration::from_secs(5); + while parent.children().is_empty() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + assert_eq!(parent.children(), vec!["e2e_par_child".to_string()]); + + // A search the server hands us must reach the child, whether or not we + // can answer it ourselves โ€” carrying the stream is the parent's duty. + let token = 515_151_u32; + search(&mut searcher, token); + + let relayed = read_distributed_until( + &mut child, + Duration::from_secs(20), + |f| match f { + distributed::Distributed::Search { + username, + token: got, + query, + } if *got == token => Some((username.clone(), query.clone())), + _ => None, + }, + ) + .expect("the search should be passed down to the child"); + assert_eq!( + relayed, + ( + "e2e_par_searcher".to_string(), + "e2e_child_relay_probe".to_string() + ) + ); + + // A second link from the same child is refused: the one we hold is live. + let mut duplicate = + dial_as_child(listen_port, "e2e_par_child").expect("child dials again"); + let told_again = + read_distributed_until(&mut duplicate, Duration::from_secs(3), |f| { + matches!(f, distributed::Distributed::BranchRoot(_)).then_some(()) + }); + assert!(told_again.is_none(), "a child we carry is not taken twice"); + assert_eq!(parent.children(), vec!["e2e_par_child".to_string()]); + drop(duplicate); + + // A child that hangs up loses its place. + drop(child); + let deadline = Instant::now() + Duration::from_secs(20); + let mut next_token = token + 1; + while !parent.children().is_empty() && Instant::now() < deadline { + search(&mut searcher, next_token); + next_token += 1; + std::thread::sleep(Duration::from_millis(500)); + } + assert!( + parent.children().is_empty(), + "a child that went away should not keep its slot" + ); +} + +#[test] +fn a_client_that_does_not_serve_children_turns_one_away() { + let server = server_or_skip!(); + + // The default: a leaf. A peer that dials with a D connection is dropped, + // and never told a branch it could hang from. + let listen_port = free_port().expect("free listen port"); + let mut leaf = Client::with_settings(server.listening_settings( + "e2e_leafonly", + "pw", + listen_port, + )); + leaf.connect().expect("leaf connect"); + assert!(leaf.login().expect("leaf login")); + + let mut child = + dial_as_child(listen_port, "e2e_leafonly_child").expect("child dials"); + + let told = + read_distributed_until(&mut child, Duration::from_secs(3), |f| { + matches!(f, distributed::Distributed::BranchRoot(_)).then_some(()) + }); + assert!(told.is_none(), "a leaf must not take on a child"); + assert!(leaf.children().is_empty()); +} + +#[test] +fn one_folder_of_a_peers_shares_can_be_asked_for_by_itself() { + // "Download folder" in other clients asks for one folder (peer code 36) + // rather than pulling the peer's whole listing. + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::create_dir_all(share_dir.join("wanted")).unwrap(); + std::fs::create_dir_all(share_dir.join("unwanted")).unwrap(); + std::fs::write(share_dir.join("wanted").join("keep.bin"), b"abcd").unwrap(); + std::fs::write(share_dir.join("unwanted").join("skip.bin"), b"efgh") + .unwrap(); + + let (_sharer, asker) = sharer_and_searcher( + &server, + &share_dir, + "e2e_folder_sharer", + "e2e_folder_asker", + ); + + // The folder is named the way the sharer advertises it: the share root's + // basename, then the subfolder, backslash-separated. + let root = share_dir.file_name().unwrap().to_string_lossy().to_string(); + let folder = format!("{root}\\wanted"); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut listing = None; + while Instant::now() < deadline && listing.is_none() { + asker + .request_folder_contents("e2e_folder_sharer", &folder) + .expect("ask for one folder"); + std::thread::sleep(Duration::from_millis(500)); + listing = asker.take_folder_contents("e2e_folder_sharer", &folder); + } + let listing = listing.expect("the peer should answer with that folder"); + + assert!( + listing + .iter() + .any(|dir| dir.files.iter().any(|f| f.name.contains("keep.bin"))), + "the folder we asked for should be in the answer, got {listing:?}" + ); + assert!( + !listing + .iter() + .any(|dir| dir.files.iter().any(|f| f.name.contains("skip.bin"))), + "no other folder should come with it, got {listing:?}" + ); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn our_interests_are_sent_again_after_a_new_login() { + // The server keeps interests only for the session that set them, so a + // client that does not send them again comes back with none โ€” which is + // why Nicotine+ re-sends its list at every login, from its config. Ours + // holds the list itself: an interest set before there is a connection is + // still on the server once one is made. + let server = server_or_skip!(); + + let item = "e2e_relike_shoegaze"; + let mut alice = + Client::with_settings(server.settings("e2e_relike_alice", "pw")); + // Set before connecting: there is nothing to send it over yet, and the + // login is what puts it on the wire. + let _ = alice.add_interest(item); + assert_eq!(alice.own_interests().likes, [item]); + + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + std::thread::sleep(Duration::from_millis(500)); + + let mut watcher = + Client::with_settings(server.settings("e2e_relike_watch", "pw")); + watcher.connect().expect("watcher connect"); + assert!(watcher.login().expect("watcher login")); + + let likes_of_alice = |watcher: &Client| -> Vec { + let deadline = Instant::now() + Duration::from_secs(5); + let mut likes = Vec::new(); + while Instant::now() < deadline && !likes.iter().any(|i| i == item) { + watcher + .request_user_interests("e2e_relike_alice") + .expect("ask about alice"); + std::thread::sleep(Duration::from_millis(250)); + likes = watcher + .user_interests("e2e_relike_alice") + .map(|i| i.likes) + .unwrap_or_default(); + } + likes + }; + + let likes = likes_of_alice(&watcher); + assert!( + likes.iter().any(|i| i == item), + "the login should have carried the interest with it, got {likes:?}" + ); + + // The same client, in a new session: its list goes back up by itself. + drop(alice); + std::thread::sleep(Duration::from_secs(1)); + let mut again = + Client::with_settings(server.settings("e2e_relike_alice", "pw")); + let _ = again.add_interest(item); + again.connect().expect("reconnect"); + assert!(again.login().expect("re-login")); + std::thread::sleep(Duration::from_millis(500)); + + let likes = likes_of_alice(&watcher); + assert!( + likes.iter().any(|i| i == item), + "a new session should carry the interests too, got {likes:?}" + ); +} + +#[test] +fn a_finished_upload_teaches_us_our_own_recorded_speed() { + // The server records a client's upload speed and pushes it to whoever + // watches that user. We watch ourselves at login, which is how our own + // figure โ€” the one the distributed child limit is derived from โ€” reaches + // us without anyone asking on our behalf. + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + let content: Vec = (0..8192u32).map(|i| (i % 251) as u8).collect(); + let filename = "e2e_ownspeed_probe.bin"; + std::fs::write(share_dir.join(filename), &content).unwrap(); + + let (sharer, leecher) = sharer_and_searcher( + &server, + &share_dir, + "e2e_ownspeed_sharer", + "e2e_ownspeed_leecher", + ); + assert_eq!( + sharer.own_average_speed(), + Some(0), + "a client that has never uploaded is recorded at zero" + ); + + let query = "ownspeed"; + let _ = leecher.search(query, Duration::from_secs(3)); + let (path, size) = reply_from(&leecher, query, "e2e_ownspeed_sharer") + .and_then(|reply| reply.files.into_iter().next()) + .map(|file| (file.name, file.size)) + .expect("the leecher should find the file"); + + let download_dir = unique_download_dir(); + let (_download, status_rx) = leecher + .download( + path, + "e2e_ownspeed_sharer".to_string(), + size, + download_dir.display().to_string(), + ) + .expect("start download"); + assert!( + wait_for_completion(&leecher, &status_rx, Duration::from_secs(25)), + "the download should complete" + ); + + let deadline = Instant::now() + Duration::from_secs(15); + while Instant::now() < deadline + && sharer.own_average_speed().unwrap_or(0) == 0 + { + std::thread::sleep(Duration::from_millis(250)); + } + assert!( + sharer.own_average_speed().unwrap_or(0) > 0, + "the speed the server recorded for us should reach us unasked, got {:?} (info: {:?})", + sharer.own_average_speed(), + sharer.user_info("e2e_ownspeed_sharer"), + ); + + let _ = std::fs::remove_dir_all(share_dir); + let _ = std::fs::remove_dir_all(download_dir); +} + +#[test] +fn a_peer_tells_us_about_itself_when_asked() { + // The profile another client shows for a user: their description, upload + // slots and queue length, straight from the peer (code 15 and its reply). + let server = server_or_skip!(); + + let share_dir = unique_download_dir(); + std::fs::write(share_dir.join("e2e_peerinfo.bin"), b"data").unwrap(); + let (_sharer, asker) = sharer_and_searcher( + &server, + &share_dir, + "e2e_peerinfo_sharer", + "e2e_peerinfo_asker", + ); + + let deadline = Instant::now() + Duration::from_secs(20); + let mut info = None; + while Instant::now() < deadline && info.is_none() { + asker + .request_peer_info("e2e_peerinfo_sharer") + .expect("ask the peer about itself"); + std::thread::sleep(Duration::from_millis(500)); + info = asker.peer_info("e2e_peerinfo_sharer"); + } + let info = info.expect("the peer should answer for itself"); + + assert!( + info.slots_free, + "a peer with nothing queued has a free slot, got {info:?}" + ); + assert_eq!(info.queue_size, 0); + assert_eq!(info.picture_bytes, None); + + let _ = std::fs::remove_dir_all(share_dir); +} + +#[test] +fn a_room_reports_members_arriving_and_leaving() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_room_comings"; + let mut alice = + Client::with_settings(server.settings("e2e_comings_alice", "pw")); + alice.connect().expect("alice connect"); + assert!(alice.login().expect("alice login")); + alice.join_room(room).expect("alice joins"); + std::thread::sleep(Duration::from_millis(500)); + let _ = alice.take_room_events(); + + // Bob arrives after her: she is told (code 16), and her roster grows. + let mut bob = + Client::with_settings(server.settings("e2e_comings_bob", "pw")); + bob.connect().expect("bob connect"); + assert!(bob.login().expect("bob login")); + bob.join_room(room).expect("bob joins"); + + let joined = + await_room_event(&alice, Duration::from_secs(5), |event| match event { + RoomEvent::UserJoined { room: r, username } + if r == room && username == "e2e_comings_bob" => + { + Some(()) + } + _ => None, + }); + assert!(joined.is_some(), "alice should be told bob arrived"); + assert!( + alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob"), + "the roster should carry bob, got {:?}", + alice.room_members(room) + ); + + // And when he leaves (code 17) the roster loses him again. + bob.leave_room(room).expect("bob leaves"); + let left = + await_room_event(&alice, Duration::from_secs(5), |event| match event { + RoomEvent::UserLeft { room: r, username } + if r == room && username == "e2e_comings_bob" => + { + Some(()) + } + _ => None, + }); + assert!(left.is_some(), "alice should be told bob left"); + + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline + && alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob") + { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + !alice + .room_members(room) + .iter() + .any(|u| u == "e2e_comings_bob"), + "the roster should drop him, got {:?}", + alice.room_members(room) + ); +} + +#[test] +fn a_member_can_give_up_a_private_room_and_an_owner_can_disband_it() { + use soulseek_rs::types::RoomEvent; + let server = server_or_skip!(); + + let room = "e2e_private_leaving"; + let mut owner = + Client::with_settings(server.settings("e2e_leave_owner", "pw")); + let mut guest = + Client::with_settings(server.settings("e2e_leave_guest", "pw")); + owner.connect().expect("owner connect"); + guest.connect().expect("guest connect"); + assert!(owner.login().expect("owner login")); + assert!(guest.login().expect("guest login")); + + guest + .set_room_invitations_enabled(true) + .expect("guest accepts invitations"); + owner + .join_private_room(room) + .expect("owner creates the room"); + std::thread::sleep(Duration::from_millis(750)); + owner + .add_room_member(room, "e2e_leave_guest") + .expect("owner invites the guest"); + + let granted = await_room_event(&guest, Duration::from_secs(5), |event| { + matches!(event, RoomEvent::OwnStandingChanged { + room: r, members: true, granted: true + } if r == room) + .then_some(()) + }); + assert!(granted.is_some(), "the guest should be a member first"); + + // The guest gives up their own membership (code 136): the owner's roster + // loses them. + guest.leave_private_room(room).expect("guest resigns"); + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline + && owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_leave_guest") + { + std::thread::sleep(Duration::from_millis(200)); + } + assert!( + !owner + .private_room_members(room) + .iter() + .any(|u| u == "e2e_leave_guest"), + "a member who resigned should leave the roster, got {:?}", + owner.private_room_members(room) + ); + + // The owner disbands the room (code 137), which frees the name: someone + // else asking for it is not refused as they would be for a room that + // still belongs to another user. + owner.disband_private_room(room).expect("owner disbands"); + std::thread::sleep(Duration::from_secs(1)); + + let mut newcomer = + Client::with_settings(server.settings("e2e_leave_newcomer", "pw")); + newcomer.connect().expect("newcomer connect"); + assert!(newcomer.login().expect("newcomer login")); + newcomer + .join_private_room(room) + .expect("newcomer asks for the name"); + + let refused = + await_room_event(&newcomer, Duration::from_secs(3), |event| { + matches!(event, RoomEvent::CantCreate { room: r } if r == room) + .then_some(()) + }); + assert!( + refused.is_none(), + "a disbanded room's name should be free again" + ); +} + +#[test] +fn privileges_can_be_handed_to_another_user() { + let server = server_or_skip!(); + + // Only a privileged account can give privileges away, and privileges + // themselves are granted in soulfind's own database. + let giver = "e2e_gift_giver"; + let mut giving = Client::with_settings(server.settings(giver, "pw")); + giving.connect().expect("giver connect"); + assert!(giving.login().expect("giver login")); + if !grant(&server, giver, "privileges") { + println!("e2e skipped: cannot grant privileges on this server"); + return; + } + drop(giving); + std::thread::sleep(Duration::from_secs(1)); + + let mut giving = Client::with_settings(server.settings(giver, "pw")); + giving.connect().expect("giver reconnect"); + assert!(giving.login().expect("giver re-login")); + + let mut receiver = + Client::with_settings(server.settings("e2e_gift_receiver", "pw")); + receiver.connect().expect("receiver connect"); + assert!(receiver.login().expect("receiver login")); + + giving + .give_privileges("e2e_gift_receiver", 1) + .expect("hand over a day of privileges"); + + // The recipient now has privilege time of their own to check (code 92). + let deadline = Instant::now() + Duration::from_secs(10); + let mut left = 0; + while Instant::now() < deadline && left == 0 { + receiver + .check_privileges() + .expect("ask how much time we have"); + std::thread::sleep(Duration::from_millis(500)); + left = receiver.own_privilege_seconds().unwrap_or(0); + } + assert!( + left > 0, + "the gifted privileges should show up as time of our own" + ); +} + +#[test] +fn a_disconnected_client_gives_its_listener_port_back() { + // A client that goes out of scope must release the port it bound: the + // next one has to be able to advertise the port it was configured with, + // not fall back to an ephemeral one nobody was told about. No server is + // needed โ€” binding the listener happens in connect(), before the dial. + let port = free_port().expect("a port to hold"); + let settings = |port: u16| ClientSettings { + username: "e2e_port_release".to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new("127.0.0.1".to_string(), 1), + enable_listen: true, + listen_port: port, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }; + + let mut first = Client::with_settings(settings(port)); + first.connect().expect("bind the listener"); + assert_eq!(first.listen_port(), Some(port)); + drop(first); + + // The listener thread notices the stop the moment it is woken, which the + // disconnect does by dialling it; give it a beat to unwind. + let deadline = Instant::now() + Duration::from_secs(5); + let mut second_port = None; + while Instant::now() < deadline && second_port != Some(port) { + let mut second = Client::with_settings(settings(port)); + second.connect().expect("bind again"); + second_port = second.listen_port(); + if second_port == Some(port) { + break; + } + drop(second); + std::thread::sleep(Duration::from_millis(200)); + } + assert_eq!( + second_port, + Some(port), + "the port should be free again once the first client is dropped" + ); +} diff --git a/soulseek-rs-lib/tests/resume_e2e.rs b/soulseek-rs-lib/tests/resume_e2e.rs new file mode 100644 index 0000000..7271c25 --- /dev/null +++ b/soulseek-rs-lib/tests/resume_e2e.rs @@ -0,0 +1,409 @@ +//! Server-independent end-to-end coverage for downloads that resume. +//! +//! The subject is a real `Client` with its real peer listener and actor stack. +//! A loopback socket stands in for a Soulseek peer and speaks the public wire +//! format. The configured server is deliberately idle: peer transfers do not +//! require a login once the control connection is registered. + +use std::fs; +use std::io::{Read, Write}; +use std::net::{Shutdown, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant}; + +use soulseek_rs::message::Message; +use soulseek_rs::{ + Client, ClientSettings, ClientVersion, DownloadStatus, PeerAddress, +}; + +const IO_TIMEOUT: Duration = Duration::from_secs(10); + +struct ScratchDir(PathBuf); + +impl ScratchDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "soulseek-resume-e2e-{}-{label}", + std::process::id() + )); + let _ = fs::remove_dir_all(&path); + fs::create_dir_all(&path).expect("create scratch directory"); + Self(path) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for ScratchDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +struct Subject { + client: Client, + peer_addr: String, + // Keeping a server listener alive is enough for ServerActor's TCP connect. + // No login or server messages are needed by the peer-transfer path. + _idle_server: TcpListener, +} + +impl Subject { + fn new(username: &str) -> Self { + let idle_server = + TcpListener::bind("127.0.0.1:0").expect("bind idle server"); + let server_addr = + idle_server.local_addr().expect("idle server address"); + let mut client = Client::with_settings(ClientSettings { + username: username.to_string(), + password: "unused".to_string(), + server_address: PeerAddress::new( + "127.0.0.1".to_string(), + server_addr.port(), + ), + enable_listen: true, + listen_port: 0, + shared_directories: Vec::new(), + accept_children: false, + version: ClientVersion::default(), + }); + client.connect().expect("connect subject"); + let port = client.listen_port().expect("subject listener port"); + Self { + client, + peer_addr: format!("127.0.0.1:{port}"), + _idle_server: idle_server, + } + } +} + +struct ControlPeer { + username: String, + subject_addr: String, + stream: TcpStream, +} + +impl ControlPeer { + fn register(subject: &Subject, username: &str) -> Self { + let mut stream = TcpStream::connect(&subject.peer_addr) + .expect("connect peer control socket"); + stream + .set_read_timeout(Some(IO_TIMEOUT)) + .expect("control read timeout"); + stream + .set_write_timeout(Some(IO_TIMEOUT)) + .expect("control write timeout"); + + // Code 15/16 is a positive registration fence. Receiving the response + // proves the inbound P connection has reached the peer actor and can + // receive QueueUpload messages from `Client::download`. + stream + .write_all(&peer_init_bytes(username, "P", 0)) + .expect("send P PeerInit"); + stream + .write_all(&Message::new().write_int32(15).get_buffer()) + .expect("send UserInfoRequest registration fence"); + stream.flush().expect("flush registration fence"); + let _ = expect_code(&mut stream, 16); + + Self { + username: username.to_string(), + subject_addr: subject.peer_addr.clone(), + stream, + } + } + + fn accept_queue_and_offer(&mut self, filename: &str, size: u64) -> u32 { + let mut queue = expect_code(&mut self.stream, 43); + queue.set_pointer(8); + assert_eq!(queue.read_string(), filename, "queued remote filename"); + + let token = 700_000; + let mut request = Message::new(); + request + .write_int32(40) + .write_int32(1) // upload from the peer to the subject + .write_int32(token) + .write_string(filename) + .write_int64(size); + self.stream + .write_all(&request.get_buffer()) + .expect("send TransferRequest"); + self.stream.flush().expect("flush TransferRequest"); + + let mut response = expect_code(&mut self.stream, 41); + response.set_pointer(8); + assert_eq!(response.read_int32(), token, "response transfer token"); + assert_eq!(response.read_int8(), 1, "transfer should be allowed"); + token + } + + fn open_file_transfer(&self, token: u32) -> (TcpStream, u64) { + let mut stream = TcpStream::connect(&self.subject_addr) + .expect("connect peer file socket"); + stream + .set_read_timeout(Some(IO_TIMEOUT)) + .expect("file read timeout"); + stream + .set_write_timeout(Some(IO_TIMEOUT)) + .expect("file write timeout"); + + let mut init = peer_init_bytes(&self.username, "F", token); + init.extend_from_slice(&token.to_le_bytes()); + stream.write_all(&init).expect("send F PeerInit and token"); + stream.flush().expect("flush F PeerInit and token"); + + let mut offset = [0u8; 8]; + stream + .read_exact(&mut offset) + .expect("read START_DOWNLOAD offset"); + (stream, u64::from_le_bytes(offset)) + } +} + +fn peer_init_bytes( + username: &str, + connection_type: &str, + token: u32, +) -> Vec { + Message::new() + .write_int8(1) + .write_string(username) + .write_string(connection_type) + .write_int32(token) + .get_buffer() +} + +fn read_framed(stream: &mut TcpStream) -> std::io::Result { + let mut len = [0u8; 4]; + stream.read_exact(&mut len)?; + let mut payload = vec![0u8; u32::from_le_bytes(len) as usize]; + stream.read_exact(&mut payload)?; + let mut bytes = len.to_vec(); + bytes.extend_from_slice(&payload); + Ok(Message::new_with_data(bytes)) +} + +fn expect_code(stream: &mut TcpStream, expected: u32) -> Message { + let deadline = Instant::now() + IO_TIMEOUT; + loop { + let message = read_framed(stream).unwrap_or_else(|error| { + panic!("read peer code {expected}: {error}") + }); + if message.get_message_code() == expected { + return message; + } + assert!( + Instant::now() < deadline, + "timed out waiting for peer code {expected}" + ); + } +} + +fn start_download( + subject: &Subject, + control: &mut ControlPeer, + filename: &str, + content_len: usize, + destination: &Path, +) -> (Receiver, TcpStream, u64) { + let (_download, statuses) = subject + .client + .download( + filename.to_string(), + control.username.clone(), + content_len as u64, + destination.display().to_string(), + ) + .expect("queue download"); + let token = control.accept_queue_and_offer(filename, content_len as u64); + let (file_stream, offset) = control.open_file_transfer(token); + (statuses, file_stream, offset) +} + +fn wait_for_status( + statuses: &Receiver, + description: &str, + wanted: impl Fn(&DownloadStatus) -> bool, +) -> DownloadStatus { + let deadline = Instant::now() + IO_TIMEOUT; + loop { + if let Ok(status) = statuses.recv_timeout(Duration::from_millis(100)) + && wanted(&status) + { + return status; + } + assert!( + Instant::now() < deadline, + "status channel did not report {description}" + ); + } +} + +fn assert_stored_status( + subject: &Subject, + filename: &str, + wanted: impl Fn(&DownloadStatus) -> bool, +) { + let status = subject + .client + .get_all_downloads() + .into_iter() + .find(|download| download.filename == filename) + .unwrap_or_else(|| panic!("download {filename} is missing from store")) + .status; + assert!(wanted(&status), "unexpected stored status: {status:?}"); +} + +fn expect_downloader_close(stream: &mut TcpStream) { + let mut unexpected = [0u8; 1]; + assert_eq!( + stream + .read(&mut unexpected) + .expect("wait for downloader to close the F socket"), + 0, + "the downloader sends no bytes after its offset" + ); +} + +fn binary_payload(len: usize) -> Vec { + (0..len).map(|index| index as u8).collect() +} + +#[test] +fn an_actual_short_transfer_resumes_opaque_bytes() { + let subject = Subject::new("resume_binary_subject"); + let mut control = ControlPeer::register(&subject, "resume_binary_peer"); + let destination = ScratchDir::new("binary-tracer"); + let content = binary_payload(3 * 64 * 1024 + 257); + let filename = "opaque.bin"; + let interrupt_at = 64 * 1024 + 1; + let part_path = destination.path().join(format!("{filename}.part")); + let final_path = destination.path().join(filename); + + assert!(content.contains(&0)); + assert!(content.contains(&u8::MAX)); + assert!(std::str::from_utf8(&content).is_err()); + + let (first_statuses, mut first, first_offset) = start_download( + &subject, + &mut control, + filename, + content.len(), + destination.path(), + ); + assert_eq!(first_offset, 0); + first + .write_all(&content[..interrupt_at]) + .expect("write short first transfer"); + first.flush().expect("flush short first transfer"); + first + .shutdown(Shutdown::Write) + .expect("interrupt first transfer"); + drop(first); + + let _ = wait_for_status(&first_statuses, "Failed", |status| { + matches!(status, DownloadStatus::Failed(_)) + }); + assert_stored_status(&subject, filename, |status| { + matches!(status, DownloadStatus::Failed(_)) + }); + assert!(!final_path.exists()); + assert_eq!( + fs::read(&part_path).expect("read retained partial file"), + content[..interrupt_at] + ); + + let (retry_statuses, mut retry, retry_offset) = start_download( + &subject, + &mut control, + filename, + content.len(), + destination.path(), + ); + assert_eq!(retry_offset, interrupt_at as u64); + retry + .write_all(&content[interrupt_at..]) + .expect("write retry tail"); + retry.flush().expect("flush retry tail"); + expect_downloader_close(&mut retry); + drop(retry); + + let _ = wait_for_status(&retry_statuses, "Completed", |status| { + matches!(status, DownloadStatus::Completed) + }); + assert_stored_status(&subject, filename, |status| { + matches!(status, DownloadStatus::Completed) + }); + assert_eq!(fs::read(&final_path).unwrap(), content); + assert!(!part_path.exists()); +} + +#[test] +fn a_live_download_pauses_and_resumes_on_the_same_connection() { + let subject = Subject::new("resume_pause_subject"); + let mut control = ControlPeer::register(&subject, "resume_pause_peer"); + let destination = ScratchDir::new("pause-resume"); + let filename = "paused.bin"; + let content = binary_payload(5 * 64 * 1024 + 257); + let part_path = destination.path().join(format!("{filename}.part")); + + let (statuses, mut peer, offset) = start_download( + &subject, + &mut control, + filename, + content.len(), + destination.path(), + ); + assert_eq!(offset, 0); + + let first_chunk = 2 * 64 * 1024; + peer.write_all(&content[..first_chunk]) + .expect("send first chunk"); + peer.flush().expect("flush first chunk"); + let _ = wait_for_status(&statuses, "initial progress", |status| { + matches!( + status, + DownloadStatus::InProgress { + bytes_downloaded, + .. + } if *bytes_downloaded >= first_chunk as u64 + ) + }); + + assert!(subject.client.pause_download(&control.username, filename)); + let _ = wait_for_status(&statuses, "Paused", |status| { + matches!(status, DownloadStatus::Paused { .. }) + }); + + // Give a read already in flight one timeout cycle to observe the pause. + std::thread::sleep(Duration::from_millis(1_200)); + let stable_len = fs::metadata(&part_path).expect("paused partial").len(); + let queued_while_paused = 8 * 1024; + peer.write_all(&content[first_chunk..first_chunk + queued_while_paused]) + .expect("queue bytes while paused"); + peer.flush().expect("flush bytes while paused"); + std::thread::sleep(Duration::from_millis(300)); + assert_eq!( + fs::metadata(&part_path).expect("paused partial").len(), + stable_len, + "a paused transfer must stop consuming its socket" + ); + + assert!(subject.client.resume_download(&control.username, filename)); + peer.write_all(&content[first_chunk + queued_while_paused..]) + .expect("send tail after resume"); + peer.flush().expect("flush resumed tail"); + expect_downloader_close(&mut peer); + + let _ = wait_for_status(&statuses, "Completed", |status| { + matches!(status, DownloadStatus::Completed) + }); + assert_eq!( + fs::read(destination.path().join(filename)).unwrap(), + content + ); +} diff --git a/soulseek-rs/docs/openrpc.json b/soulseek-rs/docs/openrpc.json index d9ab8e0..cbf59a6 100644 --- a/soulseek-rs/docs/openrpc.json +++ b/soulseek-rs/docs/openrpc.json @@ -595,6 +595,18 @@ ], "type": "object" }, + "PasswordParams": { + "description": "A new password for the account the daemon is logged in as. Plain text on\nthe wire, like the token: the transport is the local socket, or TCP behind\nan SSH tunnel (see docs/daemon-protocol.md).", + "properties": { + "password": { + "type": "string" + } + }, + "required": [ + "password" + ], + "type": "object" + }, "QueryParams": { "properties": { "query": { @@ -729,6 +741,218 @@ "username" ], "type": "object" + }, + { + "description": "The ticker board of a room, sent on join.", + "properties": { + "event": { + "const": "tickers", + "type": "string" + }, + "room": { + "type": "string" + }, + "tickers": { + "items": { + "$ref": "#/components/schemas/RoomTickerDto" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "tickers" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "ticker_added", + "type": "string" + }, + "room": { + "type": "string" + }, + "ticker": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "ticker" + ], + "type": "object" + }, + { + "properties": { + "event": { + "const": "ticker_removed", + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username" + ], + "type": "object" + }, + { + "description": "A message from the global room feed; `room` names where it was said.", + "properties": { + "event": { + "const": "global_message", + "type": "string" + }, + "message": { + "type": "string" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "message" + ], + "type": "object" + }, + { + "description": "Who may enter a private room.", + "properties": { + "event": { + "const": "private_members", + "type": "string" + }, + "room": { + "type": "string" + }, + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "users" + ], + "type": "object" + }, + { + "description": "Who runs a private room.", + "properties": { + "event": { + "const": "private_operators", + "type": "string" + }, + "room": { + "type": "string" + }, + "users": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "event", + "room", + "users" + ], + "type": "object" + }, + { + "description": "A private room's member (`members` true) or operator roster changed.", + "properties": { + "added": { + "type": "boolean" + }, + "event": { + "const": "private_roster_changed", + "type": "string" + }, + "members": { + "type": "boolean" + }, + "room": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "username", + "members", + "added" + ], + "type": "object" + }, + { + "description": "Our own membership or operatorship of a private room changed.", + "properties": { + "event": { + "const": "own_standing_changed", + "type": "string" + }, + "granted": { + "type": "boolean" + }, + "members": { + "type": "boolean" + }, + "room": { + "type": "string" + } + }, + "required": [ + "event", + "room", + "members", + "granted" + ], + "type": "object" + }, + { + "description": "The room we asked to join could not be created.", + "properties": { + "event": { + "const": "cant_create", + "type": "string" + }, + "room": { + "type": "string" + } + }, + "required": [ + "event", + "room" + ], + "type": "object" } ] }, @@ -760,6 +984,22 @@ ], "type": "object" }, + "RoomTickerDto": { + "description": "One user's ticker in a room.", + "properties": { + "ticker": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "username", + "ticker" + ], + "type": "object" + }, "RoomUserStatsDto": { "description": "One member of a room, as the JoinRoom reply described them.", "properties": { @@ -1003,6 +1243,21 @@ ], "type": "object" }, + "SharedListing": { + "description": "This session's own share index, as a peer receives it โ€” what\n`shares.files` answers with.", + "properties": { + "directories": { + "items": { + "$ref": "#/components/schemas/SharedDirectoryDto" + }, + "type": "array" + } + }, + "required": [ + "directories" + ], + "type": "object" + }, "SharesStatus": { "description": "What the share index holds, from `shares.status` / `shares.reindex`.", "properties": { @@ -2089,6 +2344,26 @@ }, "summary": "Everyone this session is watching." }, + { + "name": "account.password", + "paramStructure": "by-name", + "params": [ + { + "name": "password", + "required": true, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/Ack" + } + }, + "summary": "Change the password of the account the daemon is logged in as, and store it on the daemon's host so a restart still logs in." + }, { "name": "shares.status", "paramStructure": "by-name", @@ -2101,6 +2376,18 @@ }, "summary": "What the share index currently holds." }, + { + "name": "shares.files", + "paramStructure": "by-name", + "params": [], + "result": { + "name": "result", + "schema": { + "$ref": "#/components/schemas/SharedListing" + } + }, + "summary": "Every indexed file, in the listing a browsing peer receives." + }, { "name": "shares.set", "paramStructure": "by-name", diff --git a/soulseek-rs/src/SKILL.md b/soulseek-rs/src/SKILL.md index b416efd..2dc21fc 100644 --- a/soulseek-rs/src/SKILL.md +++ b/soulseek-rs/src/SKILL.md @@ -152,6 +152,17 @@ cheapest way to prove the credentials and connection work. `privilege_seconds` is 0 for an ordinary account and `null` only when the server did not answer โ€” those are different facts, so do not treat null as 0. +**`account password --new-password `** (or +`--new-password-stdin`, which reads the first line of stdin and keeps the +password out of `ps`) โ€” one object: `user`, `action` (`password-changed`). +The server never confirms a password change, so exit 0 means the request went +out. The new password is stored in this machine's keychain, or the daemon's +when the run is routed to one. + +**`account logout`** โ€” one object: `user`, `action` (`logged-out`). Forgets +the password stored on this machine; the account itself is untouched, and the +next start asks for it again. + **`room list`** โ€” `room`, `users`. **`room users `** โ€” `room`, `user`, `status`, `average_speed`, `shared_files`, `shared_folders`, `slots_full`, `country`. Everything but @@ -205,6 +216,11 @@ config file; they print nothing. With a daemon running they also update it in place, so the folder is on the network at once rather than after a restart. **`shares status`** and **`shares reindex`** โ€” `folders`, `files`, `directories`, counted by logging in and scanning. +**`shares files [--filter ]`** โ€” the index file by file, in the same +records `browse` emits for a peer (`user`, `directory`, `path`, `size`, +`bitrate`, `duration`), so what the network sees of this account can be diffed +against what it sees of anyone else. Use it to check that a folder is really +being served before blaming the network. **`config path`**, **`config list`**, **`config get `**, and **`config set `** โ€” `key`, `value`. diff --git a/soulseek-rs/src/api.rs b/soulseek-rs/src/api.rs index 5c242f5..e3a10ba 100644 --- a/soulseek-rs/src/api.rs +++ b/soulseek-rs/src/api.rs @@ -168,6 +168,10 @@ pub trait SessionApi: Send + Sync { fn shared_counts(&self) -> (u32, u32); // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. fn shared_directories(&self) -> Vec; + /// The share index in the shape a peer receives it, for showing this + /// session's own files exactly as the network sees them. + // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. + fn shared_listing(&self) -> Vec; // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. fn set_shared_directories(&self, directories: Vec) -> Result<()>; @@ -178,6 +182,13 @@ pub trait SessionApi: Send + Sync { /// store and accepts silently. // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. fn set_download_directory(&self, directory: String) -> Result<()>; + + /// Make `password` this account's password on the server. + /// + /// `Ok(())` means the request reached the server, not that it took it: + /// the protocol's own reply to a password change is not read back. + // aislop-ignore-next-line complexity/function-too-long -- same scanner bug: bodyless signature measured to EOF. One line. + fn change_password(&self, password: &str) -> Result<()>; } /// The local session: the library client itself, so `Arc` is already an @@ -409,6 +420,10 @@ impl SessionApi for Client { Self::shared_directories(self) } + fn shared_listing(&self) -> Vec { + Self::shared_listing(self) + } + fn set_shared_directories(&self, directories: Vec) -> Result<()> { Self::set_shared_directories(self, directories) } @@ -416,4 +431,8 @@ impl SessionApi for Client { fn set_download_directory(&self, _directory: String) -> Result<()> { Ok(()) } + + fn change_password(&self, password: &str) -> Result<()> { + Self::change_password(self, password) + } } diff --git a/soulseek-rs/src/cli.rs b/soulseek-rs/src/cli.rs index ad2e2cf..8c5efd0 100644 --- a/soulseek-rs/src/cli.rs +++ b/soulseek-rs/src/cli.rs @@ -222,6 +222,10 @@ pub enum Commands { /// Confirm the credentials and connection work Whoami, + /// This account: its password, and what this machine stores for it + #[command(subcommand)] + Account(AccountCommand), + /// The folders this client shares #[command(subcommand)] Shares(SharesCommand), @@ -270,6 +274,34 @@ pub enum DaemonCommand { Stop, } +#[derive(Subcommand, Debug)] +#[non_exhaustive] +pub enum AccountCommand { + /// Change the account's password on the server + Password(PasswordArgs), + + /// Forget the password stored on this machine, so the next start asks + /// for it again + Logout, +} + +#[derive(Args, Debug)] +pub struct PasswordArgs { + /// The new password (visible in `ps`; prefer --new-password-stdin) + #[arg( + long, + value_name = "PASSWORD", + env = "SOULSEEK_NEW_PASSWORD", + hide_env_values = true + )] + pub new_password: Option, + + /// Read the new password from the first line of stdin (takes precedence + /// over --new-password) + #[arg(long, action = ArgAction::SetTrue)] + pub new_password_stdin: bool, +} + #[derive(Subcommand, Debug)] #[non_exhaustive] pub enum CompletionsCommand { @@ -375,6 +407,13 @@ pub enum SharesCommand { directory: String, }, + /// List every indexed file, the way `browse` lists a peer's + Files { + /// Only paths containing this text, case-insensitively + #[arg(long)] + filter: Option, + }, + /// Re-index the shares and report what the network will see Reindex, diff --git a/soulseek-rs/src/commands/account.rs b/soulseek-rs/src/commands/account.rs new file mode 100644 index 0000000..56e1521 --- /dev/null +++ b/soulseek-rs/src/commands/account.rs @@ -0,0 +1,59 @@ +//! The account itself: its password on the server, and the credentials this +//! machine keeps for it. + +use super::{Ctx, Session}; +use crate::cli::PasswordArgs; +use crate::output::{AccountRecord, CliError, CliResult, Out}; +use crate::persist::secret::{KeyringStore, SecretStore}; + +/// Change the account's password and store the new one, so the next start +/// still logs in. +pub fn password(ctx: &Ctx, args: &PasswordArgs) -> CliResult { + let new = new_password(args)?; + let session = Session::open(ctx)?; + let user = session.client.username(); + let stored = crate::persist::secret::change_password( + session.client.as_ref(), + &KeyringStore, + &new, + ) + .map_err(|e| { + CliError::connection(format!("cannot change the password: {e}")) + })?; + if let Some(e) = stored { + ctx.out + .warn(&format!("password changed, but not stored here: {e}")); + } + ctx.out.emit(&AccountRecord { + user, + action: "password-changed".to_string(), + }); + Ok(()) +} + +/// Forget the stored password. The account is untouched on the server; what +/// goes is this machine's copy of the way in. +pub fn logout(out: &Out, username: Option<&str>) -> CliResult { + let user = username.ok_or_else(crate::run::missing_username)?; + KeyringStore.delete(user).map_err(|e| { + CliError::usage(format!("cannot clear the stored password: {e}")) + })?; + out.emit(&AccountRecord { + user: user.to_string(), + action: "logged-out".to_string(), + }); + Ok(()) +} + +/// Stdin beats the flag, the way `--password-stdin` beats `--password`. +fn new_password(args: &PasswordArgs) -> CliResult { + if args.new_password_stdin { + return crate::run::password_from_stdin(); + } + match args.new_password.as_deref() { + Some(password) if !password.is_empty() => Ok(password.to_string()), + _ => Err(CliError::usage( + "no new password: pass --new-password or --new-password-stdin", + )), + } +} diff --git a/soulseek-rs/src/commands/mod.rs b/soulseek-rs/src/commands/mod.rs index 27df78a..c5a009e 100644 --- a/soulseek-rs/src/commands/mod.rs +++ b/soulseek-rs/src/commands/mod.rs @@ -5,6 +5,7 @@ //! first; the ones that only read the config file or the local machine are //! dispatched by `main::run` before any credentials are demanded. +pub mod account; pub mod completions; pub mod daemon; pub mod peer; @@ -15,7 +16,9 @@ pub mod transfer; pub mod wish; use crate::api::SessionApi; -use crate::cli::{Commands, MessageCommand, RoomCommand, TransferCommand}; +use crate::cli::{ + AccountCommand, Commands, MessageCommand, RoomCommand, TransferCommand, +}; use crate::output::{CliError, CliResult, Exit, Out, PortmapRecord}; use crate::port_mapping::{self, PortMapper}; use crate::remote::{Endpoint, RemoteSession}; @@ -314,9 +317,13 @@ fn dispatch(ctx: &Ctx, command: Commands) -> CliResult { Commands::User(args) => peer::user(ctx, &args), Commands::Watch(command) => peer::watch(ctx, &command), Commands::Whoami => peer::whoami(ctx), + Commands::Account(AccountCommand::Password(args)) => { + account::password(ctx, &args) + } // main.rs runs these itself: some need no account, the rest need the // config store in scope. The arms only keep the match exhaustive. - Commands::Portmap + Commands::Account(AccountCommand::Logout) + | Commands::Portmap | Commands::Serve(_) | Commands::Daemon(_) | Commands::Wish(_) diff --git a/soulseek-rs/src/commands/settings.rs b/soulseek-rs/src/commands/settings.rs index 01057d2..c85970e 100644 --- a/soulseek-rs/src/commands/settings.rs +++ b/soulseek-rs/src/commands/settings.rs @@ -163,6 +163,28 @@ pub fn shares_status(ctx: &Ctx) -> CliResult { Ok(()) } +/// List the share index file by file, in the same records `browse` emits for +/// a peer โ€” so what the network sees of us can be read, and diffed, exactly +/// the way it is read of anyone else. +pub fn shares_files(ctx: &Ctx, filter: Option<&str>) -> CliResult { + let session = Session::open(ctx)?; + let needle = filter.map(str::to_lowercase); + let found = super::social::emit_listing( + ctx, + &session.client.username(), + &session.client.shared_listing(), + needle.as_deref(), + ); + + if found == 0 { + return Err(CliError::no_results(match filter { + Some(filter) => format!("no shared file matches '{filter}'"), + None => "no files are indexed".to_string(), + })); + } + Ok(()) +} + /// Re-scan the configured folders in a live session, so files added since the /// last run become visible without waiting for the next one. pub fn shares_reindex(ctx: &Ctx) -> CliResult { diff --git a/soulseek-rs/src/commands/social.rs b/soulseek-rs/src/commands/social.rs index f9b897c..e9f543c 100644 --- a/soulseek-rs/src/commands/social.rs +++ b/soulseek-rs/src/commands/social.rs @@ -48,6 +48,42 @@ pub fn fetch_listing( }) } +/// Emit `user`'s listing one file per record, keeping only the paths that +/// contain `needle` (already lowercased; `None` keeps all). Returns how many +/// were emitted. +/// +/// Shared with `shares files`, which prints this session's own index: the two +/// are only comparable while they are built the same way, and a field added +/// here has to reach both. +pub fn emit_listing( + ctx: &Ctx, + user: &str, + directories: &[SharedDirectory], + needle: Option<&str>, +) -> usize { + let mut emitted = 0usize; + for directory in directories { + for file in &directory.files { + let path = full_path(&directory.name, &file.name); + if needle + .is_some_and(|needle| !path.to_lowercase().contains(needle)) + { + continue; + } + emitted += 1; + ctx.out.emit(&BrowseRecord { + user: user.to_string(), + directory: directory.name.clone(), + path, + size: file.size, + bitrate: file.attribute(super::transfer::ATTR_BITRATE), + duration: file.attribute(super::transfer::ATTR_DURATION), + }); + } + } + emitted +} + pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult { let session = Session::open(ctx)?; ctx.out @@ -59,22 +95,7 @@ pub fn browse(ctx: &Ctx, args: &BrowseArgs) -> CliResult { Duration::from_secs(args.timeout), )?; - let mut files = 0usize; - for directory in &directories { - for file in &directory.files { - files += 1; - ctx.out.emit(&BrowseRecord { - user: args.user.clone(), - directory: directory.name.clone(), - path: full_path(&directory.name, &file.name), - size: file.size, - bitrate: file.attribute(super::transfer::ATTR_BITRATE), - duration: file.attribute(super::transfer::ATTR_DURATION), - }); - } - } - - if files == 0 { + if emit_listing(ctx, &args.user, &directories, None) == 0 { return Err(CliError::no_results(format!( "{} shares no files", args.user diff --git a/soulseek-rs/src/daemon/mod.rs b/soulseek-rs/src/daemon/mod.rs index 0575f39..43453d9 100644 --- a/soulseek-rs/src/daemon/mod.rs +++ b/soulseek-rs/src/daemon/mod.rs @@ -197,6 +197,7 @@ pub fn run( let daemon = Arc::new(Daemon { session: Arc::clone(&session.client), + secrets: Arc::new(crate::persist::secret::KeyringStore), hub: Arc::clone(&hub), browses: Arc::clone(&browses), downloads: Arc::clone(&downloads), diff --git a/soulseek-rs/src/daemon/openrpc.rs b/soulseek-rs/src/daemon/openrpc.rs index 59e19ee..46ca070 100644 --- a/soulseek-rs/src/daemon/openrpc.rs +++ b/soulseek-rs/src/daemon/openrpc.rs @@ -12,10 +12,10 @@ use super::proto::{ Ack, AuthParams, AuthResult, BrowseEvent, DaemonStatus, DirectoriesParams, DirectoryParams, DownloadStartParams, DownloadStarted, DownloadStatusEvent, Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, - Messages, Method, QueryParams, RoomEventDto, RoomRef, SayParams, - SearchResults, Searches, Seconds, SessionLossEvent, SharesStatus, - SlotsParams, TransferRef, UploadInfoDto, Uploads, UserMessageDto, UserRef, - UserResult, Watched, + Messages, Method, PasswordParams, QueryParams, RoomEventDto, RoomRef, + SayParams, SearchResults, Searches, Seconds, SessionLossEvent, + SharedListing, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, + Uploads, UserMessageDto, UserRef, UserResult, Watched, }; use schemars::{JsonSchema, SchemaGenerator, generate::SchemaSettings}; use serde_json::{Value, json}; @@ -47,6 +47,7 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value { | Method::DownloadCancel | Method::UploadCancel => schema_of::(generator), Method::DownloadSetDir => schema_of::(generator), + Method::AccountPassword => schema_of::(generator), Method::UploadSlots => schema_of::(generator), Method::RoomJoin | Method::RoomLeave @@ -73,6 +74,7 @@ fn params_schema(method: Method, generator: &mut SchemaGenerator) -> Value { | Method::MessageHistory | Method::SearchList | Method::SharesStatusOf + | Method::SharesFiles | Method::SharesReindex | Method::UserWatched => json!({ "type": "null" }), } @@ -101,6 +103,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value { Method::SharesStatusOf | Method::SharesSet | Method::SharesReindex => { schema_of::(generator) } + Method::SharesFiles => schema_of::(generator), // Everything else acknowledges and says nothing more. Method::DaemonStop | Method::SearchStart @@ -111,6 +114,7 @@ fn result_schema(method: Method, generator: &mut SchemaGenerator) -> Value { | Method::DownloadRemoveQueued | Method::DownloadCancel | Method::DownloadSetDir + | Method::AccountPassword | Method::UploadCancel | Method::UploadSlots | Method::PrivilegesCheck @@ -170,6 +174,10 @@ fn summary(method: Method) -> &'static str { Method::DownloadSetDir => { "Land future transfers in a different folder on the daemon's host." } + Method::AccountPassword => { + "Change the password of the account the daemon is logged in as, \ + and store it on the daemon's host so a restart still logs in." + } Method::UploadList => "Uploads being served, active first.", Method::UploadCancel => "Stop serving an upload in progress.", Method::UploadSlots => "How many uploads may run at once.", @@ -206,6 +214,9 @@ fn summary(method: Method) -> &'static str { } Method::UserInfoOf => "What the server has said about a user so far.", Method::SharesStatusOf => "What the share index currently holds.", + Method::SharesFiles => { + "Every indexed file, in the listing a browsing peer receives." + } Method::SharesSet => "Replace the shared folders and re-index.", Method::SharesReindex => "Re-scan the configured folders.", } diff --git a/soulseek-rs/src/daemon/proto/dto.rs b/soulseek-rs/src/daemon/proto/dto.rs index 82ea25a..2598859 100644 --- a/soulseek-rs/src/daemon/proto/dto.rs +++ b/soulseek-rs/src/daemon/proto/dto.rs @@ -373,6 +373,79 @@ pub enum RoomEventDto { room: String, username: String, }, + /// The ticker board of a room, sent on join. + Tickers { + room: String, + tickers: Vec, + }, + TickerAdded { + room: String, + username: String, + ticker: String, + }, + TickerRemoved { + room: String, + username: String, + }, + /// A message from the global room feed; `room` names where it was said. + GlobalMessage { + room: String, + username: String, + message: String, + }, + /// Who may enter a private room. + PrivateMembers { + room: String, + users: Vec, + }, + /// Who runs a private room. + PrivateOperators { + room: String, + users: Vec, + }, + /// A private room's member (`members` true) or operator roster changed. + PrivateRosterChanged { + room: String, + username: String, + members: bool, + added: bool, + }, + /// Our own membership or operatorship of a private room changed. + OwnStandingChanged { + room: String, + members: bool, + granted: bool, + }, + /// The room we asked to join could not be created. + CantCreate { + room: String, + }, +} + +/// One user's ticker in a room. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct RoomTickerDto { + pub username: String, + pub ticker: String, +} + +impl From<&soulseek_rs::RoomTicker> for RoomTickerDto { + fn from(ticker: &soulseek_rs::RoomTicker) -> Self { + Self { + username: ticker.username.clone(), + ticker: ticker.ticker.clone(), + } + } +} + +impl From for soulseek_rs::RoomTicker { + fn from(dto: RoomTickerDto) -> Self { + Self { + username: dto.username, + ticker: dto.ticker, + } + } } impl From<&RoomEvent> for RoomEventDto { @@ -403,6 +476,67 @@ impl From<&RoomEvent> for RoomEventDto { room: room.clone(), username: username.clone(), }, + RoomEvent::Tickers { room, tickers } => Self::Tickers { + room: room.clone(), + tickers: tickers.iter().map(RoomTickerDto::from).collect(), + }, + RoomEvent::TickerAdded { + room, + username, + ticker, + } => Self::TickerAdded { + room: room.clone(), + username: username.clone(), + ticker: ticker.clone(), + }, + RoomEvent::TickerRemoved { room, username } => { + Self::TickerRemoved { + room: room.clone(), + username: username.clone(), + } + } + RoomEvent::GlobalMessage { + room, + username, + message, + } => Self::GlobalMessage { + room: room.clone(), + username: username.clone(), + message: message.clone(), + }, + RoomEvent::PrivateMembers { room, users } => Self::PrivateMembers { + room: room.clone(), + users: users.clone(), + }, + RoomEvent::PrivateOperators { room, users } => { + Self::PrivateOperators { + room: room.clone(), + users: users.clone(), + } + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => Self::PrivateRosterChanged { + room: room.clone(), + username: username.clone(), + members: *members, + added: *added, + }, + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => Self::OwnStandingChanged { + room: room.clone(), + members: *members, + granted: *granted, + }, + RoomEvent::CantCreate { room } => { + Self::CantCreate { room: room.clone() } + } } } } @@ -432,6 +566,58 @@ impl From for RoomEvent { RoomEventDto::UserLeft { room, username } => { Self::UserLeft { room, username } } + RoomEventDto::Tickers { room, tickers } => Self::Tickers { + room, + tickers: tickers.into_iter().map(Into::into).collect(), + }, + RoomEventDto::TickerAdded { + room, + username, + ticker, + } => Self::TickerAdded { + room, + username, + ticker, + }, + RoomEventDto::TickerRemoved { room, username } => { + Self::TickerRemoved { room, username } + } + RoomEventDto::GlobalMessage { + room, + username, + message, + } => Self::GlobalMessage { + room, + username, + message, + }, + RoomEventDto::PrivateMembers { room, users } => { + Self::PrivateMembers { room, users } + } + RoomEventDto::PrivateOperators { room, users } => { + Self::PrivateOperators { room, users } + } + RoomEventDto::PrivateRosterChanged { + room, + username, + members, + added, + } => Self::PrivateRosterChanged { + room, + username, + members, + added, + }, + RoomEventDto::OwnStandingChanged { + room, + members, + granted, + } => Self::OwnStandingChanged { + room, + members, + granted, + }, + RoomEventDto::CantCreate { room } => Self::CantCreate { room }, } } } @@ -584,6 +770,26 @@ impl From<&SharedDirectory> for SharedDirectoryDto { } } +/// The same, consuming the listing. The daemon owns what `shared_listing` +/// hands it and drops it straight after, so copying every name and attribute +/// out of it would double the cost of answering `shares.files`. +impl From for SharedDirectoryDto { + fn from(directory: SharedDirectory) -> Self { + Self { + name: directory.name, + files: directory + .files + .into_iter() + .map(|file| SharedFileEntryDto { + name: file.name, + size: file.size, + attributes: file.attributes, + }) + .collect(), + } + } +} + impl From for SharedDirectory { fn from(dto: SharedDirectoryDto) -> Self { Self { diff --git a/soulseek-rs/src/daemon/proto/method.rs b/soulseek-rs/src/daemon/proto/method.rs index af379e0..de93784 100644 --- a/soulseek-rs/src/daemon/proto/method.rs +++ b/soulseek-rs/src/daemon/proto/method.rs @@ -82,6 +82,15 @@ pub struct DirectoriesParams { pub directories: Vec, } +/// A new password for the account the daemon is logged in as. Plain text on +/// the wire, like the token: the transport is the local socket, or TCP behind +/// an SSH tunnel (see docs/daemon-protocol.md). +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct PasswordParams { + pub password: String, +} + /// Where the daemon should land transfers from now on, on *its* filesystem. /// The same trust as `shares.set`: an authenticated caller is the owner. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] @@ -203,6 +212,14 @@ pub struct BrowseEvent { pub directories: Vec, } +/// This session's own share index, as a peer receives it โ€” what +/// `shares.files` answers with. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[cfg_attr(test, derive(schemars::JsonSchema))] +pub struct SharedListing { + pub directories: Vec, +} + /// One step in a transfer this client started. #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[cfg_attr(test, derive(schemars::JsonSchema))] diff --git a/soulseek-rs/src/daemon/proto/mod.rs b/soulseek-rs/src/daemon/proto/mod.rs index e0e243a..9f24d7c 100644 --- a/soulseek-rs/src/daemon/proto/mod.rs +++ b/soulseek-rs/src/daemon/proto/mod.rs @@ -111,7 +111,9 @@ wire_vocabulary! { UserWatch => "user.watch", UserUnwatch => "user.unwatch", UserWatched => "user.watched", + AccountPassword => "account.password", SharesStatusOf => "shares.status", + SharesFiles => "shares.files", SharesSet => "shares.set", SharesReindex => "shares.reindex", } diff --git a/soulseek-rs/src/daemon/server/mod.rs b/soulseek-rs/src/daemon/server/mod.rs index 37bd4e2..ca7fe3c 100644 --- a/soulseek-rs/src/daemon/server/mod.rs +++ b/soulseek-rs/src/daemon/server/mod.rs @@ -11,13 +11,15 @@ use super::proto::{ DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, DownloadStartParams, DownloadStarted, Downloads, IntervalSeconds, MemberStats, Members, MessageParams, Messages, Method, OPENRPC, - PROTOCOL_VERSION, QueryParams, RoomRef, RoomUserStatsDto, RpcError, - SayParams, SearchResultDto, SearchResults, SearchSummary, Searches, - Seconds, SharesStatus, SlotsParams, TransferRef, UploadInfoDto, Uploads, - UserInfoDto, UserRef, UserResult, Watched, + PROTOCOL_VERSION, PasswordParams, QueryParams, RoomRef, RoomUserStatsDto, + RpcError, SayParams, SearchResultDto, SearchResults, SearchSummary, + Searches, Seconds, SharedDirectoryDto, SharedListing, SharesStatus, + SlotsParams, TransferRef, UploadInfoDto, Uploads, UserInfoDto, UserRef, + UserResult, Watched, }; use crate::api::SessionApi; use crate::output::Exit; +use crate::persist::secret::SecretStore; use serde::de::DeserializeOwned; use serde_json::Value; use std::sync::atomic::{AtomicBool, Ordering}; @@ -27,6 +29,9 @@ use std::time::{Duration, Instant}; /// Everything a connection needs to answer a request. pub struct Daemon { pub session: Arc, + /// Where this host keeps the account's password. The daemon owns the + /// login, so a password it changes it also has to store. + pub secrets: Arc, pub hub: Arc, pub browses: Arc, pub downloads: Arc>>, @@ -346,7 +351,36 @@ impl Daemon { users: self.session.watched_users(), }), + Method::AccountPassword => { + let params: PasswordParams = parse(params)?; + // The daemon logs in again from this host's store when it + // restarts, so a password it changed but never stored would + // lock it out of its own account. + let stored = crate::persist::secret::change_password( + self.session.as_ref(), + self.secrets.as_ref(), + ¶ms.password, + ) + .map_err(|e| { + RpcError::application(Exit::Connection, e.to_string()) + })?; + if let Some(e) = stored { + soulseek_rs::warn!( + "Password changed, but not stored on this host: {e}" + ); + } + ok(Ack::OK) + } + Method::SharesStatusOf => ok(self.shares()), + Method::SharesFiles => ok(SharedListing { + directories: self + .session + .shared_listing() + .into_iter() + .map(SharedDirectoryDto::from) + .collect(), + }), Method::SharesSet => { let directories: DirectoriesParams = parse(params)?; self.session @@ -656,6 +690,32 @@ mod tests { assert_eq!(stats["members"], json!([])); } + /// The daemon owns the login, so a password it changes it also stores โ€” + /// its next start logs in from that store, not from the client that + /// asked. + #[test] + fn a_password_change_reaches_the_session_and_this_hosts_store() { + let session = Arc::new(SilentSession::default()); + let daemon = daemon_with("t", Arc::clone(&session)); + + daemon + .call(Method::AccountPassword, json!({ "password": "hunter2" })) + .expect("the password change is served"); + + assert_eq!( + *session.password.lock().expect("not poisoned"), + Some("hunter2".to_string()) + ); + assert_eq!( + daemon + .secrets + .get(&session.username()) + .expect("the store answers") + .as_deref(), + Some("hunter2") + ); + } + #[test] fn a_watch_is_readable_back_and_survives_until_unwatched() { // The three methods are only useful together: what `user.watch` @@ -719,14 +779,23 @@ mod tests { #[derive(Default)] struct SilentSession { watched: Mutex>, + /// What `account.password` asked the session to set, if anything. + password: Mutex>, } /// The one room `SilentSession` claims to have joined. const STUB_ROOM: &str = "jazz"; fn daemon_with_token(token: &str) -> Daemon { + daemon_with(token, Arc::new(SilentSession::default())) + } + + /// A daemon over a session the caller can read back, with an in-memory + /// secret store: no test may touch the machine's keychain. + fn daemon_with(token: &str, session: Arc) -> Daemon { Daemon { - session: Arc::new(SilentSession::default()), + session, + secrets: Arc::new(crate::persist::secret::MemoryStore::default()), hub: Arc::new(Hub::new()), browses: Arc::new(PendingBrowses::default()), downloads: Arc::new(Mutex::new(Vec::new())), @@ -745,6 +814,11 @@ mod tests { fn username(&self) -> String { "tester".to_string() } + fn change_password(&self, password: &str) -> soulseek_rs::Result<()> { + *self.password.lock().expect("not poisoned") = + Some(password.to_string()); + Ok(()) + } fn listen_port(&self) -> Option { None } @@ -953,6 +1027,9 @@ mod tests { fn shared_counts(&self) -> (u32, u32) { (0, 0) } + fn shared_listing(&self) -> Vec { + Vec::new() + } fn shared_directories(&self) -> Vec { Vec::new() } diff --git a/soulseek-rs/src/lib.rs b/soulseek-rs/src/lib.rs index bda54b4..f09c0c3 100644 --- a/soulseek-rs/src/lib.rs +++ b/soulseek-rs/src/lib.rs @@ -14,3 +14,9 @@ pub(crate) mod port_mapping; pub mod remote; pub mod run; pub(crate) mod ui; + +/// The interactive window, reachable so an integration test can drive it +/// with key events, advance it a frame at a time and read the screen back. +/// Hidden from the documented API: it is a harness door, not a contract. +#[doc(hidden)] +pub use ui::MainTui; diff --git a/soulseek-rs/src/models/app_state.rs b/soulseek-rs/src/models/app_state.rs index 3c8a786..a97f16e 100644 --- a/soulseek-rs/src/models/app_state.rs +++ b/soulseek-rs/src/models/app_state.rs @@ -1,6 +1,6 @@ use crate::models::{BrowseTabs, FileDisplayData, RoomsState, SettingsState}; use chrono::{DateTime, Local}; -use ratatui::{layout::Rect, widgets::TableState}; +use ratatui::{layout::Rect, text::Line, widgets::TableState}; use soulseek_rs::{DownloadStatus, types::Download}; use std::sync::atomic::AtomicBool; use std::sync::{Arc, mpsc::Receiver, mpsc::Sender}; @@ -57,6 +57,14 @@ impl FocusedPane { pub struct PaneLayout { hidden: [bool; FocusedPane::ALL.len()], pub zoomed: bool, + /// Cell height dragged onto the divider between Results and the row + /// underneath. `None` keeps the default proportions. + pub top_height: Option, + /// Cell widths dragged onto the dividers in the bottom row. `None` keeps + /// the default proportions; Info, which has no divider of its own, takes + /// whatever the row has left over. + pub searches_width: Option, + pub downloads_width: Option, } impl PaneLayout { @@ -174,6 +182,55 @@ impl LogView { } } +/// A log wrapped to a width, kept between frames. +/// +/// Wrapping every line of a long log on every draw is what made a busy room +/// lag. Lines only ever arrive at the end, so a draw wraps the new ones and +/// starts over only when the width or `key` (what is being shown, a filter +/// say) changes. +#[derive(Debug, Clone, Default)] +pub struct WrappedLog { + width: usize, + key: String, + /// How many source lines the rows cover. + wrapped: usize, + rows: Vec>, +} + +impl WrappedLog { + /// The rows for `lines`, `wrap` turning one line into its rows (none, + /// for a line a filter drops). + pub fn rows( + &mut self, + width: usize, + key: &str, + lines: &[T], + wrap: impl Fn(&T) -> Vec>, + ) -> &[Line<'static>] { + if width != self.width || key != self.key || lines.len() < self.wrapped + { + self.rows.clear(); + self.wrapped = 0; + self.width = width; + self.key = key.to_string(); + } + for line in &lines[self.wrapped..] { + self.rows.extend(wrap(line)); + } + self.wrapped = lines.len(); + &self.rows + } +} + +/// Why the window closed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TuiExit { + /// The program is done. + Quit, + /// The account was logged out: the caller shows the login screen again. + Logout, +} + /// What the shared command bar is currently capturing input for. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandBarMode { @@ -238,7 +295,8 @@ pub struct AppState { /// Where the keys overlay is being read, for a terminal too short to /// show it whole. pub help_view: LogView, - pub should_exit: bool, + /// Set once, when the window is closing, to why. + pub exit: Option, pub command_bar_active: bool, pub command_bar_input: String, pub command_bar_cursor_position: usize, @@ -254,6 +312,11 @@ pub struct AppState { /// Compose buffer for the active conversation. pub chat_input: String, pub chat_composing: bool, + /// The open conversation wrapped for drawing. + pub chat_wrapped: WrappedLog, + /// Narrows the open conversation to messages mentioning it. + pub chat_filter: String, + pub chat_filtering: bool, /// Where the conversation is being read. pub chat_view: LogView, @@ -277,6 +340,15 @@ pub struct AppState { pub searches_pane_area: Option, pub results_pane_area: Option, pub downloads_pane_area: Option, + /// The whole area the panes share, and the bottom row's part of it, + /// recorded at each draw so a divider drag maps to cells. + pub content_area: Option, + pub row_area: Option, + pub info_pane_area: Option, + /// The border rows a mouse can grab to resize: the one Results sits on + /// and the seam at the right of each row pane. + pub hsplit_divider: Option, + pub vsplit_dividers: Vec<(FocusedPane, Rect)>, /// Where the open popup was last drawn, for its page size. Only one is /// ever open, so one record serves them all. pub popup_area: Option, @@ -320,7 +392,7 @@ impl AppState { layout: PaneLayout::default(), show_help: false, help_view: LogView::default(), - should_exit: false, + exit: None, command_bar_active: false, command_bar_input: String::new(), command_bar_cursor_position: 0, @@ -332,6 +404,9 @@ impl AppState { chat_peer: None, chat_input: String::new(), chat_composing: false, + chat_wrapped: WrappedLog::default(), + chat_filter: String::new(), + chat_filtering: false, chat_view: LogView::default(), browse: BrowseTabs::new(), @@ -349,6 +424,11 @@ impl AppState { searches_pane_area: None, results_pane_area: None, downloads_pane_area: None, + content_area: None, + row_area: None, + info_pane_area: None, + hsplit_divider: None, + vsplit_dividers: Vec::new(), popup_area: None, } } @@ -501,6 +581,27 @@ mod tests { layout.visible().collect() } + #[test] + fn a_wrapped_log_only_wraps_what_arrived_and_starts_over_on_a_change() { + let wrap = |line: &String| vec![Line::raw(line.clone())]; + let mut log = WrappedLog::default(); + let mut lines = vec!["a".to_string(), "b".to_string()]; + assert_eq!(log.rows(40, "", &lines, wrap).len(), 2); + lines.push("c".to_string()); + assert_eq!(log.rows(40, "", &lines, wrap).len(), 3); + // A narrower pane or another filter rebuilds; fewer lines do too. + let only_c = |line: &String| { + (line == "c") + .then(|| Line::raw(line.clone())) + .into_iter() + .collect() + }; + assert_eq!(log.rows(40, "c", &lines, only_c).len(), 1); + assert_eq!(log.rows(30, "c", &lines, wrap).len(), 3); + lines.truncate(1); + assert_eq!(log.rows(30, "c", &lines, wrap).len(), 1); + } + #[test] fn every_pane_starts_visible_and_unzoomed() { let state = AppState::new(); diff --git a/soulseek-rs/src/models/browse.rs b/soulseek-rs/src/models/browse.rs index 305558f..f428f79 100644 --- a/soulseek-rs/src/models/browse.rs +++ b/soulseek-rs/src/models/browse.rs @@ -154,23 +154,20 @@ pub struct BrowseRow { pub size: Option, } -/// Flatten the tree into visible rows, descending only into expanded folders. -#[must_use] -pub fn flatten_browse( - nodes: &[BrowseNode], - expanded: &HashSet, -) -> Vec { - let mut rows = Vec::new(); - flatten_into(nodes, 0, expanded, &mut rows); - rows -} - +/// Emit `nodes` as rows. Without a `needle`, every folder and file, a +/// folder's contents only while it is open. With one, only the paths that +/// contain it and the folders on the way there, open ones still deciding +/// whether their contents show. `emit` false only counts. Says whether +/// anything under `nodes` was kept. fn flatten_into( nodes: &[BrowseNode], depth: usize, expanded: &HashSet, + needle: Option<&str>, rows: &mut Vec, -) { + emit: bool, +) -> bool { + let mut kept = false; for node in nodes { match node { BrowseNode::Folder { @@ -178,36 +175,107 @@ fn flatten_into( path, children, } => { - let is_expanded = expanded.contains(path); - rows.push(BrowseRow { - depth, - is_folder: true, - expanded: is_expanded, - name: name.clone(), - path: path.clone(), - size: None, - }); - if is_expanded { - flatten_into(children, depth + 1, expanded, rows); + let open = expanded.contains(path); + let mark = rows.len(); + if emit { + rows.push(BrowseRow { + depth, + is_folder: true, + expanded: open, + name: name.clone(), + path: path.clone(), + size: None, + }); + } + let below = match needle { + None => { + if open { + flatten_into( + children, + depth + 1, + expanded, + None, + rows, + emit, + ); + } + true + } + Some(needle) => { + flatten_into( + children, + depth + 1, + expanded, + Some(needle), + rows, + emit && open, + ) || path_has(path, needle) + } + }; + if below { + kept = true; + } else { + rows.truncate(mark); } } BrowseNode::File { name, path, size } => { - rows.push(BrowseRow { - depth, - is_folder: false, - expanded: false, - name: name.clone(), - path: path.clone(), - size: Some(*size), - }); + if needle.is_none_or(|needle| path_has(path, needle)) { + kept = true; + if emit { + rows.push(BrowseRow { + depth, + is_folder: false, + expanded: false, + name: name.clone(), + path: path.clone(), + size: Some(*size), + }); + } + } } } } + kept +} + +/// Whether `path` contains `needle`, which is already ASCII-lowercase. Case +/// is folded for ASCII only, without allocating: a filter keystroke runs +/// this over every path in a share. A letter beyond ASCII matches as typed. +fn path_has(path: &str, needle: &str) -> bool { + needle.is_empty() + || path + .as_bytes() + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle.as_bytes())) +} + +/// Open every folder with a path containing `needle` somewhere under it, +/// so a filter shows all it matched. Says whether anything under `nodes` +/// matched. +fn open_matching( + nodes: &[BrowseNode], + needle: &str, + expanded: &mut HashSet, +) -> bool { + let mut any = false; + for node in nodes { + match node { + BrowseNode::Folder { path, children, .. } => { + if open_matching(children, needle, expanded) + || path_has(path, needle) + { + expanded.insert(path.clone()); + any = true; + } + } + BrowseNode::File { path, .. } => any |= path_has(path, needle), + } + } + any } /// Find a node by its full path. -#[must_use] -pub fn find_node<'a>( +fn find_node<'a>( nodes: &'a [BrowseNode], path: &str, ) -> Option<&'a BrowseNode> { @@ -235,8 +303,7 @@ pub fn find_node<'a>( /// All file leaves under `node` as `(full_path, size)`, descending the tree /// structurally (never string-prefix matching). -#[must_use] -pub fn files_under(node: &BrowseNode) -> Vec<(String, u64)> { +fn files_under(node: &BrowseNode) -> Vec<(String, u64)> { let mut out = Vec::new(); collect_files(node, &mut out); out @@ -265,9 +332,22 @@ pub enum BrowseStatus { /// UI state for browsing one user. pub struct BrowseState { pub username: String, + /// This session's own share index rather than a peer's: filled from the + /// local index, re-indexed rather than re-requested, and not downloadable. + pub own: bool, pub status: BrowseStatus, pub tree: Vec, - pub expanded: HashSet, + expanded: HashSet, + /// The tree flattened for the current expansion, kept between frames: + /// a heavy sharer's tree runs to tens of thousands of rows. + rows: Vec, + /// Narrows the rows to paths containing it, case-insensitively. Setting + /// it opens every folder with a match; clearing it puts the folders back + /// the way they were, kept in `kept_expanded` meanwhile. + filter: String, + kept_expanded: Option>, + /// Whether typing goes to the filter. + pub filtering: bool, pub selected_row: usize, pub file_count: usize, pub folder_count: usize, @@ -279,9 +359,14 @@ impl BrowseState { pub fn loading(username: String) -> Self { Self { username, + own: false, status: BrowseStatus::Loading, tree: Vec::new(), expanded: HashSet::new(), + rows: Vec::new(), + filter: String::new(), + kept_expanded: None, + filtering: false, selected_row: 0, file_count: 0, folder_count: 0, @@ -289,6 +374,15 @@ impl BrowseState { } } + /// An empty view of this session's own shares, waiting for the index. + #[must_use] + pub fn loading_own(username: String) -> Self { + Self { + own: true, + ..Self::loading(username) + } + } + /// Populate from a received listing: build the tree, auto-expand the top /// level so structure is visible, and set Loaded/Empty. pub fn load(&mut self, directories: &[SharedDirectory]) { @@ -310,12 +404,105 @@ impl BrowseState { self.file_count = built.file_count; self.folder_count = built.folder_count; self.selected_row = 0; + self.refresh(); } /// The flattened visible rows for the current expansion state. #[must_use] - pub fn rows(&self) -> Vec { - flatten_browse(&self.tree, &self.expanded) + pub fn rows(&self) -> &[BrowseRow] { + &self.rows + } + + /// Open or close the folder at `path`. + pub fn set_expanded(&mut self, path: &str, expanded: bool) { + if expanded { + self.expanded.insert(path.to_string()); + } else { + self.expanded.remove(path); + } + self.refresh(); + } + + pub fn expand_all(&mut self) { + folder_paths(&self.tree, &mut self.expanded); + self.refresh(); + } + + pub fn collapse_all(&mut self) { + self.expanded.clear(); + self.refresh(); + } + + #[must_use] + pub fn filter(&self) -> &str { + &self.filter + } + + /// Narrow the rows to `filter`, starting over from the top, with every + /// folder holding a match open. An emptied filter puts the folders back + /// as they were before it. + pub fn set_filter(&mut self, filter: String) { + if filter == self.filter { + return; + } + if filter.is_empty() { + if let Some(kept) = self.kept_expanded.take() { + self.expanded = kept; + } + } else { + if self.kept_expanded.is_none() { + self.kept_expanded = Some(self.expanded.clone()); + } + self.expanded.clear(); + open_matching( + &self.tree, + &filter.to_ascii_lowercase(), + &mut self.expanded, + ); + } + self.filter = filter; + self.selected_row = 0; + self.refresh(); + } + + /// The files under the folder at `path`, only those the filter lets + /// through: what `d` on a filtered folder downloads is what it shows. + #[must_use] + pub fn folder_files(&self, path: &str) -> Vec<(String, u64)> { + let needle = self.filter.to_ascii_lowercase(); + find_node(&self.tree, path) + .map(files_under) + .unwrap_or_default() + .into_iter() + .filter(|(path, _)| path_has(path, &needle)) + .collect() + } + + /// Re-flatten after the tree, the expansion or the filter changed under + /// `rows`. + fn refresh(&mut self) { + let needle = + (!self.filter.is_empty()).then(|| self.filter.to_ascii_lowercase()); + self.rows.clear(); + flatten_into( + &self.tree, + 0, + &self.expanded, + needle.as_deref(), + &mut self.rows, + true, + ); + self.selected_row = + self.selected_row.min(self.rows.len().saturating_sub(1)); + } +} + +fn folder_paths(nodes: &[BrowseNode], out: &mut HashSet) { + for node in nodes { + if let BrowseNode::Folder { path, children, .. } = node { + out.insert(path.clone()); + folder_paths(children, out); + } } } @@ -354,7 +541,10 @@ impl BrowseTabs { /// `true` if the caller should (re)issue a browse request โ€” i.e. the tab is /// new or was retried after a timeout. pub fn open(&mut self, username: &str) -> bool { - if let Some(idx) = self.tabs.iter().position(|t| t.username == username) + if let Some(idx) = self + .tabs + .iter() + .position(|t| !t.own && t.username == username) { self.active = idx; // Re-request only if the previous attempt gave up. @@ -371,6 +561,40 @@ impl BrowseTabs { } } + /// Where this session's own share tab sits, if it is open. + #[must_use] + pub fn own_index(&self) -> Option { + self.tabs.iter().position(|tab| tab.own) + } + + /// Focus the tab showing this session's own shares, opening it if it is + /// not there yet. Returns `true` when it still needs its listing. + pub fn open_own(&mut self, username: &str) -> bool { + if let Some(idx) = self.own_index() { + self.active = idx; + return false; + } + self.tabs + .push(BrowseState::loading_own(username.to_string())); + self.active = self.tabs.len() - 1; + true + } + + /// Whether the focused tab is this session's own share index. + #[must_use] + pub fn active_is_own(&self) -> bool { + self.active_tab().is_some_and(|tab| tab.own) + } + + /// Who the active tab's files would be downloaded from, if anyone. The + /// own tab names nobody: its files are already on this disk. + #[must_use] + pub fn download_target(&self) -> Option<&str> { + self.active_tab() + .filter(|tab| !tab.own) + .map(|tab| tab.username.as_str()) + } + /// Retry the active tab if it timed out: reset it to loading and return the /// username so the caller can re-issue the request. Returns `None` if the /// active tab isn't in a retryable (timed-out) state. @@ -413,6 +637,16 @@ impl BrowseTabs { mod tests { use super::*; + /// The tree flattened for `expanded`, unfiltered. + fn flatten_browse( + nodes: &[BrowseNode], + expanded: &HashSet, + ) -> Vec { + let mut rows = Vec::new(); + flatten_into(nodes, 0, expanded, None, &mut rows, true); + rows + } + fn dir(name: &str, files: &[(&str, u64)]) -> SharedDirectory { SharedDirectory { name: name.to_string(), @@ -556,6 +790,29 @@ mod tests { assert!(tabs.is_empty()); } + #[test] + fn the_own_tab_is_opened_once_and_is_not_a_peer_of_the_same_name() { + let mut tabs = BrowseTabs::new(); + // The first open needs a listing; focusing it again does not. + assert!(tabs.open_own("tester")); + assert!(!tabs.open_own("tester")); + assert_eq!(tabs.tabs.len(), 1); + assert_eq!(tabs.own_index(), Some(0)); + assert!(tabs.active_tab().expect("tab").own); + + // Browsing the account's own name over the network is a separate + // tab: the own tab holds the local index, not a peer's answer. + assert!(tabs.open("tester")); + assert_eq!(tabs.tabs.len(), 2); + assert!(!tabs.active_tab().expect("tab").own); + assert_eq!(tabs.own_index(), Some(0)); + + // The peer of that name is downloadable; we are not. + assert_eq!(tabs.download_target(), Some("tester")); + tabs.active = 0; + assert_eq!(tabs.download_target(), None); + } + #[test] fn browse_tabs_reopen_after_timeout_requests_again() { let mut tabs = BrowseTabs::new(); @@ -579,6 +836,48 @@ mod tests { assert_eq!(tabs.retry_active(), None); } + #[test] + fn expanding_and_collapsing_refresh_the_rows_and_keep_the_selection() { + let mut state = BrowseState::loading("bob".to_string()); + state.load(&[dir("music\\album", &[("a.mp3", 1), ("b.mp3", 2)])]); + assert_eq!(state.rows().len(), 2, "music open, album closed"); + state.set_expanded("music\\album", true); + assert_eq!(state.rows().len(), 4); + state.selected_row = 3; + state.set_expanded("music\\album", false); + assert_eq!(state.rows().len(), 2); + assert_eq!(state.selected_row, 1, "pulled back onto the last row"); + } + + #[test] + fn a_filter_opens_every_folder_with_a_match_and_folds_still_work() { + let mut state = BrowseState::loading("bob".to_string()); + state.load(&[ + dir("music\\alpha", &[("a1.mp3", 1), ("a2.flac", 2)]), + dir("music\\beta", &[("b1.mp3", 3)]), + ]); + let names = |state: &BrowseState| -> Vec { + state.rows().iter().map(|row| row.name.clone()).collect() + }; + state.set_filter("beta".to_string()); + assert_eq!(names(&state), ["music", "beta", "b1.mp3"]); + state.set_filter("FLAC".to_string()); + assert_eq!(names(&state), ["music", "alpha", "a2.flac"], "any case"); + assert_eq!( + state.folder_files("music"), + [("music\\alpha\\a2.flac".to_string(), 2)], + "a folder downloads what it shows" + ); + state.set_expanded("music\\alpha", false); + assert_eq!(names(&state), ["music", "alpha"], "folded under a filter"); + state.set_filter(String::new()); + assert_eq!(state.rows().len(), 3, "the folders as they were before"); + state.expand_all(); + assert_eq!(state.rows().len(), 6); + state.collapse_all(); + assert_eq!(state.rows().len(), 1); + } + #[test] fn browse_state_load_auto_expands_top_level() { let mut state = BrowseState::loading("bob".to_string()); diff --git a/soulseek-rs/src/models/mod.rs b/soulseek-rs/src/models/mod.rs index bbe1a7b..69f5710 100644 --- a/soulseek-rs/src/models/mod.rs +++ b/soulseek-rs/src/models/mod.rs @@ -6,11 +6,15 @@ mod settings; pub use app_state::{ AppState, ChatMessage, CommandBarMode, DownloadEntry, FocusedPane, LogView, - MessageDirection, PaneLayout, SearchEntry, SearchStatus, -}; -pub use browse::{ - BrowseState, BrowseStatus, BrowseTabs, files_under, find_node, + MessageDirection, PaneLayout, SearchEntry, SearchStatus, TuiExit, + WrappedLog, }; +pub use browse::{BrowseState, BrowseStatus, BrowseTabs}; pub use file_display_data::FileDisplayData; -pub use rooms::{RoomLine, RoomsState, RoomsView}; -pub use settings::{SettingsAction, SettingsMode, SettingsState}; +pub use rooms::{ + ChatFilter, OpenRoom, RoomLine, RoomsState, RoomsView, contains_filter, + matching_users, +}; +pub use settings::{ + AccountInfo, SettingsAction, SettingsMode, SettingsRow, SettingsState, +}; diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs index b4f3db1..8897f39 100644 --- a/soulseek-rs/src/models/rooms.rs +++ b/soulseek-rs/src/models/rooms.rs @@ -1,4 +1,4 @@ -use crate::models::LogView; +use crate::models::{LogView, WrappedLog}; use chrono::{DateTime, Local}; use soulseek_rs::types::{RoomEvent, RoomInfo}; @@ -41,6 +41,8 @@ pub struct OpenRoom { pub input: String, /// Where the log is being read. pub view: LogView, + /// The log wrapped for drawing. + pub wrapped: WrappedLog, } impl OpenRoom { @@ -77,8 +79,22 @@ pub struct RoomsState { pub view: RoomsView, /// Whether the compose input for the active room is capturing keys. pub composing: bool, - /// Selected index into the active room's member list (for browse/PM). + /// Selected index into the active room's member list (for browse/PM), + /// as narrowed by `user_filter`. pub user_selected: usize, + /// Narrows the active room's log to lines mentioning it. + pub log_filter: String, + /// Narrows the active room's member list to names containing it. + pub user_filter: String, + /// Which of the two, if either, typing goes to. + pub filtering: Option, +} + +/// A room's two filterable lists. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatFilter { + Log, + Users, } impl Default for RoomsState { @@ -100,6 +116,31 @@ impl RoomsState { view: RoomsView::List, composing: false, user_selected: 0, + log_filter: String::new(), + user_filter: String::new(), + filtering: None, + } + } + + /// The active room's members the filter lets through. + #[must_use] + pub fn visible_users(&self) -> Vec<&str> { + self.active_room().map_or_else(Vec::new, |room| { + matching_users(&room.users, &self.user_filter) + }) + } + + /// Narrow the member list, starting over from its top. + pub fn set_user_filter(&mut self, filter: String) { + self.user_filter = filter; + self.user_selected = 0; + } + + /// Narrow the log, back at its newest matching line. + pub fn set_log_filter(&mut self, filter: String) { + self.log_filter = filter; + if let Some(view) = self.active_view_mut() { + view.to_newest(); } } @@ -159,8 +200,9 @@ impl RoomsState { /// The username highlighted in the active room's member list, if any. #[must_use] pub fn selected_user(&self) -> Option { - self.active_room() - .and_then(|r| r.users.get(self.user_selected).cloned()) + self.visible_users() + .get(self.user_selected) + .map(|user| (*user).to_string()) } /// Where the active room's log is being read, to move it. @@ -173,11 +215,9 @@ impl RoomsState { } pub fn select_user_down(&mut self) { - if let Some(room) = self.active_room() - && !room.users.is_empty() - { - self.user_selected = - (self.user_selected + 1).min(room.users.len() - 1); + let len = self.visible_users().len(); + if len > 0 { + self.user_selected = (self.user_selected + 1).min(len - 1); } } @@ -296,6 +336,81 @@ impl RoomsState { .push(RoomLine::system(format!("โ† {username}"))); } } + // Tickers and private-room standing are narrated into the room's + // own transcript: they belong to a room the user has open, and a + // system line is where the rest of that room's news already goes. + RoomEvent::Tickers { room, tickers } => { + if let Some(idx) = self.open_index(&room) { + for ticker in tickers { + self.open[idx].lines.push(RoomLine::system(format!( + "โ‰ก {}: {}", + ticker.username, ticker.ticker + ))); + } + } + } + RoomEvent::TickerAdded { + room, + username, + ticker, + } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system(format!( + "โ‰ก {username}: {ticker}" + ))); + } + } + RoomEvent::TickerRemoved { room, username } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system(format!( + "โ‰ก {username} cleared their ticker" + ))); + } + } + RoomEvent::PrivateRosterChanged { + room, + username, + members, + added, + } => { + if let Some(idx) = self.open_index(&room) { + let roster = if members { "member" } else { "operator" }; + let verb = if added { "is now" } else { "is no longer" }; + self.open[idx].lines.push(RoomLine::system(format!( + "{username} {verb} a {roster}" + ))); + } + } + RoomEvent::OwnStandingChanged { + room, + members, + granted, + } => { + if let Some(idx) = self.open_index(&room) { + let what = if members { + "membership" + } else { + "operator status" + }; + let verb = if granted { "granted" } else { "revoked" }; + self.open[idx].lines.push(RoomLine::system(format!( + "โ€” your {what} was {verb} โ€”" + ))); + } + } + RoomEvent::CantCreate { room } => { + if let Some(idx) = self.open_index(&room) { + self.open[idx].lines.push(RoomLine::system( + "โ€” this room cannot be created โ€”".to_string(), + )); + } + } + // The global feed carries rooms the user has not opened, and the + // private rosters are read from the client rather than rendered + // into a transcript. + RoomEvent::GlobalMessage { .. } + | RoomEvent::PrivateMembers { .. } + | RoomEvent::PrivateOperators { .. } => {} } // The active room's member list may have grown/shrunk (join/leave or a // wholesale replace on Joined); keep the selection highlight in range so @@ -305,7 +420,7 @@ impl RoomsState { /// Keep `user_selected` within the active room's member list. fn clamp_user_selected(&mut self) { - let len = self.active_room().map_or(0, |r| r.users.len()); + let len = self.visible_users().len(); self.user_selected = self.user_selected.min(len.saturating_sub(1)); } @@ -320,6 +435,25 @@ impl RoomsState { } } +/// Whether `text` contains `filter`, ignoring case; an empty filter matches. +#[must_use] +pub fn contains_filter(text: &str, filter: &str) -> bool { + filter.is_empty() || text.to_lowercase().contains(&filter.to_lowercase()) +} + +/// The names in `users` that contain `filter`, in their own order. +#[must_use] +pub fn matching_users<'a>(users: &'a [String], filter: &str) -> Vec<&'a str> { + let needle = filter.to_lowercase(); + users + .iter() + .filter(|user| { + needle.is_empty() || user.to_lowercase().contains(&needle) + }) + .map(String::as_str) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/soulseek-rs/src/models/settings.rs b/soulseek-rs/src/models/settings.rs index 191378e..d892d99 100644 --- a/soulseek-rs/src/models/settings.rs +++ b/soulseek-rs/src/models/settings.rs @@ -1,9 +1,9 @@ -//! State machine for the settings popup (download folder + share paths). +//! State machine for the settings popup (account, download folder, shares). //! Pure โ€” no client or terminal โ€” so every transition is unit-testable. use ratatui::crossterm::event::{KeyCode, KeyEvent}; -/// Which row is highlighted: 0 = download dir, 1.. = share paths. +/// What the popup is doing with the keyboard. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SettingsMode { /// Moving between rows. @@ -12,10 +12,39 @@ pub enum SettingsMode { EditingDownloadDir, /// Typing a new share path to add. AddingShare, + /// Typing a new password; the keystrokes are masked. + NewPassword, + /// Typing it again, with the first entry held here. A password reaches the + /// server unconfirmed, so a typo entered once would change the account to + /// something nobody knows. + RepeatPassword(String), + /// Waiting for "log out?" to be answered. + ConfirmingLogout, +} + +impl SettingsMode { + /// Whether what is being typed must not be shown. + const fn is_secret(&self) -> bool { + matches!(self, Self::NewPassword | Self::RepeatPassword(_)) + } +} + +/// One line the selection can rest on. +/// +/// The rows are derived from the state rather than counted, because the logout +/// row is absent when the session belongs to a daemon and index arithmetic +/// would silently shift with it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsRow { + ChangePassword, + Logout, + DownloadDir, + /// Share path at this index. + Share(usize), } /// What the TUI should do after a key was handled. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum SettingsAction { None, /// Settings changed: persist config and apply the new share set live. @@ -24,12 +53,33 @@ pub enum SettingsAction { Reindex, /// Close the popup. Close, + /// Make this the account's password. + ChangePassword(String), + /// End the session and forget the stored password. + Logout, +} + +/// Who the session is logged in as and what it offers the network. +/// +/// Captured when the popup opens rather than read while drawing: attached to a +/// daemon every one of these is a round trip, and the popup redraws each tick. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountInfo { + pub username: String, + /// Already formatted, e.g. "1234 files in 56 folders". + pub shares: String, + /// The port peers can reach us on, when the listener is up. + pub listen_port: Option, + /// The daemon this session is borrowed from. `Some` means the account is + /// the daemon's, so this window cannot log it out. + pub daemon: Option, } pub struct SettingsState { + pub account: AccountInfo, pub download_dir: String, pub share_dirs: Vec, - /// 0 = download dir row; 1 + i = share path i. + /// Index into [`Self::rows`]. pub selected: usize, pub mode: SettingsMode, /// Edit buffer while typing. @@ -40,8 +90,13 @@ pub struct SettingsState { impl SettingsState { #[must_use] - pub const fn new(download_dir: String, share_dirs: Vec) -> Self { + pub const fn new( + account: AccountInfo, + download_dir: String, + share_dirs: Vec, + ) -> Self { Self { + account, download_dir, share_dirs, selected: 0, @@ -51,16 +106,42 @@ impl SettingsState { } } - const fn rows(&self) -> usize { - 1 + self.share_dirs.len() + /// The daemon owns its own login; a window borrowing it can only detach. + #[must_use] + pub const fn can_log_out(&self) -> bool { + self.account.daemon.is_none() + } + + /// Every row, in the order they are drawn. + #[must_use] + pub fn rows(&self) -> Vec { + let mut rows = vec![SettingsRow::ChangePassword]; + if self.can_log_out() { + rows.push(SettingsRow::Logout); + } + rows.push(SettingsRow::DownloadDir); + rows.extend((0..self.share_dirs.len()).map(SettingsRow::Share)); + rows + } + + fn last_row(&self) -> usize { + self.rows().len() - 1 + } + + /// The row the selection rests on. + #[must_use] + pub fn selected_row(&self) -> SettingsRow { + self.rows()[self.selected.min(self.last_row())] } pub fn handle_key(&mut self, key: KeyEvent) -> SettingsAction { match self.mode { SettingsMode::Navigate => self.handle_navigate(key), - SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => { - self.handle_typing(key) - } + SettingsMode::ConfirmingLogout => self.handle_logout_confirm(key), + SettingsMode::EditingDownloadDir + | SettingsMode::AddingShare + | SettingsMode::NewPassword + | SettingsMode::RepeatPassword(_) => self.handle_typing(key), } } @@ -73,22 +154,28 @@ impl SettingsState { SettingsAction::None } KeyCode::Down | KeyCode::Char('j') => { - self.selected = (self.selected + 1).min(self.rows() - 1); + self.selected = (self.selected + 1).min(self.last_row()); SettingsAction::None } - KeyCode::Enter | KeyCode::Char('e') if self.selected == 0 => { - self.mode = SettingsMode::EditingDownloadDir; - self.input = self.download_dir.clone(); - SettingsAction::None + KeyCode::Enter => self.activate(), + // 'e' has always meant "edit this folder"; it must not reach a row + // that logs the account out. + KeyCode::Char('e') + if self.selected_row() == SettingsRow::DownloadDir => + { + self.activate() } KeyCode::Char('a') => { self.mode = SettingsMode::AddingShare; self.input.clear(); SettingsAction::None } - KeyCode::Char('d') if self.selected > 0 => { - self.share_dirs.remove(self.selected - 1); - self.selected = self.selected.min(self.rows() - 1); + KeyCode::Char('d') => { + let SettingsRow::Share(index) = self.selected_row() else { + return SettingsAction::None; + }; + self.share_dirs.remove(index); + self.selected = self.selected.min(self.last_row()); SettingsAction::Apply } KeyCode::Char('r') => SettingsAction::Reindex, @@ -96,6 +183,31 @@ impl SettingsState { } } + fn activate(&mut self) -> SettingsAction { + match self.selected_row() { + SettingsRow::ChangePassword => { + self.mode = SettingsMode::NewPassword; + // Defensive: a password buffer is never left holding anything. + self.input.clear(); + } + SettingsRow::Logout => self.mode = SettingsMode::ConfirmingLogout, + SettingsRow::DownloadDir => { + self.mode = SettingsMode::EditingDownloadDir; + self.input.clone_from(&self.download_dir); + } + SettingsRow::Share(_) => {} + } + SettingsAction::None + } + + fn handle_logout_confirm(&mut self, key: KeyEvent) -> SettingsAction { + self.mode = SettingsMode::Navigate; + if matches!(key.code, KeyCode::Char('y' | 'Y') | KeyCode::Enter) { + return SettingsAction::Logout; + } + SettingsAction::None + } + fn handle_typing(&mut self, key: KeyEvent) -> SettingsAction { match key.code { KeyCode::Esc => { @@ -103,25 +215,7 @@ impl SettingsState { self.input.clear(); SettingsAction::None } - KeyCode::Enter => { - let value = self.input.trim().to_string(); - let adding = self.mode == SettingsMode::AddingShare; - self.mode = SettingsMode::Navigate; - self.input.clear(); - if value.is_empty() { - return SettingsAction::None; - } - if adding { - if self.share_dirs.contains(&value) { - return SettingsAction::None; - } - self.share_dirs.push(value); - self.selected = self.rows() - 1; - } else { - self.download_dir = value; - } - SettingsAction::Apply - } + KeyCode::Enter => self.submit(), KeyCode::Backspace => { self.input.pop(); SettingsAction::None @@ -133,6 +227,48 @@ impl SettingsState { _ => SettingsAction::None, } } + + fn submit(&mut self) -> SettingsAction { + if self.mode.is_secret() { + return self.submit_password(); + } + let value = self.input.trim().to_string(); + let adding = self.mode == SettingsMode::AddingShare; + self.mode = SettingsMode::Navigate; + self.input.clear(); + if value.is_empty() { + return SettingsAction::None; + } + if adding { + if self.share_dirs.contains(&value) { + return SettingsAction::None; + } + self.share_dirs.push(value); + self.selected = self.last_row(); + } else { + self.download_dir = value; + } + SettingsAction::Apply + } + + /// The first entry is only remembered; the second is what commits. + fn submit_password(&mut self) -> SettingsAction { + let typed = std::mem::take(&mut self.input); + let SettingsMode::RepeatPassword(first) = + std::mem::replace(&mut self.mode, SettingsMode::Navigate) + else { + if !typed.is_empty() { + self.mode = SettingsMode::RepeatPassword(typed); + } + return SettingsAction::None; + }; + if typed != first { + self.status = + Some("The two passwords differ โ€” nothing was changed".into()); + return SettingsAction::None; + } + SettingsAction::ChangePassword(typed) + } } #[cfg(test)] @@ -150,8 +286,30 @@ mod tests { } } + fn account() -> AccountInfo { + AccountInfo { + username: "alice".into(), + shares: "12 files in 3 folders".into(), + listen_port: Some(2234), + daemon: None, + } + } + fn state() -> SettingsState { - SettingsState::new("/dl".into(), vec!["/dl".into(), "/music".into()]) + SettingsState::new( + account(), + "/dl".into(), + vec!["/dl".into(), "/music".into()], + ) + } + + /// Put the selection on a row by name, whatever index it has. + fn select(state: &mut SettingsState, row: SettingsRow) { + state.selected = state + .rows() + .iter() + .position(|candidate| *candidate == row) + .expect("the row is present"); } #[test] @@ -172,6 +330,7 @@ mod tests { assert_eq!(action, SettingsAction::Apply); assert_eq!(s.share_dirs, vec!["/dl", "/music", "/flacs"]); assert_eq!(s.mode, SettingsMode::Navigate); + assert_eq!(s.selected_row(), SettingsRow::Share(2)); } #[test] @@ -186,16 +345,16 @@ mod tests { #[test] fn deleting_the_selected_share_applies() { let mut s = state(); - s.handle_key(key(KeyCode::Down)); // select share 0 - s.handle_key(key(KeyCode::Down)); // select share 1 (/music) + select(&mut s, SettingsRow::Share(1)); let action = s.handle_key(key(KeyCode::Char('d'))); assert_eq!(action, SettingsAction::Apply); assert_eq!(s.share_dirs, vec!["/dl"]); } #[test] - fn delete_on_the_download_dir_row_does_nothing() { + fn delete_on_a_row_that_is_not_a_share_does_nothing() { let mut s = state(); + select(&mut s, SettingsRow::DownloadDir); assert_eq!(s.handle_key(key(KeyCode::Char('d'))), SettingsAction::None); assert_eq!(s.share_dirs.len(), 2); } @@ -203,6 +362,7 @@ mod tests { #[test] fn editing_the_download_dir_applies() { let mut s = state(); + select(&mut s, SettingsRow::DownloadDir); s.handle_key(key(KeyCode::Enter)); assert_eq!(s.mode, SettingsMode::EditingDownloadDir); assert_eq!(s.input, "/dl"); @@ -217,6 +377,7 @@ mod tests { #[test] fn escape_cancels_an_edit_without_applying() { let mut s = state(); + select(&mut s, SettingsRow::DownloadDir); s.handle_key(key(KeyCode::Enter)); type_str(&mut s, "junk"); assert_eq!(s.handle_key(key(KeyCode::Esc)), SettingsAction::None); @@ -238,10 +399,112 @@ mod tests { for _ in 0..10 { s.handle_key(key(KeyCode::Down)); } - assert_eq!(s.selected, 2); + assert_eq!(s.selected_row(), SettingsRow::Share(1)); for _ in 0..10 { s.handle_key(key(KeyCode::Up)); } - assert_eq!(s.selected, 0); + assert_eq!(s.selected_row(), SettingsRow::ChangePassword); + } + + #[test] + fn a_password_typed_twice_the_same_way_is_changed() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + assert_eq!(s.mode, SettingsMode::NewPassword); + type_str(&mut s, "hunter2"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert!(matches!(s.mode, SettingsMode::RepeatPassword(_))); + // The first entry is not left lying in the buffer the popup draws. + assert!(s.input.is_empty()); + type_str(&mut s, "hunter2"); + assert_eq!( + s.handle_key(key(KeyCode::Enter)), + SettingsAction::ChangePassword("hunter2".into()) + ); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn a_mistyped_repeat_changes_nothing_and_says_so() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter2"); + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter3"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::Navigate); + assert!(s.status.is_some()); + } + + #[test] + fn an_empty_password_is_not_asked_for_twice() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn escape_during_the_repeat_forgets_the_first_entry() { + let mut s = state(); + select(&mut s, SettingsRow::ChangePassword); + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter2"); + s.handle_key(key(KeyCode::Enter)); + s.handle_key(key(KeyCode::Esc)); + // Starting over asks for both entries again rather than matching the + // abandoned one. + s.handle_key(key(KeyCode::Enter)); + type_str(&mut s, "hunter2"); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert!(matches!(s.mode, SettingsMode::RepeatPassword(_))); + } + + #[test] + fn logging_out_is_confirmed_first() { + let mut s = state(); + select(&mut s, SettingsRow::Logout); + assert_eq!(s.handle_key(key(KeyCode::Enter)), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::ConfirmingLogout); + assert_eq!( + s.handle_key(key(KeyCode::Char('y'))), + SettingsAction::Logout + ); + } + + #[test] + fn anything_but_yes_cancels_the_logout() { + let mut s = state(); + select(&mut s, SettingsRow::Logout); + s.handle_key(key(KeyCode::Enter)); + assert_eq!(s.handle_key(key(KeyCode::Char('n'))), SettingsAction::None); + assert_eq!(s.mode, SettingsMode::Navigate); + } + + #[test] + fn a_daemons_session_offers_no_logout_row() { + let mut s = SettingsState::new( + AccountInfo { + daemon: Some("127.0.0.1:2245".into()), + ..account() + }, + "/dl".into(), + vec!["/music".into()], + ); + assert_eq!( + s.rows(), + vec![ + SettingsRow::ChangePassword, + SettingsRow::DownloadDir, + SettingsRow::Share(0), + ] + ); + // The row below "change password" is the download folder, not a + // logout the window cannot perform. + s.handle_key(key(KeyCode::Down)); + assert_eq!(s.selected_row(), SettingsRow::DownloadDir); } } diff --git a/soulseek-rs/src/output.rs b/soulseek-rs/src/output.rs index abe9d61..21d6aa4 100644 --- a/soulseek-rs/src/output.rs +++ b/soulseek-rs/src/output.rs @@ -345,6 +345,20 @@ impl Record for WhoamiRecord { } } +/// What happened to this account. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct AccountRecord { + pub user: String, + /// `password-changed` or `logged-out`. + pub action: String, +} + +impl Record for AccountRecord { + fn text(&self) -> String { + format!("{}\t{}", sanitize(&self.action), sanitize(&self.user)) + } +} + /// What the server knows about another user. /// Each field is `None` when the server has not answered that half yet, so a /// consumer never reads "offline" or "shares nothing" from a missing reply. diff --git a/soulseek-rs/src/persist/config.rs b/soulseek-rs/src/persist/config.rs index 3781647..6dd6f8c 100644 --- a/soulseek-rs/src/persist/config.rs +++ b/soulseek-rs/src/persist/config.rs @@ -12,6 +12,11 @@ pub struct FileConfig { pub server: Option, pub listener_port: Option, pub disable_listener: Option, + /// Serve children in the distributed search network: other peers hang + /// from this client and every search it receives is passed down to them. + /// Off by default โ€” it costs a socket and the network's whole search + /// stream per child, and it needs the listener. + pub accept_children: Option, pub download_dir: Option, /// Single shared folder (also what `--shared-dir` sets). Prefer /// `shared_dirs` for multiple; both may be combined. @@ -46,6 +51,7 @@ impl FileConfig { "server", "listener_port", "disable_listener", + "accept_children", "download_dir", "shared_dirs", "max_concurrent_downloads", @@ -64,6 +70,7 @@ impl FileConfig { "server" => self.server.clone(), "listener_port" => self.listener_port.map(|v| v.to_string()), "disable_listener" => self.disable_listener.map(|v| v.to_string()), + "accept_children" => self.accept_children.map(|v| v.to_string()), "download_dir" => self.download_dir.clone(), "shared_dirs" => { let dirs = self.shared_dirs.clone().unwrap_or_default(); @@ -127,6 +134,22 @@ impl FileConfig { }) }; } + "accept_children" => { + self.accept_children = if clear { + None + } else { + Some(match value { + "true" | "1" | "yes" => true, + "false" | "0" | "no" => false, + other => { + return Err(format!( + "accept_children wants true or false, got \ + '{other}'" + )); + } + }) + }; + } "shared_dirs" => { self.shared_dirs = (!clear).then(|| { value @@ -251,6 +274,7 @@ pub struct Resolved { pub server: String, pub listener_port: u16, pub disable_listener: bool, + pub accept_children: bool, pub download_dir: String, pub shared_dirs: Vec, pub max_concurrent_downloads: usize, @@ -283,6 +307,11 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { .clone() .or_else(|| file.download_dir.clone()) .unwrap_or_else(super::paths::default_download_dir); + let disable_listener = if cli.listener { + false + } else { + cli.no_listener || file.disable_listener.unwrap_or(false) + }; Resolved { username: cli.username.clone().or_else(|| file.username.clone()), server: cli @@ -294,11 +323,11 @@ pub fn resolve(cli: &crate::cli::Cli, file: &FileConfig) -> Resolved { .listener_port .or(file.listener_port) .unwrap_or(DEFAULT_LISTENER_PORT), - disable_listener: if cli.listener { - false - } else { - cli.no_listener || file.disable_listener.unwrap_or(false) - }, + disable_listener, + // A client with no listener has nothing for a child to dial, so the + // setting only takes effect alongside one. + accept_children: file.accept_children.unwrap_or(false) + && !disable_listener, download_dir: download_dir.clone(), shared_dirs: resolve_shared_dirs(cli, file, &download_dir), max_concurrent_downloads: cli @@ -496,6 +525,37 @@ mod tests { assert!(!resolve(&cli, &FileConfig::default()).disable_listener); } + #[test] + fn accepting_children_needs_a_listener() { + // Serving children means peers dial us; with the listener off there + // is nothing to dial, so the setting must not survive resolution. + let file = FileConfig { + accept_children: Some(true), + ..FileConfig::default() + }; + assert!(resolve(&bare_cli(), &file).accept_children); + + let off = FileConfig { + disable_listener: Some(true), + ..file.clone() + }; + assert!(!resolve(&bare_cli(), &off).accept_children); + + let mut cli = bare_cli(); + cli.no_listener = true; + assert!(!resolve(&cli, &file).accept_children); + } + + #[test] + fn accept_children_round_trips_through_get_and_set() { + let mut file = FileConfig::default(); + file.set("accept_children", "true").expect("set"); + assert_eq!(file.get("accept_children").as_deref(), Some("true")); + assert!(file.set("accept_children", "maybe").is_err()); + file.set("accept_children", "").expect("clear"); + assert_eq!(file.get("accept_children"), None); + } + #[test] fn file_values_override_defaults() { let file = FileConfig { @@ -503,6 +563,7 @@ mod tests { server: Some("localhost:2242".into()), listener_port: Some(4321), disable_listener: Some(true), + accept_children: None, download_dir: Some("/music".into()), shared_dir: Some("/shared".into()), shared_dirs: None, diff --git a/soulseek-rs/src/persist/secret.rs b/soulseek-rs/src/persist/secret.rs index 034fc90..7d41f1a 100644 --- a/soulseek-rs/src/persist/secret.rs +++ b/soulseek-rs/src/persist/secret.rs @@ -1,11 +1,18 @@ +use crate::api::SessionApi; use color_eyre::Result; /// Where passwords live. The real implementation is the OS keychain /// ([`KeyringStore`]); tests use an in-memory fake. Passwords are never /// written to config.toml. -pub trait SecretStore { +/// +/// `Send + Sync` because the daemon holds one behind an `Arc` and answers +/// requests on a thread per connection. +pub trait SecretStore: Send + Sync { fn get(&self, username: &str) -> Result>; fn set(&self, username: &str, password: &str) -> Result<()>; + /// Forget this account's password. Succeeds when there was nothing + /// stored: the caller wants it gone, and it is. + fn delete(&self, username: &str) -> Result<()>; } /// OS keychain (macOS Keychain / Windows Credential Manager / Linux Secret @@ -30,6 +37,40 @@ impl SecretStore for KeyringStore { .and_then(|entry| entry.set_password(password)) .map_err(|e| color_eyre::eyre::eyre!("keyring: {e}")) } + + fn delete(&self, username: &str) -> Result<()> { + match keyring::Entry::new(SERVICE, username) + .and_then(|entry| entry.delete_credential()) + { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(color_eyre::eyre::eyre!("keyring: {e}")), + } + } +} + +/// Change the account's password and keep the local copy in step, so the next +/// start still logs in. +/// +/// One rule in one place: whoever owns the login owns the stored copy. A +/// window or a command borrowing a daemon's session owns neither, and the +/// daemon writes its own host's store when it serves the request. +/// +/// `Ok` means the request reached the server, not that the server took it โ€” +/// Soulseek answers a password change with nothing at all โ€” and carries the +/// store's complaint when the change went out but the local copy did not. +pub fn change_password( + session: &dyn SessionApi, + secrets: &dyn SecretStore, + password: &str, +) -> soulseek_rs::Result> { + session.change_password(password)?; + if session.daemon_endpoint().is_some() { + return Ok(None); + } + Ok(secrets + .set(&session.username(), password) + .err() + .map(|e| e.to_string())) } /// Resolve the password with precedence: CLI/env > keychain > `password_cmd`. @@ -72,33 +113,48 @@ fn run_password_cmd(cmd: &str) -> Option { (!password.is_empty()).then(|| password.to_string()) } +/// An in-memory store for tests, so no test touches the machine's keychain. +#[cfg(test)] +#[derive(Default)] +pub struct MemoryStore { + secrets: std::sync::Mutex>, + /// Answer every read as if there were no secret service at all. + pub fail: bool, +} + +#[cfg(test)] +impl SecretStore for MemoryStore { + fn get(&self, username: &str) -> Result> { + if self.fail { + return Err(color_eyre::eyre::eyre!("no secret service")); + } + Ok(self + .secrets + .lock() + .expect("not poisoned") + .get(username) + .cloned()) + } + + fn set(&self, username: &str, password: &str) -> Result<()> { + self.secrets + .lock() + .expect("not poisoned") + .insert(username.into(), password.into()); + Ok(()) + } + + fn delete(&self, username: &str) -> Result<()> { + self.secrets.lock().expect("not poisoned").remove(username); + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; - use std::cell::RefCell; - use std::collections::HashMap; - #[derive(Default)] - struct FakeStore { - secrets: RefCell>, - fail: bool, - } - - impl SecretStore for FakeStore { - fn get(&self, username: &str) -> Result> { - if self.fail { - return Err(color_eyre::eyre::eyre!("no secret service")); - } - Ok(self.secrets.borrow().get(username).cloned()) - } - - fn set(&self, username: &str, password: &str) -> Result<()> { - self.secrets - .borrow_mut() - .insert(username.into(), password.into()); - Ok(()) - } - } + type FakeStore = MemoryStore; #[test] fn cli_password_wins_over_everything() { @@ -156,6 +212,16 @@ mod tests { assert_eq!(resolve_password(None, Some("alice"), None, &store), None); } + #[test] + fn a_deleted_password_is_no_longer_resolved() { + let store = FakeStore::default(); + store.set("alice", "from-keyring").unwrap(); + store.delete("alice").unwrap(); + assert_eq!(resolve_password(None, Some("alice"), None, &store), None); + // Deleting again is not an error: the password is gone either way. + store.delete("alice").unwrap(); + } + #[test] fn no_username_means_no_keyring_lookup() { let store = FakeStore { diff --git a/soulseek-rs/src/remote/mod.rs b/soulseek-rs/src/remote/mod.rs index 435d4b0..6339eb1 100644 --- a/soulseek-rs/src/remote/mod.rs +++ b/soulseek-rs/src/remote/mod.rs @@ -13,9 +13,9 @@ use crate::daemon::proto::{ DaemonStatus, DirectoriesParams, DirectoryParams, DownloadDto, DownloadStartParams, DownloadStarted, Downloads, Event, IntervalSeconds, MemberStats, Members, MessageParams, Messages, Method, PROTOCOL_VERSION, - QueryParams, Request, Response, RoomRef, RpcError, SayParams, - SearchResults, Searches, Seconds, SharesStatus, SlotsParams, TransferRef, - Uploads, UserRef, UserResult, Watched, + PasswordParams, QueryParams, Request, Response, RoomRef, RpcError, + SayParams, SearchResults, Searches, Seconds, SharedListing, SharesStatus, + SlotsParams, TransferRef, Uploads, UserRef, UserResult, Watched, }; use mirror::Mirror; use serde::de::DeserializeOwned; @@ -823,6 +823,18 @@ impl SessionApi for RemoteSession { self.shares().directories } + // ponytail: a blocking round-trip that carries the whole index โ€” a very + // large share is tens of MB of JSON before the next redraw. It is only + // ever a keypress, never a frame; if that stops being tolerable, push it + // as an event the way `browse.user` answers with `event.browse`. + fn shared_listing(&self) -> Vec { + self.request::<_, SharedListing>(Method::SharesFiles, ()) + .map(|listing| { + listing.directories.into_iter().map(Into::into).collect() + }) + .unwrap_or_default() + } + fn set_shared_directories(&self, directories: Vec) -> Result<()> { self.tell(Method::SharesSet, DirectoriesParams { directories }) } @@ -830,6 +842,15 @@ impl SessionApi for RemoteSession { fn set_download_directory(&self, directory: String) -> Result<()> { self.tell(Method::DownloadSetDir, DirectoryParams { directory }) } + + fn change_password(&self, password: &str) -> Result<()> { + self.tell( + Method::AccountPassword, + PasswordParams { + password: password.to_string(), + }, + ) + } } #[cfg(test)] diff --git a/soulseek-rs/src/run.rs b/soulseek-rs/src/run.rs index af56d40..b0e8b58 100644 --- a/soulseek-rs/src/run.rs +++ b/soulseek-rs/src/run.rs @@ -6,7 +6,9 @@ use crate::cli::{ parse_server_address, }; use crate::commands::Ctx; +use crate::models::TuiExit; use crate::output::{CliError, CliResult, Exit, Out}; +use crate::persist::secret::SecretStore; use crate::ui::launch_main_tui; use soulseek_rs::{ClientSettings, ClientVersion, PeerAddress}; use std::io::{BufRead, IsTerminal}; @@ -57,7 +59,7 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { if let Some(endpoint) = daemon_endpoint(&cli, &resolved) { return run_attached_tui(&resolved, &endpoint, config_path); } - return run_default_tui(&cli, &resolved, config_path, &file_config); + return run_default_tui(&cli, &resolved, config_path); }; // Anything that only reads the network stack or the config file runs @@ -74,6 +76,14 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { Commands::Skills(ref command) => { return crate::commands::skills::run(out, command); } + // Forgetting a password needs no session โ€” least of all the one it is + // about to make unusable. + Commands::Account(crate::cli::AccountCommand::Logout) => { + return crate::commands::account::logout( + out, + resolved.username.as_deref(), + ); + } Commands::Completions(ref command) => { return crate::commands::completions::run(out, command); } @@ -157,6 +167,9 @@ pub fn run(mut cli: Cli, out: &Out) -> CliResult { Commands::Shares(SharesCommand::Status) => { crate::commands::settings::shares_status(&ctx) } + Commands::Shares(SharesCommand::Files { ref filter }) => { + crate::commands::settings::shares_files(&ctx, filter.as_deref()) + } Commands::Shares(SharesCommand::Reindex) => { crate::commands::settings::shares_reindex(&ctx) } @@ -211,12 +224,7 @@ fn context( (String::new(), String::new()) } else { ( - resolved.username.clone().ok_or_else(|| { - CliError::usage( - "no username: pass --username, set SOULSEEK_USERNAME, or \ - put one in config.toml", - ) - })?, + resolved.username.clone().ok_or_else(missing_username)?, password(cli, resolved)?.ok_or_else(|| { CliError::usage( "no password: pass --password/--password-stdin, set \ @@ -259,6 +267,7 @@ fn context( enable_listen: !resolved.disable_listener, listen_port: resolved.listener_port, shared_directories: shared_directories(resolved, out), + accept_children: resolved.accept_children, version: ClientVersion::REFERENCE_CLIENT, }, download_dir, @@ -324,16 +333,7 @@ fn password( resolved: &crate::persist::config::Resolved, ) -> CliResult> { if cli.password_stdin { - let mut line = String::new(); - io::stdin() - .lock() - .read_line(&mut line) - .map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?; - let line = line.trim_end_matches(['\r', '\n']); - if line.is_empty() { - return Err(CliError::usage("no password on stdin")); - } - return Ok(Some(line.to_string())); + return password_from_stdin().map(Some); } Ok(crate::persist::secret::resolve_password( cli.password.as_deref(), @@ -343,6 +343,29 @@ fn password( )) } +/// Read a password from the first line of stdin, the way `docker login` does. +pub fn password_from_stdin() -> CliResult { + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .map_err(|e| CliError::usage(format!("cannot read stdin: {e}")))?; + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + return Err(CliError::usage("no password on stdin")); + } + Ok(line.to_string()) +} + +/// What to say when nothing supplied a username. +#[must_use] +pub fn missing_username() -> CliError { + CliError::usage( + "no username: pass --username, set SOULSEEK_USERNAME, or put one in \ + config.toml", + ) +} + /// Validate the configured shares. A bad entry is a warning, not a failure: /// the client simply shares less. fn shared_directories( @@ -402,12 +425,11 @@ fn enter_terminal() -> ratatui::DefaultTerminal { /// Run the interactive TUI (the default no-subcommand path): bring the /// terminal up first, run the login/registration screen (skipped past when /// stored credentials work), persist whatever logged in, then enter the -/// main UI. +/// main UI. Logging out from the settings popup starts the round again. fn run_default_tui( cli: &Cli, resolved: &crate::persist::config::Resolved, config_path: Option, - file_config: &crate::persist::config::FileConfig, ) -> CliResult { let out = Out::new(false, false); let (server_host, server_port) = @@ -425,13 +447,11 @@ fn run_default_tui( let shared_directories = shared_directories(resolved, &out); let secret_store = crate::persist::secret::KeyringStore; - let initial_password = password(cli, resolved)?; - - // Enable logger buffering BEFORE connection to prevent log artifacts - soulseek_rs::utils::logger::enable_buffering(); + let mut initial_password = password(cli, resolved)?; let enable_listen = !resolved.disable_listener; let listen_port = resolved.listener_port; + let accept_children = resolved.accept_children; let make_settings = move |username: String, password: String| ClientSettings { username, @@ -440,60 +460,84 @@ fn run_default_tui( enable_listen, listen_port, shared_directories: shared_directories.clone(), + accept_children, version: ClientVersion::REFERENCE_CLIENT, }; - let mut terminal = enter_terminal(); + // Logging out closes the window and comes straight back to the login + // screen, so one pass of this loop is one session: terminal, credentials, + // main UI. The port mapping and the state store are dropped at the end of + // the pass; the client goes with the last worker thread still holding it, + // so a search or browse in flight can keep the old session open a little + // past the logout. + let mut username = resolved.username.clone(); + loop { + // Enable logger buffering BEFORE connection to prevent log artifacts. + // The window turns it off again when it gives the terminal back. + soulseek_rs::utils::logger::enable_buffering(); + let mut terminal = enter_terminal(); - let outcome = crate::ui::login::run_login_flow( - &mut terminal, - &make_settings, - resolved.username.clone(), - initial_password, - ); + let outcome = crate::ui::login::run_login_flow( + &mut terminal, + &make_settings, + username.clone(), + initial_password.take(), + ); - let outcome = match outcome { - Ok(Some(outcome)) => outcome, - Ok(None) => { - // User cancelled at the login screen. - ratatui::restore(); - soulseek_rs::utils::logger::disable_buffering(); + let outcome = match outcome { + Ok(Some(outcome)) => outcome, + // The user cancelled at the login screen. + Ok(None) => return leave_terminal(Ok(())), + Err(e) => { + return leave_terminal(Err(CliError::new( + Exit::Failure, + e.to_string(), + ))); + } + }; + + persist_credentials(&outcome, config_path.as_deref(), &secret_store); + username = Some(outcome.username.clone()); + + // Best-effort: make ourselves reachable behind a home router so + // firewalled peers can connect back. Mapped only once the listener is + // up, and for the port it really bound. Kept alive for the session. + let _port_mapper = outcome + .client + .listen_port() + .map(crate::port_mapping::PortMapper::spawn); + + let store = crate::persist::paths::state_dir() + .map(crate::persist::state::StateStore::new); + + let exit = launch_main_tui( + terminal, + Arc::new(outcome.client), + resolved.download_dir.clone(), + Duration::from_secs(resolved.search_timeout), + store, + config_path.clone(), + ) + .map_err(|e| CliError::new(Exit::Failure, e.to_string()))?; + + if exit == TuiExit::Quit { return Ok(()); } - Err(e) => { - ratatui::restore(); - soulseek_rs::utils::logger::disable_buffering(); - return Err(CliError::new(Exit::Failure, e.to_string())); + + // Logged out, so the stored password goes with the session: this pass + // is already asking for one, but the next start must ask too rather + // than walk straight back in. + if let Err(e) = secret_store.delete(&outcome.username) { + out.warn(&format!("could not clear the stored password: {e}")); } - }; + } +} - persist_credentials( - &outcome, - config_path.as_deref(), - file_config, - &secret_store, - ); - - // Best-effort: make ourselves reachable behind a home router so firewalled - // peers can connect back. Mapped only once the listener is up, and for the - // port it really bound. Kept alive for the session. - let _port_mapper = outcome - .client - .listen_port() - .map(crate::port_mapping::PortMapper::spawn); - - let store = crate::persist::paths::state_dir() - .map(crate::persist::state::StateStore::new); - - launch_main_tui( - terminal, - Arc::new(outcome.client), - resolved.download_dir.clone(), - Duration::from_secs(resolved.search_timeout), - store, - config_path, - ) - .map_err(|e| CliError::new(Exit::Failure, e.to_string())) +/// Give the terminal back and let logging through again, on the way out. +fn leave_terminal(result: CliResult) -> CliResult { + ratatui::restore(); + soulseek_rs::utils::logger::disable_buffering(); + result } /// Run the TUI against a daemon's session. @@ -539,6 +583,8 @@ fn run_attached_tui( // No StateStore: the daemon owns what survives a restart, so a second // writer here would fight it for the same files. + // The daemon owns the login, so this window has no logout to come back + // from: however it closed, the run is over. launch_main_tui( terminal, Arc::new(session), @@ -547,6 +593,7 @@ fn run_attached_tui( None, config_path, ) + .map(|_| ()) .map_err(|e| CliError::new(Exit::Failure, e.to_string())) } @@ -556,17 +603,10 @@ fn run_attached_tui( fn persist_credentials( outcome: &crate::ui::login::LoginOutcome, config_path: Option<&Path>, - file_config: &crate::persist::config::FileConfig, secret_store: &dyn crate::persist::secret::SecretStore, ) { - if let Some(path) = config_path - && file_config.username.as_deref() != Some(&outcome.username) - { - let mut updated = file_config.clone(); - updated.username = Some(outcome.username.clone()); - if let Err(e) = updated.save(path) { - eprintln!("โš ๏ธ Could not save config: {e}"); - } + if let Some(path) = config_path { + save_username(path, &outcome.username); } if outcome.entered_via_form && let Err(e) = secret_store.set(&outcome.username, &outcome.password) @@ -575,6 +615,25 @@ fn persist_credentials( } } +/// Remember who logged in, for the next start to offer. +/// +/// Read back from disk rather than from the copy this run started with: the +/// settings popup writes the same file, and a second login after a logout +/// would otherwise save a snapshot taken before those edits. +fn save_username(path: &Path, username: &str) { + let mut config = match crate::persist::config::FileConfig::load(path) { + Ok(config) => config, + Err(e) => return eprintln!("โš ๏ธ Could not read config: {e}"), + }; + if config.username.as_deref() == Some(username) { + return; + } + config.username = Some(username.to_string()); + if let Err(e) = config.save(path) { + eprintln!("โš ๏ธ Could not save config: {e}"); + } +} + /// Open files a session asks the OS for at startup. /// /// The client holds a socket per peer and will talk to a thousand of them diff --git a/soulseek-rs/src/ui/login.rs b/soulseek-rs/src/ui/login.rs index f126c79..4bf2334 100644 --- a/soulseek-rs/src/ui/login.rs +++ b/soulseek-rs/src/ui/login.rs @@ -6,7 +6,7 @@ //! ([`run_login_flow`]) drives it against a real terminal and client. use crate::ui::{ - GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, + GLYPH_CURSOR, accent_style, dimmed_style, error_style, info_style, mask, pane_block, primary_style, title_style, warning_style, }; use color_eyre::Result; @@ -281,7 +281,7 @@ fn render(frame: &mut Frame, form: &LoginForm) { frame.render_widget( field( "Password:", - "โ€ข".repeat(form.password.chars().count()), + mask(&form.password), editing && form.focused == LoginField::Password, ), rows[1], diff --git a/soulseek-rs/src/ui/main_tui/browse.rs b/soulseek-rs/src/ui/main_tui/browse.rs index 525a063..2727014 100644 --- a/soulseek-rs/src/ui/main_tui/browse.rs +++ b/soulseek-rs/src/ui/main_tui/browse.rs @@ -1,6 +1,6 @@ use super::MainTui; -use super::input::{jumped, list_jump}; -use crate::models::{BrowseStatus, files_under, find_node}; +use super::input::{FilterEdit, edit_filter, jumped, list_jump}; +use crate::models::BrowseStatus; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::{thread, time::Duration}; @@ -9,11 +9,27 @@ const BROWSE_TIMEOUT: Duration = Duration::from_secs(20); impl MainTui { pub(super) fn handle_browse_input(&mut self, key: KeyEvent) { + // Typing goes to the filter; the other keys still move around. + if self.state.browse.active_tab().is_some_and(|b| b.filtering) + && self.handle_browse_filter_input(key) + { + return; + } + // Esc peels back one level: a filter first, then the popup. + if key.code == KeyCode::Esc + && let Some(browse) = self.state.browse.active_tab_mut() + && !browse.filter().is_empty() + { + browse.set_filter(String::new()); + return; + } if matches!(key.code, KeyCode::Esc | KeyCode::Char('q')) { self.state.show_browse = false; return; } - let jump = list_jump(key, self.popup_page()); + // The tab bar takes a row once more than one user is open. + let chrome = u16::from(self.state.browse.tabs.len() > 1); + let jump = list_jump(key, self.popup_page(chrome)); // Control combinations are only ever jumps: ctrl-d pages, d downloads. if jump.is_none() && key.modifiers.contains(KeyModifiers::CONTROL) { return; @@ -38,6 +54,14 @@ impl MainTui { self.sync_browse_selection(); return; } + KeyCode::Char('B') => { + self.show_own_shares(); + return; + } + KeyCode::Char('r') if self.state.browse.active_is_own() => { + self.reindex_shares(); + return; + } KeyCode::Char('r') => { // Retry a timed-out browse. if let Some(username) = self.state.browse.retry_active() { @@ -45,11 +69,18 @@ impl MainTui { } return; } + // The shared folders themselves are the settings pane's business, + // so adding or dropping one is a keystroke away from seeing it. + KeyCode::Char('o') if self.state.browse.active_is_own() => { + self.state.show_browse = false; + self.open_settings(); + return; + } _ => {} } - // Snapshot the flattened rows + current selection, then drop the borrow. - let (rows, sel, row) = { + // The highlighted row and where it sits, then drop the borrow. + let (len, sel, row) = { let Some(browse) = self.state.browse.active_tab() else { self.state.show_browse = false; return; @@ -62,13 +93,12 @@ impl MainTui { return; } let sel = browse.selected_row.min(rows.len() - 1); - let row = rows[sel].clone(); - (rows, sel, row) + (rows.len(), sel, rows[sel].clone()) }; if let Some(jump) = jump { if let Some(browse) = self.state.browse.active_tab_mut() { - browse.selected_row = jumped(sel, rows.len(), jump); + browse.selected_row = jumped(sel, len, jump); } self.sync_browse_selection(); return; @@ -85,7 +115,11 @@ impl MainTui { } KeyCode::Char('d') => { let files = if row.is_folder { - self.browse_folder_files(&row.path) + self.state + .browse + .active_tab() + .map(|b| b.folder_files(&row.path)) + .unwrap_or_default() } else { vec![(row.path.clone(), row.size.unwrap_or(0))] }; @@ -102,63 +136,82 @@ impl MainTui { browse.selected_row = sel.saturating_sub(1); } KeyCode::Down | KeyCode::Char('j') => { - browse.selected_row = (sel + 1).min(rows.len() - 1); + browse.selected_row = (sel + 1).min(len - 1); } + // The folder-sized steps: the next or previous folder row. + KeyCode::Char('J') => { + if let Some(next) = + (sel + 1..len).find(|&i| browse.rows()[i].is_folder) + { + browse.selected_row = next; + } + } + KeyCode::Char('K') => { + if let Some(previous) = + (0..sel).rev().find(|&i| browse.rows()[i].is_folder) + { + browse.selected_row = previous; + } + } + KeyCode::Char('H') => browse.collapse_all(), + KeyCode::Char('L') => browse.expand_all(), + KeyCode::Char('/') => browse.filtering = true, KeyCode::Right | KeyCode::Char('l') => { if row.is_folder && !row.expanded { - browse.expanded.insert(row.path.clone()); + browse.set_expanded(&row.path, true); } else if row.is_folder { - browse.selected_row = (sel + 1).min(rows.len() - 1); + browse.selected_row = (sel + 1).min(len - 1); } } KeyCode::Left | KeyCode::Char('h') => { if row.is_folder && row.expanded { - browse.expanded.remove(&row.path); - } else if let Some(parent) = - (0..sel).rev().find(|&i| rows[i].depth < row.depth) + browse.set_expanded(&row.path, false); + } else if let Some(parent) = (0..sel) + .rev() + .find(|&i| browse.rows()[i].depth < row.depth) { browse.selected_row = parent; } } KeyCode::Enter => { // Folder toggle (files handled above). - if row.expanded { - browse.expanded.remove(&row.path); - } else { - browse.expanded.insert(row.path.clone()); - } + browse.set_expanded(&row.path, !row.expanded); } _ => {} } - // Re-clamp against the new flattened length. - let new_len = browse.rows().len(); - browse.selected_row = - browse.selected_row.min(new_len.saturating_sub(1)); } self.sync_browse_selection(); } + /// The keys that edit the filter while it is being typed. Says whether + /// `key` was one of them; the rest move around the narrowed rows. + fn handle_browse_filter_input(&mut self, key: KeyEvent) -> bool { + let Some(browse) = self.state.browse.active_tab_mut() else { + return false; + }; + let mut filter = browse.filter().to_string(); + let Some(edit) = edit_filter(&mut filter, key) else { + return false; + }; + browse.filtering = edit == FilterEdit::Changed; + browse.set_filter(filter); + self.sync_browse_selection(); + true + } + /// Point the browse table cursor at the active tab's selected row. fn sync_browse_selection(&mut self) { let selected = self.state.browse.active_tab().map(|b| b.selected_row); self.state.browse_table_state.select(selected); } - /// Files (`path`, `size`) under the active browse tab's folder at `path`. - fn browse_folder_files(&self, path: &str) -> Vec<(String, u64)> { - self.state - .browse - .active_tab() - .and_then(|b| find_node(&b.tree, path)) - .map(files_under) - .unwrap_or_default() - } - /// Queue downloads of `files` (path, size) from the active browse tab's user. + /// + /// Our own shares are already on this disk, so the own tab queues nothing. fn queue_browse_files(&mut self, files: Vec<(String, u64)>) { let Some(username) = - self.state.browse.active_tab().map(|b| b.username.clone()) + self.state.browse.download_target().map(str::to_string) else { return; }; @@ -204,6 +257,31 @@ impl MainTui { self.sync_browse_selection(); } + /// Open (or focus) the view of what this session shares, filled from the + /// index the network is served from. + pub(super) fn show_own_shares(&mut self) { + if self.state.browse.open_own(&self.client.username()) { + self.load_own_shares(); + } + self.state.show_browse = true; + self.sync_browse_selection(); + } + + /// Put a fresh copy of the share index into the own tab, if it is open. + /// A re-index or an added folder would otherwise leave it showing what + /// the network saw a scan ago. + pub(super) fn load_own_shares(&mut self) { + // The early return matters: without an own tab there is nothing to + // fill, and against a daemon asking anyway is a whole index over the + // socket for nobody. + let Some(idx) = self.state.browse.own_index() else { + return; + }; + let listing = self.client.shared_listing(); + self.state.browse.tabs[idx].load(&listing); + self.sync_browse_selection(); + } + /// The username of the highlighted search result (filter-aware). pub(super) fn highlighted_result_owner(&self) -> Option { self.highlighted_result().map(|f| f.username.clone()) diff --git a/soulseek-rs/src/ui/main_tui/downloads.rs b/soulseek-rs/src/ui/main_tui/downloads.rs index d996501..8e650a0 100644 --- a/soulseek-rs/src/ui/main_tui/downloads.rs +++ b/soulseek-rs/src/ui/main_tui/downloads.rs @@ -89,6 +89,31 @@ impl MainTui { } } + /// Empty the whole transfer list: every live download is cancelled and + /// every row โ€” live or finished โ€” is forgotten by the session too, so a + /// shared queue empties for every window rather than only this one. + /// Uploads still streaming are cancelled; finished ones are the + /// session's history and refresh back from it regardless. + pub(super) fn clear_all_transfers(&mut self) { + for entry in &self.state.downloads { + let download = &entry.download; + if !download.is_finished() { + self.client + .cancel_download(&download.username, &download.filename); + } + self.client + .remove_download(&download.username, &download.filename); + } + for upload in &self.state.uploads { + if upload.status == UploadStatus::InProgress { + self.client + .cancel_upload(&upload.username, &upload.filename); + } + } + self.state.downloads.clear(); + self.state.downloads_table_state.select(None); + } + pub(super) fn toggle_selected_download_pause(&self) { let Some(index) = self.state.downloads_table_state.selected() else { return; @@ -206,6 +231,21 @@ impl MainTui { self.state.results_selected_indices.clear(); } + /// The user behind the highlighted transfer, download or upload. + pub(super) fn selected_transfer_user(&self) -> Option { + match selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ) { + Some(InfoSubject::Download(entry)) => { + Some(entry.download.username.clone()) + } + Some(InfoSubject::Upload(upload)) => Some(upload.username.clone()), + Some(InfoSubject::Result(_)) | None => None, + } + } + pub(super) fn cancel_selected_transfer(&self) { let selected = selected_transfer( self.state.downloads_table_state.selected(), diff --git a/soulseek-rs/src/ui/main_tui/input.rs b/soulseek-rs/src/ui/main_tui/input.rs index 2b7038b..1377491 100644 --- a/soulseek-rs/src/ui/main_tui/input.rs +++ b/soulseek-rs/src/ui/main_tui/input.rs @@ -1,6 +1,7 @@ use super::MainTui; -use crate::models::{CommandBarMode, FocusedPane, LogView}; -use crate::ui::pane_block; +use super::render::{MIN_PANE, shrink}; +use crate::models::{CommandBarMode, FocusedPane, LogView, PaneLayout}; +use crate::ui::page_of; use crate::ui::panes::{ InfoSubject, name_end_offset, query_end_offset, selected_transfer, transfer_name_end_offset, upload_display_name, @@ -8,13 +9,14 @@ use crate::ui::panes::{ use ratatui::crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; -use ratatui::layout::{Position, Rect}; +use ratatui::layout::Position; use ratatui::widgets::TableState; const NAME_SCROLL_STEP: isize = 8; impl MainTui { - pub(super) fn handle_key_event(&mut self, key: KeyEvent) { + /// One key press, routed to whatever owns the keyboard right now. + pub fn handle_key_event(&mut self, key: KeyEvent) { // Command bar takes priority if self.state.command_bar_active { return self.handle_command_bar_input(key); @@ -70,7 +72,7 @@ impl MainTui { // Global shortcuts match key.code { KeyCode::Char('q') => { - self.state.should_exit = true; + self.state.exit = Some(crate::models::TuiExit::Quit); return; } KeyCode::Char('?') => { @@ -100,6 +102,13 @@ impl MainTui { self.state.hide_focused_pane(); return; } + KeyCode::Char('W') => { + // The universal undo for the window: every pane back, zoom + // off, and any mouse-dragged pane sizes back to the default + // proportions โ€” they all live in the layout. + self.state.layout = PaneLayout::default(); + return; + } KeyCode::Esc if self.state.layout.zoomed => { self.state.layout.zoomed = false; return; @@ -135,6 +144,12 @@ impl MainTui { self.open_settings(); return; } + // The same tree a peer gets, from our own index: what we look + // like to the network, rather than what we meant to share. + KeyCode::Char('B') => { + self.show_own_shares(); + return; + } KeyCode::Char('b') => { // From a highlighted search result, browse its owner directly; // otherwise prompt for a username. @@ -142,6 +157,10 @@ impl MainTui { && let Some(owner) = self.highlighted_result_owner() { self.start_browse(owner); + } else if self.state.focused_pane == FocusedPane::Downloads + && let Some(user) = self.selected_transfer_user() + { + self.start_browse(user); } else { self.state.command_bar_active = true; self.state.command_bar_mode = CommandBarMode::Browse; @@ -186,6 +205,20 @@ impl MainTui { return; } + // Typing a filter captures the keys that edit it; the rest still + // move around. + if self.state.chat_filtering + && let Some(edit) = edit_filter(&mut self.state.chat_filter, key) + { + if edit != FilterEdit::Changed { + self.state.chat_filtering = false; + } + if edit != FilterEdit::Kept { + self.state.chat_view.to_newest(); + } + return; + } + // Paging keys move through the conversation's history. if scroll_log(&mut self.state.chat_view, key) { return; @@ -195,9 +228,15 @@ impl MainTui { } match key.code { + // Esc peels back one level: a filter first, then the popup. + KeyCode::Esc if !self.state.chat_filter.is_empty() => { + self.state.chat_filter.clear(); + self.state.chat_view.to_newest(); + } KeyCode::Char('i' | 'q') | KeyCode::Esc => { self.state.show_messages = false; } + KeyCode::Char('/') => self.state.chat_filtering = true, // Move through the conversation list on the right. KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') => { self.state.cycle_chat_peer(true); @@ -326,30 +365,16 @@ impl MainTui { } fn handle_filter_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.results_is_filtering = false; - self.state.results_filter_query.clear(); - self.state.results_filtered_items = - self.state.results_items.clone(); - self.state.results_filtered_indices = - (0..self.state.results_items.len()).collect(); - } - KeyCode::Char(c) => { - self.state.results_filter_query.push(c); - self.apply_filter(); - } - KeyCode::Backspace => { - self.state.results_filter_query.pop(); - self.apply_filter(); - } - // Confirm the filter: leave typing mode but keep the query, so - // the normal Results keys (j/k, space, enter) act on the - // filtered list. - KeyCode::Enter => { - self.state.results_is_filtering = false; - } - _ => self.handle_results_input(key), + let Some(edit) = edit_filter(&mut self.state.results_filter_query, key) + else { + return self.handle_results_input(key); + }; + // A kept query stays: the normal Results keys act on the filtered + // list. An empty one shows everything without a rebuild. + if edit == FilterEdit::Changed { + self.apply_filter(); + } else { + self.state.results_is_filtering = false; } } @@ -374,10 +399,10 @@ impl MainTui { navigate_list(key, table, len, page) } - /// Rows a popup shows at once: its inner height less a tab bar and a - /// compose line, which is what its list or log has left. - pub(super) fn popup_page(&self) -> usize { - page_of(self.state.popup_area, 2) + /// Rows a popup's list shows at once: its inner height less the `chrome` + /// rows above the list, a tab bar or a header. + pub(super) fn popup_page(&self, chrome: u16) -> usize { + page_of(self.state.popup_area, chrome) } /// Rows the focused pane shows at once: its inner height less the table @@ -390,11 +415,10 @@ impl MainTui { if self.navigate_focused_list(key) || self.scroll_focused_name(key) { return; } + let selected = self.state.searches_table_state.selected(); match key.code { KeyCode::Enter => { - if let Some(selected) = - self.state.searches_table_state.selected() - { + if let Some(selected) = selected { self.state.selected_search_index = Some(selected); if let Some(search) = self.state.searches.get(selected) { self.state.results_items = search.results.clone(); @@ -410,12 +434,16 @@ impl MainTui { } } KeyCode::Char('d') => { - if let Some(selected) = - self.state.searches_table_state.selected() - { + if let Some(selected) = selected { self.remove_search_at_index(selected); } } + // `s` asks for a query; `S` reuses the highlighted one. + KeyCode::Char('S') => { + if let Some(selected) = selected { + self.rerun_search_at_index(selected); + } + } KeyCode::Char('C') => { self.clear_all_searches(); } @@ -440,10 +468,7 @@ impl MainTui { self.state.results_selected_indices.insert(index); } } - KeyCode::Char('/') => { - self.state.results_is_filtering = true; - self.state.results_filter_query.clear(); - } + KeyCode::Char('/') => self.state.results_is_filtering = true, KeyCode::Char('a') => { let indices: Vec = if self.state.results_filter_query.is_empty() { @@ -550,39 +575,176 @@ impl MainTui { KeyCode::Char('c') => { self.clear_finished_downloads(); } + KeyCode::Char('C') => { + self.clear_all_transfers(); + } _ => {} } } pub(super) fn handle_mouse_event(&mut self, mouse: MouseEvent) { - if mouse.kind != MouseEventKind::Down(MouseButton::Left) { - return; + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + let at = Position::new(mouse.column, mouse.row); + // A divider wins over the panes it separates; grabbing one + // starts a drag, and the click alone already resizes. + if let Some(drag) = self.grabbed_divider(at) { + self.resize_drag = Some(drag); + self.apply_resize(drag, at); + return; + } + // The areas are from the last draw. A pane hidden since then, in the + // same batch of events, still has one, and is not there to click. + let hit = FocusedPane::ALL.into_iter().find(|pane| { + self.state.layout.is_visible(*pane) + && self + .state + .pane_area(*pane) + .is_some_and(|area| area.contains(at)) + }); + if let Some(pane) = hit { + self.state.focused_pane = pane; + } + } + MouseEventKind::Drag(MouseButton::Left) => { + if let Some(drag) = self.resize_drag { + self.apply_resize( + drag, + Position::new(mouse.column, mouse.row), + ); + } + } + MouseEventKind::Up(MouseButton::Left) => { + self.resize_drag = None; + } + _ => {} } + } - let clicked = Position::new(mouse.column, mouse.row); - // The areas are from the last draw. A pane hidden since then, in the - // same batch of events, still has one, and is not there to click. - let hit = FocusedPane::ALL.into_iter().find(|pane| { - self.state.layout.is_visible(*pane) - && self - .state - .pane_area(*pane) - .is_some_and(|area| area.contains(clicked)) - }); - if let Some(pane) = hit { - self.state.focused_pane = pane; + /// Which dragged divider, if any, the pointer is on. Nothing is grabbable + /// while a popup or a zoom covers the dividers. + fn grabbed_divider(&self, at: Position) -> Option { + if self.state.layout.zoomed + || self.state.show_help + || self.state.show_messages + || self.state.show_browse + || self.state.show_rooms + || self.state.settings.is_some() + { + return None; + } + if self + .state + .hsplit_divider + .is_some_and(|rect| rect.contains(at)) + { + return Some(ResizeDrag::Rows); + } + self.state + .vsplit_dividers + .iter() + .find(|(_, rect)| rect.contains(at)) + .map(|(pane, _)| ResizeDrag::Columns(*pane)) + } + + /// Move the divider under the pointer to where the pointer is, keeping + /// every pane at least [`MIN_PANE`] cells. + fn apply_resize(&mut self, drag: ResizeDrag, at: Position) { + match drag { + ResizeDrag::Rows => { + let Some(area) = self.state.content_area else { + return; + }; + // The dragged row is the pane's last row, so the height is + // everything from the top of the content down to it. + let height = at.y.saturating_sub(area.y).saturating_add(1); + self.state.layout.top_height = + Some(shrink(height, area.height)); + } + ResizeDrag::Columns(pane) => { + let Some(row) = self.state.row_area else { + return; + }; + let left = if pane == FocusedPane::Downloads + && self.state.layout.is_visible(FocusedPane::Searches) + { + // Downloads starts where Searches ends. + self.state + .searches_pane_area + .map_or(row.x, |area| area.x.saturating_add(area.width)) + } else { + row.x + }; + let width = at.x.saturating_sub(left).saturating_add(1); + // Info shares the row, so the drag may not crowd it (and any + // other visible pane) out of `MIN_PANE` cells. + let others = MIN_PANE + * u16::from( + self.state.layout.is_visible(FocusedPane::Searches) + && pane == FocusedPane::Downloads, + ) + + MIN_PANE; + let max = row.width.saturating_sub(others).max(MIN_PANE); + let width = width.max(MIN_PANE).min(max); + match pane { + FocusedPane::Searches => { + self.state.layout.searches_width = Some(width); + } + FocusedPane::Downloads => { + self.state.layout.downloads_width = Some(width); + } + FocusedPane::Results => {} + } + } } } } -/// Rows a list in `area` shows at once, less the `chrome` rows above it: a -/// table header, a popup's tab bar and compose line. At least one, so a page -/// key always moves, even before the first draw. -fn page_of(area: Option, chrome: u16) -> usize { - area.map_or(0, |area| { - usize::from(pane_block(false).inner(area).height.saturating_sub(chrome)) - }) - .max(1) +/// What a divider does while the left button is held on it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ResizeDrag { + /// The border between Results and the row underneath: sets that row's height. + Rows, + /// The seam at the right of the named row pane: gives it that width and + /// takes it from the panes to its right. + Columns(FocusedPane), +} + +/// What a key did to a filter being typed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum FilterEdit { + Changed, + Kept, + Cleared, +} + +/// Apply `key` to `filter` the way every typed filter takes it: characters +/// and Backspace edit it, Enter keeps it, Esc clears it. A control or alt +/// chord is not typing. `None` when the key was none of those. +pub(super) fn edit_filter( + filter: &mut String, + key: KeyEvent, +) -> Option { + match key.code { + KeyCode::Esc => { + filter.clear(); + Some(FilterEdit::Cleared) + } + KeyCode::Enter => Some(FilterEdit::Kept), + KeyCode::Backspace => { + filter.pop(); + Some(FilterEdit::Changed) + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + filter.push(c); + Some(FilterEdit::Changed) + } + _ => None, + } } /// A move over a whole list at once: to either end, or by a number of rows diff --git a/soulseek-rs/src/ui/main_tui/mod.rs b/soulseek-rs/src/ui/main_tui/mod.rs index 75ee091..3658f4c 100644 --- a/soulseek-rs/src/ui/main_tui/mod.rs +++ b/soulseek-rs/src/ui/main_tui/mod.rs @@ -7,7 +7,7 @@ mod search; mod settings; use crate::api::SessionApi; -use crate::models::AppState; +use crate::models::{AppState, TuiExit}; use crate::persist::{ snapshot::{Snapshot, restore_messages, restore_searches}, state::{PersistedMessage, StateStore}, @@ -31,6 +31,8 @@ pub struct MainTui { config_path: Option, /// Last snapshot written to disk, to skip no-op saves. saved_snapshot: Snapshot, + /// Which divider the left mouse button is holding, if any. + resize_drag: Option, } impl MainTui { @@ -50,6 +52,7 @@ impl MainTui { store, config_path, saved_snapshot: Snapshot::default(), + resize_drag: None, }; tui.restore_persisted_state(); tui @@ -179,7 +182,7 @@ impl MainTui { self.saved_snapshot = snapshot; } - pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { + pub fn run(mut self, mut terminal: DefaultTerminal) -> Result { use ratatui::crossterm::{event::DisableMouseCapture, execute}; // Run the event loop, then restore the terminal unconditionally: if the @@ -193,34 +196,33 @@ impl MainTui { ratatui::restore(); soulseek_rs::utils::logger::disable_buffering(); - result + result.map(|()| self.state.exit.unwrap_or(TuiExit::Quit)) + } + + /// What the window holds, for a test to read back after driving it. + #[must_use] + pub const fn state(&self) -> &AppState { + &self.state + } + + /// One frame's worth of catching up with the session: everything the + /// event loop does between drawing and reading the keyboard. + pub fn poll_session(&mut self) { + self.update_search_results(); + self.update_downloads(); + self.poll_private_messages(); + self.poll_browse_result(); + self.poll_room_events(); + self.state.uploads = self.client.uploads(); + self.spinner_state = (self.spinner_state + 1) % 10; + self.save_persisted_state(); } fn run_event_loop(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { - while !self.state.should_exit { + while self.state.exit.is_none() { terminal.draw(|frame| self.render(frame))?; - // Poll for search results updates - self.update_search_results(); - - // Poll for download updates - self.update_downloads(); - - // Poll for incoming private messages - self.poll_private_messages(); - - // Poll for a browse (shared-file listing) response - self.poll_browse_result(); - - // Poll for chat-room events - self.poll_room_events(); - - // Refresh the uploads we are serving to peers - self.state.uploads = self.client.uploads(); - - self.spinner_state = (self.spinner_state + 1) % 10; - - self.save_persisted_state(); + self.poll_session(); // Drain every queued input event before the next draw: key // autorepeat outpaces the frame time, and handling one event per @@ -237,7 +239,7 @@ impl MainTui { } _ => {} } - if self.state.should_exit || !poll(Duration::ZERO)? { + if self.state.exit.is_some() || !poll(Duration::ZERO)? { break; } } @@ -255,1628 +257,11 @@ pub fn launch_main_tui( search_timeout: Duration, store: Option, config_path: Option, -) -> Result<()> { +) -> Result { let tui = MainTui::new(client, download_dir, search_timeout, store, config_path); tui.run(terminal) } #[cfg(test)] -mod tests { - use super::*; - use crate::daemon::proto::ChatMessageDto; - use crate::models::{FocusedPane, MessageDirection, SearchStatus}; - use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - /// A stand-in for a daemon: it already holds a conversation, a transfer - /// queue, and a search cache, none of which this window created. - #[derive(Default)] - struct TalkativeSession { - history: Vec, - downloads: Vec, - searches: std::sync::Mutex>, - /// What `try_get_search_results` hands back to the window. - results: std::sync::Mutex>, - /// How many times a window asked for the search list. - all_searches_calls: std::sync::atomic::AtomicUsize, - /// How many times a window pulled a full result set across. Against a - /// daemon each pull is the whole set over the socket, so the tests - /// count them. - result_fetches: std::sync::atomic::AtomicUsize, - /// Whether this session is a daemon's. Local sessions do not sync. - shared: bool, - /// What `set_download_directory` was last asked for, if anything. - download_dir_set: std::sync::Mutex>, - /// What `set_shared_directories` was last asked for, if anything. - shares_set: std::sync::Mutex>>, - download_cancelled: std::sync::Mutex>, - upload_cancelled: std::sync::Mutex>, - } - - fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download { - at_status(username, filename, soulseek_rs::DownloadStatus::Queued) - } - - fn completed( - username: &str, - filename: &str, - ) -> soulseek_rs::types::Download { - at_status(username, filename, soulseek_rs::DownloadStatus::Completed) - } - - fn at_status( - username: &str, - filename: &str, - status: soulseek_rs::DownloadStatus, - ) -> soulseek_rs::types::Download { - let (sender, _) = std::sync::mpsc::channel(); - soulseek_rs::types::Download { - username: username.to_string(), - filename: filename.to_string(), - token: 1, - size: 4096, - download_directory: "/tmp".to_string(), - status, - sender, - queue_position: None, - metadata: soulseek_rs::types::DownloadMetadata::default(), - } - } - - fn key(ch: char) -> KeyEvent { - KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE) - } - - impl SessionApi for TalkativeSession { - fn message_history(&self) -> Vec { - self.history.clone() - } - fn daemon_endpoint(&self) -> Option { - self.shared.then(|| "test-daemon".to_string()) - } - fn username(&self) -> String { - "tester".to_string() - } - fn listen_port(&self) -> Option { - None - } - fn session_loss(&self) -> Option { - None - } - fn search_with_cancel( - &self, - _query: &str, - _timeout: Duration, - _cancel: Option>, - ) -> soulseek_rs::Result> { - Ok(Vec::new()) - } - fn get_search_results( - &self, - _key: &str, - ) -> Vec { - Vec::new() - } - fn all_searches(&self) -> Vec { - self.all_searches_calls - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - self.searches.lock().expect("not poisoned").clone() - } - fn forget_search(&self, query: &str) -> bool { - let mut searches = self.searches.lock().expect("not poisoned"); - let before = searches.len(); - searches.retain(|search| search.query != query); - searches.len() != before - } - fn get_search_results_count(&self, _key: &str) -> usize { - 0 - } - fn try_get_search_results( - &self, - _key: &str, - ) -> Option> { - self.result_fetches - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - Some(self.results.lock().expect("not poisoned").clone()) - } - fn start_wishlist_search( - &self, - _query: &str, - ) -> soulseek_rs::Result<()> { - Ok(()) - } - fn wishlist_interval(&self) -> Duration { - Duration::from_mins(12) - } - fn download( - &self, - _filename: String, - _username: String, - _size: u64, - _directory: String, - ) -> soulseek_rs::Result<( - soulseek_rs::types::Download, - std::sync::mpsc::Receiver, - )> { - Err(soulseek_rs::SoulseekRs::NotConnected) - } - fn download_with_metadata( - &self, - _filename: String, - _username: String, - _size: u64, - _directory: String, - _metadata: soulseek_rs::types::DownloadMetadata, - ) -> soulseek_rs::Result<( - soulseek_rs::types::Download, - std::sync::mpsc::Receiver, - )> { - Err(soulseek_rs::SoulseekRs::NotConnected) - } - fn get_all_downloads(&self) -> Vec { - self.downloads.clone() - } - fn pause_download(&self, _u: &str, _f: &str) -> bool { - false - } - fn resume_download(&self, _u: &str, _f: &str) -> bool { - false - } - fn remove_queued_download(&self, _u: &str, _f: &str) -> bool { - false - } - fn remove_download(&self, _u: &str, _f: &str) -> bool { - false - } - fn uploads(&self) -> Vec { - Vec::new() - } - fn take_upload_events(&self) -> Vec { - Vec::new() - } - fn cancel_upload(&self, u: &str, f: &str) -> bool { - *self.upload_cancelled.lock().expect("not poisoned") = - Some((u.to_string(), f.to_string())); - true - } - fn cancel_download(&self, u: &str, f: &str) -> bool { - *self.download_cancelled.lock().expect("not poisoned") = - Some((u.to_string(), f.to_string())); - true - } - fn set_upload_slots(&self, _slots: usize) {} - fn check_privileges(&self) -> soulseek_rs::Result<()> { - Ok(()) - } - fn own_privilege_seconds(&self) -> Option { - None - } - fn request_room_list(&self) -> soulseek_rs::Result<()> { - Ok(()) - } - fn join_room(&self, _room: &str) -> soulseek_rs::Result<()> { - Ok(()) - } - fn leave_room(&self, _room: &str) -> soulseek_rs::Result<()> { - Ok(()) - } - fn say_in_room( - &self, - _room: &str, - _message: &str, - ) -> soulseek_rs::Result<()> { - Ok(()) - } - fn room_members(&self, _room: &str) -> Vec { - Vec::new() - } - fn room_member_stats( - &self, - _room: &str, - ) -> Vec { - Vec::new() - } - fn take_room_events(&self) -> Vec { - Vec::new() - } - fn send_private_message( - &self, - _u: &str, - _m: &str, - ) -> soulseek_rs::Result<()> { - Ok(()) - } - fn take_private_messages(&self) -> Vec { - Vec::new() - } - fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> { - Ok(()) - } - fn take_browse_result( - &self, - _username: &str, - ) -> Option> { - None - } - fn request_user_info( - &self, - _username: &str, - ) -> soulseek_rs::Result<()> { - Ok(()) - } - fn watch_user(&self, _username: &str) -> soulseek_rs::Result<()> { - Ok(()) - } - fn unwatch_user(&self, _username: &str) -> soulseek_rs::Result<()> { - Ok(()) - } - fn watched_users(&self) -> Vec { - Vec::new() - } - fn user_info(&self, _username: &str) -> Option { - None - } - fn shared_counts(&self) -> (u32, u32) { - (0, 0) - } - fn shared_directories(&self) -> Vec { - Vec::new() - } - fn set_shared_directories( - &self, - directories: Vec, - ) -> soulseek_rs::Result<()> { - *self.shares_set.lock().expect("not poisoned") = Some(directories); - Ok(()) - } - fn set_download_directory( - &self, - directory: String, - ) -> soulseek_rs::Result<()> { - *self.download_dir_set.lock().expect("not poisoned") = - Some(directory); - Ok(()) - } - } - - fn tui(history: Vec) -> MainTui { - with_session(TalkativeSession { - history, - shared: true, - ..TalkativeSession::default() - }) - } - - fn shared(searches: Vec) -> TalkativeSession { - TalkativeSession { - searches: std::sync::Mutex::new(searches), - shared: true, - ..TalkativeSession::default() - } - } - - fn search_of( - query: &str, - files: usize, - age: u64, - ) -> crate::api::SessionSearch { - crate::api::SessionSearch { - query: query.to_string(), - files, - started_secs_ago: Some(age), - } - } - - #[test] - fn x_cancels_the_highlighted_download() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session.clone()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: at_status( - "bob", - "song.mp3", - soulseek_rs::DownloadStatus::InProgress { - bytes_downloaded: 1, - total_bytes: 4096, - speed_bytes_per_sec: 1.0, - }, - ), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('x')); - - assert_eq!( - *session.download_cancelled.lock().expect("not poisoned"), - Some(("bob".to_string(), "song.mp3".to_string())) - ); - assert_eq!( - *session.upload_cancelled.lock().expect("not poisoned"), - None - ); - } - - #[test] - fn d_clears_a_cancelled_row() { - let mut tui = with_session(TalkativeSession::default()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: at_status( - "bob", - "gone.mp3", - soulseek_rs::DownloadStatus::Cancelled, - ), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('d')); - - assert!(tui.state.downloads.is_empty(), "the row goes"); - } - - #[test] - fn x_on_a_finished_download_asks_nothing() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session.clone()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: completed("bob", "done.mp3"), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('x')); - - assert_eq!( - *session.download_cancelled.lock().expect("not poisoned"), - None - ); - } - - #[test] - fn x_on_an_upload_row_still_cancels_the_upload() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session.clone()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "song.mp3"), - receiver: None, - }); - tui.state.uploads.push(soulseek_rs::types::UploadInfo { - username: "alice".to_string(), - filename: "served.mp3".to_string(), - size: 10, - bytes_sent: 1, - speed_bytes_per_sec: 1.0, - status: soulseek_rs::types::UploadStatus::InProgress, - }); - tui.state.downloads_table_state.select(Some(1)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('x')); - - assert_eq!( - *session.upload_cancelled.lock().expect("not poisoned"), - Some(("alice".to_string(), "served.mp3".to_string())) - ); - assert_eq!( - *session.download_cancelled.lock().expect("not poisoned"), - None - ); - } - - fn with_session(session: TalkativeSession) -> MainTui { - attach(Arc::new(session)) - } - - fn attach(session: Arc) -> MainTui { - MainTui::new( - session, - "/tmp".to_string(), - Duration::from_secs(1), - None, - None, - ) - } - - #[test] - fn an_attached_session_opens_with_the_conversation_already_in_it() { - // A TUI attached to a daemon has no state file of its own; the - // conversation has to come from the session, or it starts blank while - // the daemon has been collecting messages for hours. - let tui = tui(vec![ - ChatMessageDto { - peer: "bob".into(), - outgoing: false, - text: "hi".into(), - at: 1, - }, - ChatMessageDto { - peer: "bob".into(), - outgoing: true, - text: "hello".into(), - at: 2, - }, - ]); - - assert_eq!(tui.state.messages.len(), 2); - assert_eq!(tui.state.messages[0].peer, "bob"); - assert_eq!(tui.state.messages[0].direction, MessageDirection::Incoming); - assert_eq!( - tui.state.messages[1].direction, - MessageDirection::Outgoing, - "both halves of the conversation, not just what arrived" - ); - assert_eq!(tui.state.chat_peers(), ["bob"]); - } - - #[test] - fn a_window_shows_transfers_it_did_not_start() { - // Two windows on one daemon are two views of one queue. A transfer - // queued in the other one has to appear here, or closing a window - // would look like losing its downloads. - let mut tui = with_session(TalkativeSession { - downloads: vec![queued("bob", "@@x\\theirs.mp3")], - shared: true, - ..TalkativeSession::default() - }); - assert!( - tui.state.downloads.is_empty(), - "nothing until the first poll" - ); - - tui.update_downloads(); - - assert_eq!(tui.state.downloads.len(), 1); - assert_eq!(tui.state.downloads[0].download.filename, "@@x\\theirs.mp3"); - assert!( - tui.state.downloads[0].receiver.is_none(), - "someone else's transfer has no channel of ours" - ); - - // Polling again must not double it up. - tui.update_downloads(); - assert_eq!(tui.state.downloads.len(), 1); - } - - #[test] - fn a_transfer_the_session_forgets_leaves_every_window() { - let mut tui = with_session(TalkativeSession { - downloads: vec![queued("bob", "gone.mp3")], - shared: true, - ..TalkativeSession::default() - }); - tui.update_downloads(); - assert_eq!(tui.state.downloads.len(), 1); - - // The session no longer has it โ€” cancelled from the other window. - tui.client = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - tui.update_downloads(); - assert!( - tui.state.downloads.is_empty(), - "a queue is shared in both directions" - ); - } - - #[test] - fn d_clears_a_finished_row_but_not_a_running_one() { - // Finished transfers are what a list fills up with after a restart, so - // `d` has to be able to dismiss one โ€” restored or not. A running - // transfer is not ours to discard, and stays put. - let mut tui = with_session(TalkativeSession::default()); - tui.state.downloads.push(crate::models::DownloadEntry { - download: completed("bob", "done.mp3"), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Downloads; - - tui.handle_key_event(key('d')); - - assert!(tui.state.downloads.is_empty(), "the row goes too"); - assert_eq!(tui.state.downloads_table_state.selected(), None); - - tui.state.downloads.push(crate::models::DownloadEntry { - download: at_status( - "bob", - "still.mp3", - soulseek_rs::DownloadStatus::InProgress { - bytes_downloaded: 1, - total_bytes: 2, - speed_bytes_per_sec: 1.0, - }, - ), - receiver: None, - }); - tui.state.downloads_table_state.select(Some(0)); - - tui.handle_key_event(key('d')); - - assert_eq!(tui.state.downloads.len(), 1, "it is not disposable"); - } - - #[test] - fn a_window_shows_searches_run_in_another() { - let mut tui = - with_session(shared(vec![search_of("their query", 7, 9_999)])); - tui.update_search_results(); - - assert_eq!(tui.state.searches.len(), 1); - assert_eq!(tui.state.searches[0].query, "their query"); - - // And it is not added a second time on the next frame. - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 1); - } - - #[test] - fn a_search_another_window_is_still_running_shows_as_active() { - // It had said "Done" the moment it appeared, because an imported - // search carried no notion of still collecting โ€” so the other window - // looked finished while results were still arriving. - let mut tui = - with_session(shared(vec![search_of("still going", 3, 0)])); - tui.update_search_results(); - - assert_eq!(tui.state.searches[0].status, SearchStatus::Active); - assert_eq!( - tui.state.searches[0].known_files, 3, - "and its count comes across before the results do" - ); - } - - #[test] - fn a_search_that_has_run_its_window_shows_as_done() { - let mut tui = - with_session(shared(vec![search_of("finished", 12, 9_999)])); - tui.update_search_results(); - assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); - } - - #[test] - fn removing_a_search_removes_it_from_the_session() { - // Removing it only from this window would last until the next sync, - // which would put it straight back โ€” and in a shared session it would - // come back for every other window too. - let mut tui = - with_session(shared(vec![search_of("unwanted", 1, 9_999)])); - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 1); - - tui.remove_search_at_index(0); - tui.update_search_results(); - - assert!(tui.state.searches.is_empty(), "and it stays gone"); - } - - #[test] - fn clearing_every_search_clears_them_in_the_session_too() { - let mut tui = with_session(shared(vec![ - search_of("one", 1, 9_999), - search_of("two", 2, 9_999), - ])); - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 2); - - tui.clear_all_searches(); - tui.update_search_results(); - - assert!( - tui.state.searches.is_empty(), - "clearing has to reach the session, or every row comes back" - ); - } - - #[test] - fn a_search_dismissed_in_another_window_leaves_this_one() { - let session = shared(vec![search_of("theirs", 4, 9_999)]); - let mut tui = with_session(session); - tui.update_search_results(); - assert_eq!(tui.state.searches.len(), 1); - - // The other window dismissed it. - tui.client.forget_search("theirs"); - tui.update_search_results(); - - assert!(tui.state.searches.is_empty()); - } - - fn files_from_bob(names: &[&str]) -> soulseek_rs::SearchResult { - soulseek_rs::SearchResult { - token: 0, - files: names - .iter() - .map(|name| soulseek_rs::types::File { - username: "bob".to_string(), - name: (*name).to_string(), - size: 42, - attribs: std::collections::HashMap::new(), - }) - .collect(), - slots: 1, - speed: 0, - username: "bob".to_string(), - } - } - - #[test] - fn a_tick_asks_the_session_for_its_searches_once() { - // Against a daemon every call is a round trip, and the daemon answers - // it by walking its whole search cache โ€” twice per frame was half the - // cost of attaching at all. - let session = Arc::new(shared(vec![search_of("q", 0, 9_999)])); - let mut tui = attach(session.clone()); - - tui.update_search_results(); - - assert_eq!( - session - .all_searches_calls - .load(std::sync::atomic::Ordering::Relaxed), - 1, - "one sync means one listing, not one per use of it" - ); - } - - /// An attached window that selected a two-file search and has already - /// ticked past its one necessary fetch. - fn window_that_pulled_two_files() -> (Arc, MainTui) { - let session = Arc::new(TalkativeSession { - results: std::sync::Mutex::new(vec![files_from_bob(&[ - "@@x\\a.mp3", - "@@x\\b.mp3", - ])]), - ..shared(vec![search_of("aphex twin", 2, 9_999)]) - }); - let mut tui = attach(session.clone()); - tui.state.selected_search_index = Some(0); - tui.update_search_results(); - tui.update_search_results(); - (session, tui) - } - - #[test] - fn a_selected_search_with_nothing_new_is_not_pulled_across_again() { - // The result set only accumulates, so an unchanged count means a - // fetch would hand back what this window already holds โ€” against a - // daemon that is the entire result set over the socket, every frame, - // forever. - let (session, tui) = window_that_pulled_two_files(); - - assert_eq!(tui.state.searches[0].results.len(), 2); - assert_eq!( - session - .result_fetches - .load(std::sync::atomic::Ordering::Relaxed), - 1, - "the set crosses once; after that the unchanged count spares it" - ); - } - - #[test] - fn fresh_results_still_reach_a_window_that_skipped_a_fetch() { - let (session, mut tui) = window_that_pulled_two_files(); - - // A late responder lands in the session. - *session.searches.lock().expect("not poisoned") = - vec![search_of("aphex twin", 3, 9_999)]; - *session.results.lock().expect("not poisoned") = - vec![files_from_bob(&["@@x\\a.mp3", "@@x\\b.mp3", "@@x\\c.mp3"])]; - tui.update_search_results(); - - assert_eq!( - tui.state.searches[0].results.len(), - 3, - "a changed count must bring the new results across" - ); - } - - #[test] - fn a_local_window_fetches_results_without_counting_on_the_session() { - // Locally nothing maintains known_files, so the count gate must stay - // out of the way: a search whose count still reads zero has to get - // its results anyway. - let session = Arc::new(TalkativeSession { - results: std::sync::Mutex::new(vec![files_from_bob(&[ - "@@x\\a.mp3", - "@@x\\b.mp3", - ])]), - shared: false, - ..TalkativeSession::default() - }); - let mut tui = attach(session); - tui.state.searches.push(crate::models::SearchEntry { - query: "mine".to_string(), - status: SearchStatus::Active, - results: Vec::new(), - known_files: 0, - owned: true, - start_time: std::time::Instant::now(), - cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - - tui.update_search_results(); - - assert_eq!( - tui.state.searches[0].results.len(), - 2, - "a zero count matching zero held rows must not read as \ - nothing-to-fetch on a local session" - ); - } - - #[test] - fn a_window_with_its_own_session_is_left_alone() { - // Nothing to share, so nothing is synced โ€” and in particular the - // completed transfers restored from disk, which the session has never - // heard of, are not mistaken for cancelled and deleted. - let mut tui = with_session(TalkativeSession { - downloads: Vec::new(), - shared: false, - ..TalkativeSession::default() - }); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "from-disk.mp3"), - receiver: None, - }); - - tui.update_downloads(); - - assert_eq!( - tui.state.downloads.len(), - 1, - "a local window's history must survive the tick" - ); - } - - #[test] - fn a_local_session_starts_from_its_own_snapshot_instead() { - // Locally the session knows no history and the state file is the whole - // record, so nothing is invented. - assert!(tui(Vec::new()).state.messages.is_empty()); - } - - /// Attached, both folders in the settings form name paths on the - /// *daemon's* machine: they go over the socket exactly as typed, and - /// nothing is created or validated against this machine's filesystem. - #[test] - fn applying_settings_attached_pushes_both_folders_to_the_daemon() { - let session = Arc::new(TalkativeSession { - shared: true, - ..TalkativeSession::default() - }); - let scratch = tempfile::tempdir().expect("a temp dir"); - let config_path = scratch.path().join("config.toml"); - let mut tui = MainTui::new( - session.clone(), - "/daemon/old".to_string(), - Duration::from_secs(1), - None, - Some(config_path.clone()), - ); - - let daemon_only = scratch.path().join("only-on-the-daemon"); - let daemon_dir = daemon_only.to_str().expect("utf-8").to_string(); - tui.open_settings(); - { - let settings = tui.state.settings.as_mut().expect("open"); - settings.download_dir.clone_from(&daemon_dir); - settings.share_dirs = vec!["/not-here-either".to_string()]; - } - tui.apply_settings(); - - assert_eq!( - *session.download_dir_set.lock().expect("not poisoned"), - Some(daemon_dir.clone()), - "the folder is pushed to the daemon" - ); - assert_eq!( - *session.shares_set.lock().expect("not poisoned"), - Some(vec!["/not-here-either".to_string()]), - "shares go over as typed โ€” the daemon's filesystem judges them" - ); - assert!( - !daemon_only.exists(), - "no directory is created on this machine for a daemon's path" - ); - assert_eq!(tui.download_dir, daemon_dir); - - let saved = crate::persist::config::FileConfig::load(&config_path) - .expect("config readable"); - assert_eq!( - saved.download_dir.as_deref(), - Some(daemon_dir.as_str()), - "the change still lands in config.toml for the next restart" - ); - } - - /// Locally the same form works on this machine: the folder is created - /// here and share paths that do not exist here are dropped before the - /// session hears about them. - #[test] - fn applying_settings_locally_stays_on_this_machine() { - let session = Arc::new(TalkativeSession { - shared: false, - ..TalkativeSession::default() - }); - let scratch = tempfile::tempdir().expect("a temp dir"); - let config_path = scratch.path().join("config.toml"); - let share = scratch.path().join("music"); - std::fs::create_dir_all(&share).expect("share dir"); - let mut tui = MainTui::new( - session.clone(), - "/tmp".to_string(), - Duration::from_secs(1), - None, - Some(config_path), - ); - - let downloads = scratch.path().join("downloads"); - let downloads_dir = downloads.to_str().expect("utf-8").to_string(); - tui.open_settings(); - { - let settings = tui.state.settings.as_mut().expect("open"); - settings.download_dir.clone_from(&downloads_dir); - settings.share_dirs = vec![ - share.to_str().expect("utf-8").to_string(), - "/definitely-not-a-real-share".to_string(), - ]; - } - tui.apply_settings(); - - // The seam is told in both modes; a local session accepts and takes - // the directory per download instead of storing it. - assert_eq!( - *session.download_dir_set.lock().expect("not poisoned"), - Some(downloads_dir.clone()) - ); - assert_eq!( - *session.shares_set.lock().expect("not poisoned"), - Some(vec![share.to_str().expect("utf-8").to_string()]), - "paths missing on this machine are dropped before applying" - ); - assert!(downloads.is_dir(), "the folder is created here"); - assert_eq!(tui.download_dir, downloads_dir); - } - - fn results(n: usize) -> Vec { - (0..n) - .map(|i| crate::models::FileDisplayData { - filename: format!("@@x\\Music\\{i:02}.mp3"), - username: "bob".to_string(), - ..Default::default() - }) - .collect() - } - - fn results_tui(n: usize) -> MainTui { - let mut tui = with_session(TalkativeSession::default()); - tui.state.results_items = results(n); - tui.state.focused_pane = FocusedPane::Results; - tui.state.results_pane_area = - Some(ratatui::layout::Rect::new(0, 0, 80, 13)); - tui - } - - fn press(tui: &mut MainTui, code: KeyCode) { - tui.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); - } - - fn ctrl(tui: &mut MainTui, ch: char) { - tui.handle_key_event(KeyEvent::new( - KeyCode::Char(ch), - KeyModifiers::CONTROL, - )); - } - - fn selected(tui: &MainTui) -> Option { - tui.state.results_table_state.selected() - } - - #[test] - fn end_and_home_jump_to_the_last_and_first_result() { - let mut tui = results_tui(30); - press(&mut tui, KeyCode::End); - assert_eq!(selected(&tui), Some(29)); - press(&mut tui, KeyCode::Home); - assert_eq!(selected(&tui), Some(0)); - press(&mut tui, KeyCode::Char('G')); - assert_eq!(selected(&tui), Some(29)); - press(&mut tui, KeyCode::Char('g')); - assert_eq!(selected(&tui), Some(0)); - } - - #[test] - fn page_keys_move_a_pane_of_rows_and_stop_at_the_edges() { - let mut tui = results_tui(30); - press(&mut tui, KeyCode::PageDown); - assert_eq!(selected(&tui), Some(10)); - ctrl(&mut tui, 'f'); - assert_eq!(selected(&tui), Some(20)); - press(&mut tui, KeyCode::PageDown); - assert_eq!(selected(&tui), Some(29), "clamps instead of wrapping"); - press(&mut tui, KeyCode::PageUp); - assert_eq!(selected(&tui), Some(19)); - ctrl(&mut tui, 'b'); - assert_eq!(selected(&tui), Some(9)); - press(&mut tui, KeyCode::PageUp); - assert_eq!(selected(&tui), Some(0)); - assert!( - !tui.state.show_browse && !tui.state.command_bar_active, - "ctrl-b pages up instead of opening browse" - ); - } - - #[test] - fn ctrl_d_and_ctrl_u_move_half_a_pane() { - let mut tui = results_tui(30); - ctrl(&mut tui, 'd'); - assert_eq!(selected(&tui), Some(5)); - ctrl(&mut tui, 'u'); - assert_eq!(selected(&tui), Some(0)); - } - - #[test] - fn navigation_keys_do_nothing_without_results() { - let mut tui = results_tui(0); - tui.state.results_table_state.select(None); - press(&mut tui, KeyCode::End); - press(&mut tui, KeyCode::PageDown); - ctrl(&mut tui, 'd'); - assert_eq!(selected(&tui), None); - } - - #[test] - fn l_and_h_scroll_the_highlighted_name_up_to_its_end() { - let mut tui = results_tui(3); - tui.state.results_items[1].filename = "x".repeat(40); - tui.state.results_table_state.select(Some(1)); - press(&mut tui, KeyCode::Char('l')); - assert_eq!(tui.state.results_name_offset, 8); - press(&mut tui, KeyCode::Right); - assert_eq!(tui.state.results_name_offset, 16); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 30); - press(&mut tui, KeyCode::Char('l')); - assert_eq!(tui.state.results_name_offset, 30, "stops at the end"); - press(&mut tui, KeyCode::Char('h')); - assert_eq!(tui.state.results_name_offset, 22); - press(&mut tui, KeyCode::Left); - assert_eq!(tui.state.results_name_offset, 14); - press(&mut tui, KeyCode::Char('0')); - assert_eq!(tui.state.results_name_offset, 0); - press(&mut tui, KeyCode::Char('h')); - assert_eq!(tui.state.results_name_offset, 0); - } - - #[test] - fn scrolling_follows_the_highlighted_row_not_the_longest_one() { - let mut tui = results_tui(3); - tui.state.results_items[0].filename = "short.mp3".to_string(); - tui.state.results_items[1].filename = "x".repeat(40); - tui.state.results_items[2].filename = "short.mp3".to_string(); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 0, "row 0 fits already"); - tui.state.results_table_state.select(Some(1)); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 30); - tui.state.results_table_state.select(Some(2)); - press(&mut tui, KeyCode::Char('h')); - assert_eq!( - tui.state.results_name_offset, 0, - "a step left lands within the row now highlighted" - ); - } - - #[test] - fn picking_another_search_starts_its_results_unscrolled() { - let mut tui = results_tui(3); - tui.state.results_items[1].filename = "x".repeat(40); - tui.state.results_table_state.select(Some(1)); - press(&mut tui, KeyCode::Char('$')); - assert_eq!(tui.state.results_name_offset, 30); - - tui.state.searches.push(crate::models::SearchEntry { - query: "other".to_string(), - status: SearchStatus::Active, - results: results(2), - known_files: 2, - owned: true, - start_time: std::time::Instant::now(), - cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - tui.state.searches_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Enter); - assert_eq!(tui.state.results_name_offset, 0); - } - - #[test] - fn ctrl_keys_are_ignored_outside_the_results_pane() { - let mut tui = results_tui(3); - tui.state.searches.push(crate::models::SearchEntry { - query: "keep me".to_string(), - status: SearchStatus::Active, - results: Vec::new(), - known_files: 0, - owned: true, - start_time: std::time::Instant::now(), - cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - tui.state.searches_table_state.select(Some(0)); - tui.state.focused_pane = FocusedPane::Searches; - ctrl(&mut tui, 'd'); - assert_eq!(tui.state.searches.len(), 1, "ctrl-d is not d"); - ctrl(&mut tui, 'b'); - assert!(!tui.state.command_bar_active, "ctrl-b is not b"); - ctrl(&mut tui, 'q'); - assert!(!tui.state.should_exit, "ctrl-q is not q"); - } - - #[test] - fn ctrl_keys_page_while_a_filter_is_being_typed() { - let mut tui = results_tui(30); - press(&mut tui, KeyCode::Char('/')); - press(&mut tui, KeyCode::Char('m')); - ctrl(&mut tui, 'd'); - assert_eq!(selected(&tui), Some(5)); - assert_eq!(tui.state.results_filter_query, "m", "no d appended"); - press(&mut tui, KeyCode::End); - assert_eq!(selected(&tui), Some(29)); - } - - #[test] - fn space_after_a_filter_that_matches_nothing_does_not_panic() { - let mut tui = results_tui(3); - press(&mut tui, KeyCode::Char('/')); - press(&mut tui, KeyCode::Char('z')); - press(&mut tui, KeyCode::Enter); - assert!(tui.state.results_filtered_items.is_empty()); - press(&mut tui, KeyCode::Char(' ')); - assert!(tui.state.results_selected_indices.is_empty()); - } - - fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String { - let backend = ratatui::backend::TestBackend::new(width, height); - let mut terminal = ratatui::Terminal::new(backend).expect("backend"); - terminal.draw(|frame| tui.render(frame)).expect("draw"); - terminal.backend().to_string() - } - - fn screen_of(tui: &mut MainTui) -> String { - screen_sized(tui, 160, 40) - } - - /// The screen row carrying a pane's legend, `[2] Results` say, without - /// the quotes the test backend wraps each row in. - fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str { - let line = screen - .lines() - .find(|line| line.contains(needle)) - .unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}")); - let start = line.find('"').map_or(0, |i| i + 1); - let end = line.rfind('"').unwrap_or(line.len()); - &line[start..end] - } - - /// A window with a search, its results, and a transfer, so every pane - /// has something to show. - fn furnished_tui() -> MainTui { - let mut tui = results_tui(3); - tui.state.searches.push(crate::models::SearchEntry { - query: "aphex twin".to_string(), - status: SearchStatus::Completed, - results: results(3), - known_files: 3, - owned: true, - start_time: std::time::Instant::now(), - cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }); - tui.state.selected_search_index = Some(0); - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", "@@x\\Music\\00.mp3"), - receiver: None, - }); - tui - } - - #[test] - fn the_results_pane_has_the_whole_width_to_itself() { - let mut tui = furnished_tui(); - let screen = screen_of(&mut tui); - - let results = row_with(&screen, "[2] Results"); - assert!(results.starts_with('โ•ญ'), "{results}"); - assert!(results.trim_end().ends_with('โ•ฎ'), "{results}"); - assert!(!results.contains("[1]"), "nothing beside it: {results}"); - - // The other three share the row underneath, in this order. - let row = row_with(&screen, "[1] Searches"); - let searches = row.find("[1]").expect("searches"); - let downloads = row.find("[3]").expect("downloads"); - let info = row.find("[Info]").expect("info"); - assert!(searches < downloads && downloads < info, "{row}"); - } - - #[test] - fn tab_and_shift_tab_walk_the_panes_in_legend_order() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - press(&mut tui, KeyCode::BackTab); - assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); - } - - #[test] - fn w_hides_the_focused_pane_and_its_number_brings_it_back() { - let mut tui = furnished_tui(); - tui.state.focused_pane = FocusedPane::Searches; - - press(&mut tui, KeyCode::Char('w')); - - let screen = screen_of(&mut tui); - assert!(!screen.contains("[1] Searches"), "{screen}"); - assert!(screen.contains("[2] Results"), "{screen}"); - assert!(screen.contains("[3] Downloads"), "{screen}"); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - assert_eq!(tui.state.searches_pane_area, None, "nothing to click"); - // The transfers widen into the space. - let row = row_with(&screen, "[3] Downloads"); - assert!(row.starts_with('โ•ญ'), "{row}"); - - press(&mut tui, KeyCode::Char('1')); - - let screen = screen_of(&mut tui); - assert!(screen.contains("[1] Searches"), "{screen}"); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - } - - #[test] - fn hiding_the_results_gives_the_row_the_whole_height() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('w')); - let screen = screen_of(&mut tui); - assert!(!screen.contains("[2] Results"), "{screen}"); - // The row now starts right under the status bar. - let status = screen - .lines() - .position(|line| line.contains("Status")) - .expect("status bar"); - let row = screen - .lines() - .position(|line| line.contains("[1] Searches")) - .expect("searches"); - assert_eq!(row, status + 3, "{screen}"); - } - - #[test] - fn the_last_pane_standing_cannot_be_hidden() { - // Results goes, then Downloads; Searches is what is left, and a - // third press leaves it there. - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('w')); - press(&mut tui, KeyCode::Char('w')); - press(&mut tui, KeyCode::Char('w')); - let screen = screen_of(&mut tui); - assert!(screen.contains("[1] Searches"), "{screen}"); - assert!(!screen.contains("[3] Downloads"), "{screen}"); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - } - - #[test] - fn z_zooms_the_focused_pane_and_esc_or_z_leaves_it() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('z')); - - let screen = screen_of(&mut tui); - assert!(screen.contains("[2] Results"), "{screen}"); - assert!(!screen.contains("[1] Searches"), "{screen}"); - assert!(!screen.contains("[3] Downloads"), "{screen}"); - assert!(!screen.contains("[Info]"), "{screen}"); - assert!(screen.contains("[z โ†’ unzoom]"), "{screen}"); - - // Tab while zoomed switches which pane fills the window. - press(&mut tui, KeyCode::Tab); - let screen = screen_of(&mut tui); - assert!(screen.contains("[3] Downloads"), "{screen}"); - assert!(!screen.contains("[2] Results"), "{screen}"); - - press(&mut tui, KeyCode::Esc); - assert!(!tui.state.layout.zoomed); - let screen = screen_of(&mut tui); - assert!(screen.contains("[2] Results"), "{screen}"); - assert!(screen.contains("[Info]"), "{screen}"); - - press(&mut tui, KeyCode::Char('z')); - press(&mut tui, KeyCode::Char('z')); - assert!(!tui.state.layout.zoomed, "z toggles"); - } - - #[test] - fn a_search_brings_a_hidden_results_pane_back() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('w')); - assert!(!tui.state.layout.is_visible(FocusedPane::Results)); - - press(&mut tui, KeyCode::Char('s')); - press(&mut tui, KeyCode::Char('x')); - press(&mut tui, KeyCode::Enter); - - assert!(tui.state.layout.is_visible(FocusedPane::Results)); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - } - - #[test] - fn question_mark_opens_the_keys_list_and_closes_it_again() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('?')); - - let screen = screen_of(&mut tui); - assert!(screen.contains("Keys"), "{screen}"); - assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); - assert!(screen.contains("clear every finished one"), "{screen}"); - - // Other keys are swallowed while it is open. - press(&mut tui, KeyCode::Char('q')); - assert!(!tui.state.should_exit, "q closes the list, not the app"); - assert!(!tui.state.show_help); - - press(&mut tui, KeyCode::Char('?')); - press(&mut tui, KeyCode::Char('w')); - assert!(tui.state.show_help, "w did not reach the panes"); - assert!(tui.state.layout.is_visible(FocusedPane::Results)); - press(&mut tui, KeyCode::Esc); - assert!(!tui.state.show_help); - } - - #[test] - fn on_a_small_terminal_the_keys_list_stacks_and_scrolls() { - let mut tui = furnished_tui(); - press(&mut tui, KeyCode::Char('?')); - - let screen = screen_sized(&mut tui, 80, 24); - let panes = row_with(&screen, "Panes"); - assert!(!panes.contains("Searches"), "one column: {panes}"); - assert!(screen.contains("focus a pane, hidden or not"), "{screen}"); - assert!(!screen.contains("next room, chat or user"), "{screen}"); - - press(&mut tui, KeyCode::End); - let screen = screen_sized(&mut tui, 80, 24); - assert!(screen.contains("next room, chat or user"), "{screen}"); - assert!(!screen.contains("next / previous pane"), "{screen}"); - - press(&mut tui, KeyCode::Home); - let screen = screen_sized(&mut tui, 80, 24); - assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); - - // A wide window shows both columns at once, nothing to scroll. - press(&mut tui, KeyCode::End); - let screen = screen_of(&mut tui); - let panes = row_with(&screen, "Panes"); - assert!(panes.contains("Searches"), "two columns: {panes}"); - assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); - } - - #[test] - fn a_click_where_a_pane_just_hid_does_not_focus_it() { - let mut tui = furnished_tui(); - let _ = screen_of(&mut tui); - let searches = tui.state.searches_pane_area.expect("laid out"); - // Hidden, but not yet redrawn: the old area is still on record. - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Char('w')); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { - kind: ratatui::crossterm::event::MouseEventKind::Down( - ratatui::crossterm::event::MouseButton::Left, - ), - column: searches.x + 1, - row: searches.y + 1, - modifiers: KeyModifiers::NONE, - }); - assert_eq!(tui.state.focused_pane, FocusedPane::Results); - } - - #[test] - fn l_and_h_scroll_a_transfer_name_and_a_query_too() { - let mut tui = furnished_tui(); - tui.state.downloads[0].download.filename = "y".repeat(120); - tui.state.searches[0].query = "z".repeat(60); - let _ = screen_of(&mut tui); - - tui.state.focused_pane = FocusedPane::Downloads; - press(&mut tui, KeyCode::Char('l')); - assert_eq!(tui.state.downloads_name_offset, 8); - press(&mut tui, KeyCode::Char('$')); - let end = tui.state.downloads_name_offset; - assert!(end > 8, "the end is past a step: {end}"); - let screen = screen_of(&mut tui); - assert!(row_with(&screen, "โ€ฆyyyy").contains("โ€ฆyyyy"), "{screen}"); - press(&mut tui, KeyCode::Char('0')); - assert_eq!(tui.state.downloads_name_offset, 0); - - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Right); - assert_eq!(tui.state.searches_query_offset, 8); - press(&mut tui, KeyCode::End); - assert_eq!( - tui.state.searches_table_state.selected(), - Some(0), - "End is still a row key, $ is the name key" - ); - press(&mut tui, KeyCode::Char('$')); - assert!(tui.state.searches_query_offset > 8); - let screen = screen_of(&mut tui); - assert!(screen.contains("โ€ฆzzzz"), "{screen}"); - assert!( - tui.state.results_name_offset == 0, - "each list scrolls on its own" - ); - } - - fn browsed(tui: &mut MainTui, files: usize) { - tui.state.browse.open("bob"); - let listing = vec![soulseek_rs::SharedDirectory { - name: "Music".to_string(), - files: (0..files) - .map(|i| soulseek_rs::SharedFileEntry { - name: format!("{i:02}.mp3"), - size: 1, - attributes: Vec::new(), - }) - .collect(), - }]; - tui.state - .browse - .active_tab_mut() - .expect("tab") - .load(&listing); - tui.state.show_browse = true; - let _ = screen_of(tui); - } - - #[test] - fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() { - let session = Arc::new(TalkativeSession::default()); - let mut tui = attach(session); - browsed(&mut tui, 40); - let rows = tui.state.browse.active_tab().expect("tab").rows().len(); - assert_eq!(rows, 41, "the folder and its files"); - - press(&mut tui, KeyCode::End); - let selected = |tui: &MainTui| { - tui.state.browse.active_tab().expect("tab").selected_row - }; - assert_eq!(selected(&tui), 40); - press(&mut tui, KeyCode::Home); - assert_eq!(selected(&tui), 0); - press(&mut tui, KeyCode::PageDown); - let page = selected(&tui); - assert!(page > 1 && page < 40, "a page of rows: {page}"); - ctrl(&mut tui, 'd'); - assert!(selected(&tui) > page, "ctrl-d pages"); - assert!( - tui.state.downloads.is_empty() - && tui.state.downloads_receiver_channel.is_none(), - "and does not download the folder" - ); - press(&mut tui, KeyCode::Char('G')); - assert_eq!(selected(&tui), 40); - assert!(tui.state.show_browse, "still open"); - } - - fn in_a_room(tui: &mut MainTui, messages: usize) { - tui.state.rooms.apply_event( - soulseek_rs::RoomEvent::Joined { - room: "jazz".to_string(), - users: (0..30).map(|i| format!("user{i:02}")).collect(), - }, - None, - ); - tui.state.rooms.focus_or_open("jazz"); - for i in 0..messages { - tui.state.rooms.apply_event( - soulseek_rs::RoomEvent::Message { - room: "jazz".to_string(), - username: "alice".to_string(), - message: format!("line {i:03}"), - }, - Some("jazz"), - ); - } - tui.state.show_rooms = true; - let _ = screen_of(tui); - } - - #[test] - fn page_up_scrolls_a_room_log_back_and_end_returns_to_the_newest() { - let mut tui = with_session(TalkativeSession::default()); - in_a_room(&mut tui, 120); - - let screen = screen_of(&mut tui); - assert!(screen.contains("line 119"), "tails by default: {screen}"); - assert!(!screen.contains("line 000"), "{screen}"); - - press(&mut tui, KeyCode::PageUp); - let screen = screen_of(&mut tui); - assert!(!screen.contains("line 119"), "scrolled back: {screen}"); - - press(&mut tui, KeyCode::Home); - let screen = screen_of(&mut tui); - assert!(screen.contains("line 000"), "the oldest: {screen}"); - press(&mut tui, KeyCode::PageUp); - let screen = screen_of(&mut tui); - assert!(screen.contains("line 000"), "clamped at the top: {screen}"); - - press(&mut tui, KeyCode::End); - let screen = screen_of(&mut tui); - assert!(screen.contains("line 119"), "{screen}"); - - // The member list keeps its own keys, and ctrl-x is not x. - press(&mut tui, KeyCode::Down); - assert_eq!(tui.state.rooms.user_selected, 1); - ctrl(&mut tui, 'x'); - assert_eq!(tui.state.rooms.open.len(), 1, "still in the room"); - } - - #[test] - fn page_keys_move_through_the_room_list() { - let mut tui = with_session(TalkativeSession::default()); - tui.state.rooms.apply_event( - soulseek_rs::RoomEvent::List( - (0..60) - .map(|i| soulseek_rs::types::RoomInfo { - name: format!("room{i:02}"), - user_count: 60 - i, - }) - .collect(), - ), - None, - ); - tui.state.show_rooms = true; - let _ = screen_of(&mut tui); - - press(&mut tui, KeyCode::End); - assert_eq!(tui.state.rooms.list_selected, 59); - press(&mut tui, KeyCode::PageUp); - assert!(tui.state.rooms.list_selected < 59); - press(&mut tui, KeyCode::Char('g')); - assert_eq!(tui.state.rooms.list_selected, 0); - ctrl(&mut tui, 'f'); - assert!(tui.state.rooms.list_selected > 1); - } - - #[test] - fn page_up_scrolls_a_conversation_and_switching_chats_resets_it() { - let mut tui = tui((0..80) - .map(|i| ChatMessageDto { - peer: "bob".into(), - outgoing: i % 2 == 0, - text: format!("msg {i:03}"), - at: i, - }) - .chain(std::iter::once(ChatMessageDto { - peer: "carol".into(), - outgoing: false, - text: "hello".into(), - at: 100, - })) - .collect()); - tui.state.chat_peer = Some("bob".to_string()); - tui.state.show_messages = true; - let screen = screen_of(&mut tui); - assert!(screen.contains("msg 079"), "{screen}"); - - press(&mut tui, KeyCode::PageUp); - let screen = screen_of(&mut tui); - assert!(!screen.contains("msg 079"), "scrolled back: {screen}"); - press(&mut tui, KeyCode::Char('g')); - let screen = screen_of(&mut tui); - assert!(screen.contains("msg 000"), "{screen}"); - - press(&mut tui, KeyCode::Tab); - assert_eq!(tui.state.active_chat_peer(), Some("carol")); - assert!( - tui.state.chat_view.following(), - "a fresh chat starts at its end" - ); - press(&mut tui, KeyCode::BackTab); - let screen = screen_of(&mut tui); - assert!(screen.contains("msg 079"), "{screen}"); - } - - #[test] - fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() { - let mut tui = furnished_tui(); - let screen = screen_of(&mut tui); - assert!(screen.contains("[q โ†’ quit]"), "{screen}"); - let bar_rows = - screen.lines().filter(|line| line.contains(" โ†’ ")).count(); - assert_eq!(bar_rows, 2, "{screen}"); - - let wide = screen_sized(&mut tui, 300, 40); - let bar_rows = wide.lines().filter(|line| line.contains(" โ†’ ")).count(); - assert_eq!(bar_rows, 1, "{wide}"); - } - - #[test] - fn page_keys_work_in_the_transfers_and_searches_lists_too() { - let mut tui = furnished_tui(); - for i in 0..30 { - tui.state.downloads.push(crate::models::DownloadEntry { - download: queued("bob", &format!("{i}.mp3")), - receiver: None, - }); - } - tui.state.focused_pane = FocusedPane::Downloads; - tui.state.downloads_table_state.select(Some(0)); - let _ = screen_of(&mut tui); // lays the panes out, which sets the page size - - press(&mut tui, KeyCode::End); - assert_eq!(tui.state.downloads_table_state.selected(), Some(30)); - press(&mut tui, KeyCode::Home); - assert_eq!(tui.state.downloads_table_state.selected(), Some(0)); - press(&mut tui, KeyCode::PageDown); - let page = tui.state.downloads_table_state.selected().expect("row"); - assert!(page > 1 && page < 30, "a pane of rows, not one: {page}"); - ctrl(&mut tui, 'd'); - assert_eq!(tui.state.downloads.len(), 31, "ctrl-d pages, d deletes"); - assert!(tui.state.downloads_table_state.selected() > Some(page)); - - tui.state.focused_pane = FocusedPane::Searches; - press(&mut tui, KeyCode::Char('G')); - assert_eq!(tui.state.searches_table_state.selected(), Some(0)); - press(&mut tui, KeyCode::Char('g')); - assert_eq!(tui.state.searches_table_state.selected(), Some(0)); - assert_eq!(tui.state.searches.len(), 1, "g and G only move"); - } - - #[test] - fn clicking_where_a_hidden_pane_was_focuses_whatever_is_there_now() { - let mut tui = furnished_tui(); - let _ = screen_of(&mut tui); - let searches = tui.state.searches_pane_area.expect("laid out"); - let inside = ratatui::crossterm::event::MouseEvent { - kind: ratatui::crossterm::event::MouseEventKind::Down( - ratatui::crossterm::event::MouseButton::Left, - ), - column: searches.x + 1, - row: searches.y + 1, - modifiers: KeyModifiers::NONE, - }; - tui.handle_mouse_event(inside); - assert_eq!(tui.state.focused_pane, FocusedPane::Searches); - - press(&mut tui, KeyCode::Char('w')); - let _ = screen_of(&mut tui); - tui.handle_mouse_event(inside); - assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); - } - - #[test] - fn the_info_pane_describes_the_highlighted_result() { - let mut tui = results_tui(3); - tui.state.results_items[1].filename = - "@@x\\Music\\Album\\Second Track.mp3".to_string(); - tui.state.results_items[1].bitrate = Some(320); - tui.state.results_table_state.select(Some(1)); - - let screen = screen_of(&mut tui); - assert!(screen.contains("@@x/Music/Album"), "{screen}"); - assert!(screen.contains("Bitrate"), "{screen}"); - - tui.state.focused_pane = FocusedPane::Downloads; - let screen = screen_of(&mut tui); - assert!(!screen.contains("@@x/Music/Album"), "{screen}"); - } -} +mod tests; diff --git a/soulseek-rs/src/ui/main_tui/render/mod.rs b/soulseek-rs/src/ui/main_tui/render/mod.rs new file mode 100644 index 0000000..bf16054 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/render/mod.rs @@ -0,0 +1,509 @@ +mod shortcuts; + +use super::MainTui; +use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView}; +use crate::ui::panes::{ + InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane, + render_download_info_pane, render_downloads_pane, render_results_pane, + render_rooms_pane, render_searches_pane, selected_transfer, +}; +use crate::ui::{ + GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask, + pack_shortcuts, pane_block, plain_title, primary_style, + render_download_stats, warning_style, +}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Layout, Position, Rect}, + style::Modifier, + text::{Line, Span}, + widgets::Paragraph, +}; +impl MainTui { + /// Draw the whole window. + pub fn render(&mut self, frame: &mut Frame) { + let shortcuts = self.shortcut_rows(frame.area().width); + let mut constraints = vec![ + Constraint::Length(3), // Status bar + Constraint::Fill(1), // Main content + ]; + if self.state.command_bar_active { + constraints.push(Constraint::Length(3)); // Command bar + } + constraints.push(Constraint::Length(shortcuts.len() as u16 + 2)); + let main_chunks = Layout::vertical(constraints).split(frame.area()); + + let daemon = self.client.daemon_endpoint(); + render_download_stats( + frame, + main_chunks[0], + &self.state.downloads, + self.state.active_downloads_count, + daemon.as_deref(), + ); + + self.render_content(frame, main_chunks[1]); + + // Render command bar if active (vim-style, above shortcuts) + if self.state.command_bar_active { + self.render_command_bar(frame, main_chunks[2]); + self.render_shortcuts(frame, main_chunks[3], shortcuts); + } else { + self.render_shortcuts(frame, main_chunks[2], shortcuts); + } + + self.render_overlays(frame); + } + + /// Results across the top, where a long file name has the whole width; + /// Searches, Downloads and Info in a row underneath. A hidden pane drops + /// out of its row and the rest widen; a zoomed one has the area alone. + fn render_content(&mut self, frame: &mut Frame, area: Rect) { + // Only what is drawn can be clicked. + self.state.searches_pane_area = None; + self.state.results_pane_area = None; + self.state.downloads_pane_area = None; + self.state.info_pane_area = None; + self.state.hsplit_divider = None; + self.state.vsplit_dividers.clear(); + self.state.row_area = None; + + if self.state.layout.zoomed { + self.render_pane(frame, area, self.state.focused_pane); + return; + } + self.state.content_area = Some(area); + + // Transfers carry the most columns, so they get the most width. + let row_panes: Vec<(FocusedPane, u16)> = + [(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)] + .into_iter() + .filter(|(pane, _)| self.state.layout.is_visible(*pane)) + .collect(); + + let row_area = if self.state.layout.is_visible(FocusedPane::Results) { + // With only Info left in the row, the results deserve more of + // the height. + let row_weight = if row_panes.is_empty() { 1 } else { 2 }; + let top = match self.state.layout.top_height { + Some(rows) => Constraint::Length(shrink(rows, area.height)), + None => Constraint::Fill(3), + }; + let [top, bottom] = + Layout::vertical([top, Constraint::Fill(row_weight)]) + .areas(area); + // The two touching border rows between the halves; either can + // be dragged. + self.state.hsplit_divider = Some(Rect::new( + area.x, + top.y.saturating_add(top.height).saturating_sub(1), + area.width, + 2, + )); + self.render_pane(frame, top, FocusedPane::Results); + bottom + } else { + area + }; + self.state.row_area = Some(row_area); + + let mut constraints: Vec = row_panes + .iter() + .map(|(pane, weight)| { + let dragged = match *pane { + FocusedPane::Searches => self.state.layout.searches_width, + FocusedPane::Downloads => self.state.layout.downloads_width, + FocusedPane::Results => None, + }; + match dragged { + Some(cols) => { + Constraint::Length(shrink(cols, row_area.width)) + } + None => Constraint::Fill(*weight), + } + }) + .collect(); + constraints.push(Constraint::Fill(6)); // Info + let chunks = Layout::horizontal(constraints).split(row_area); + for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { + // The seam at this pane's right edge: its border column and the + // next pane's, both draggable. + self.state.vsplit_dividers.push(( + *pane, + Rect::new( + chunk.x.saturating_add(chunk.width).saturating_sub(1), + row_area.y, + 2, + row_area.height, + ), + )); + self.render_pane(frame, *chunk, *pane); + } + self.state.info_pane_area = Some(chunks[row_panes.len()]); + self.render_info_pane(frame, chunks[row_panes.len()]); + } + + fn render_pane( + &mut self, + frame: &mut Frame, + area: Rect, + pane: FocusedPane, + ) { + let focused = self.state.focused_pane == pane; + match pane { + FocusedPane::Searches => { + self.state.searches_pane_area = Some(area); + render_searches_pane( + frame, + area, + &self.state.searches, + &mut self.state.searches_table_state, + focused, + self.state.searches_query_offset, + ); + } + FocusedPane::Results => { + self.state.results_pane_area = Some(area); + self.render_results_pane(frame, area, focused); + } + FocusedPane::Downloads => { + self.state.downloads_pane_area = Some(area); + render_downloads_pane( + frame, + area, + &self.state.downloads, + &self.state.uploads, + &mut self.state.downloads_table_state, + focused, + self.state.downloads_name_offset, + ); + } + } + } + + fn render_results_pane( + &mut self, + frame: &mut Frame, + area: Rect, + focused: bool, + ) { + // When a filter is active the rendered rows are a subset, so the pane + // also needs the mapping back to unfiltered indices to render the + // selection checkboxes correctly. + let (results_items, results_original_indices) = + if self.state.results_filter_query.is_empty() { + (&self.state.results_items, None) + } else { + ( + &self.state.results_filtered_items, + Some(self.state.results_filtered_indices.as_slice()), + ) + }; + + let active_search_query = self + .state + .selected_search_index + .and_then(|idx| self.state.searches.get(idx)) + .map(|search| search.query.as_str()); + + render_results_pane( + frame, + area, + ResultsPaneParams { + items: results_items, + table_state: &mut self.state.results_table_state, + selected_indices: &self.state.results_selected_indices, + original_indices: results_original_indices, + filter_query: &self.state.results_filter_query, + is_filtering: self.state.results_is_filtering, + focused, + active_search_query, + name_offset: self.state.results_name_offset, + }, + ); + } + + /// The Info pane follows the focus: the highlighted result, or the + /// highlighted transfer. + fn render_info_pane(&self, frame: &mut Frame, area: Rect) { + let selected = if self.state.focused_pane == FocusedPane::Results { + self.highlighted_result().map(InfoSubject::Result) + } else { + selected_transfer( + self.state.downloads_table_state.selected(), + &self.state.downloads, + &self.state.uploads, + ) + }; + render_download_info_pane( + frame, + area, + selected, + matches!( + self.state.focused_pane, + FocusedPane::Results | FocusedPane::Downloads + ), + ); + } + fn render_overlays(&mut self, frame: &mut Frame) { + // Messages inbox overlays everything when open. + if self.state.show_messages { + self.render_messages_popup(frame); + } + + // Browse tree overlays everything when open. + if self.state.show_browse && !self.state.browse.is_empty() { + let area = centered_rect(80, 80, frame.area()); + self.state.popup_area = Some(area); + frame.render_widget(ratatui::widgets::Clear, area); + render_browse_pane( + frame, + area, + &self.state.browse, + &mut self.state.browse_table_state, + self.spinner_state, + &self.state.downloads, + ); + } + + // Settings popup overlays everything when open. + if self.state.settings.is_some() { + self.render_settings_popup(frame); + } + + // Chat rooms overlay everything when open. + if self.state.show_rooms { + let area = centered_rect(85, 80, frame.area()); + self.state.popup_area = Some(area); + frame.render_widget(ratatui::widgets::Clear, area); + render_rooms_pane( + frame, + area, + &mut self.state.rooms, + &mut self.state.rooms_list_table_state, + ); + } + + // The keys list only opens from the main view, so it is on top of + // nothing; drawn last so that stays true if that ever changes. + if self.state.show_help { + self.render_help_popup(frame); + } + } + + fn render_settings_popup(&self, frame: &mut Frame) { + use crate::models::{SettingsMode, SettingsRow}; + let Some(settings) = self.state.settings.as_ref() else { + return; + }; + let area = centered_rect(70, 60, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + + let selected = settings.selected_row(); + let mut lines: Vec = Vec::new(); + let entry = |on: bool, label: &str, value: String| { + Line::from(vec![ + Span::styled(if on { "> " } else { " " }, accent_style()), + Span::styled(label.to_string(), dimmed_style()), + Span::styled(value, primary_style()), + ]) + }; + let typing = |label: &str, value: String| { + Line::from(vec![ + Span::styled("> ", accent_style()), + Span::styled(label.to_string(), dimmed_style()), + Span::styled(value, primary_style()), + Span::styled(GLYPH_CURSOR, accent_style()), + ]) + }; + let section = |name: &str| Line::styled(name.to_string(), info_style()); + + // One column of labels, padded here rather than by hand in each + // string. + let field = |label: &str, value: String| { + entry(false, &format!(" {label:<14}"), value) + }; + let account = &settings.account; + lines.push(section("Account")); + lines.push(field("Signed in as", account.username.clone())); + lines.push(field("Sharing", account.shares.clone())); + lines.push(field( + "Reachable on", + account.listen_port.map_or_else( + || "not accepting connections".to_string(), + |port| format!("port {port}"), + ), + )); + if let Some(daemon) = &account.daemon { + lines.push(field("Daemon", daemon.clone())); + } + lines.push(Line::from("")); + + lines.push(match settings.mode { + SettingsMode::NewPassword => { + typing("New password: ", mask(&settings.input)) + } + SettingsMode::RepeatPassword(_) => { + typing("Repeat it: ", mask(&settings.input)) + } + _ => entry( + selected == SettingsRow::ChangePassword, + "Change passwordโ€ฆ", + String::new(), + ), + }); + if settings.mode == SettingsMode::ConfirmingLogout { + lines.push(entry( + true, + &format!("Log out of {}? ", account.username), + "y / n".to_string(), + )); + } else if settings.can_log_out() { + lines.push(entry( + selected == SettingsRow::Logout, + "Log outโ€ฆ", + String::new(), + )); + } + lines.push(Line::from("")); + + lines.push(section("Folders")); + lines.push(if settings.mode == SettingsMode::EditingDownloadDir { + typing("Download folder: ", settings.input.clone()) + } else { + entry( + selected == SettingsRow::DownloadDir, + "Download folder: ", + settings.download_dir.clone(), + ) + }); + lines.push(Line::styled( + format!("Shared folders ({}):", settings.share_dirs.len()), + dimmed_style(), + )); + for (i, dir) in settings.share_dirs.iter().enumerate() { + lines.push(entry( + selected == SettingsRow::Share(i), + "", + dir.clone(), + )); + } + if settings.share_dirs.is_empty() { + lines.push(Line::styled( + " (nothing shared โ€” press 'a' to add a folder)", + dimmed_style(), + )); + } + if settings.mode == SettingsMode::AddingShare { + lines.push(typing(" Add share: ", settings.input.clone())); + } + if let Some(status) = &settings.status { + lines.push(Line::from("")); + lines.push(Line::styled(status.clone(), warning_style())); + } + + let block = pane_block(true).title(plain_title("Settings", true)); + frame.render_widget( + ratatui::widgets::Paragraph::new(lines) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: false }), + area, + ); + } + + fn render_messages_popup(&mut self, frame: &mut Frame) { + // The per-conversation chat box supersedes the old flat message list; + // it renders the messages and the compose line itself. + let area = centered_rect(70, 60, frame.area()); + frame.render_widget(ratatui::widgets::Clear, area); + self.state.popup_area = Some(area); + let own = self.client.username(); + render_chat_pane(frame, area, &mut self.state, &own); + } + fn render_command_bar(&self, frame: &mut Frame, area: Rect) { + let prefix = match self.state.command_bar_mode { + CommandBarMode::Search => "search: ", + CommandBarMode::Message => "message (to: recipient text): ", + CommandBarMode::Browse => "browse user: ", + }; + let block = pane_block(true); + // Derive from the block's inner rect so borders and padding stay in + // one place; the cursor follows whatever the pane reserves. + let inner = block.inner(area); + let prefix_width = prefix.chars().count() as u16; + let input_width = inner.width.saturating_sub(prefix_width); + let (visible_input, cursor_column) = visible_input_at_cursor( + &self.state.command_bar_input, + self.state.command_bar_cursor_position, + input_width, + ); + let command_line = Line::from(vec![ + Span::styled(prefix, accent_style()), + Span::styled(visible_input, primary_style()), + ]); + + frame.render_widget(Paragraph::new(command_line).block(block), area); + + if inner.width > 0 && inner.height > 0 { + let cursor_x = inner + .x + .saturating_add(prefix_width) + .saturating_add(cursor_column) + .min(inner.x.saturating_add(inner.width.saturating_sub(1))); + frame.set_cursor_position(Position::new(cursor_x, inner.y)); + } + } +} + +/// A dragged pane size kept inside the space the terminal now offers: never +/// below [`MIN_PANE`], and never so big that what shares the split with it +/// drops below `MIN_PANE` too. +pub(super) fn shrink(dragged: u16, total: u16) -> u16 { + let max = total.saturating_sub(MIN_PANE).max(MIN_PANE); + dragged.max(MIN_PANE).min(max) +} + +/// The smallest pane a drag can leave anyone: a border and a row or column +/// of content each side. +pub(super) const MIN_PANE: u16 = 3; +/// A `Rect` centered within `area`, sized to the given percentages. +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::vertical([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::horizontal([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} +fn visible_input_at_cursor( + input: &str, + cursor_position: usize, + width: u16, +) -> (String, u16) { + if width == 0 { + return (String::new(), 0); + } + + let cursor_position = input.floor_char_boundary(cursor_position); + let cursor_character_index = input[..cursor_position].chars().count(); + let max_cursor_column = usize::from(width.saturating_sub(1)); + let start_character_index = + cursor_character_index.saturating_sub(max_cursor_column); + + let visible_input = input + .chars() + .skip(start_character_index) + .take(usize::from(width)) + .collect(); + let cursor_column = cursor_character_index + .saturating_sub(start_character_index) + .min(max_cursor_column); + + (visible_input, cursor_column as u16) +} diff --git a/soulseek-rs/src/ui/main_tui/render.rs b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs similarity index 50% rename from soulseek-rs/src/ui/main_tui/render.rs rename to soulseek-rs/src/ui/main_tui/render/shortcuts.rs index 166283c..3c96557 100644 --- a/soulseek-rs/src/ui/main_tui/render.rs +++ b/soulseek-rs/src/ui/main_tui/render/shortcuts.rs @@ -1,21 +1,15 @@ -use super::MainTui; -use crate::models::{CommandBarMode, FocusedPane, RoomsView}; -use crate::ui::panes::{ - InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane, - render_download_info_pane, render_downloads_pane, render_results_pane, - render_rooms_pane, render_searches_pane, selected_transfer, -}; -use crate::ui::{ +//! The keys the window advertises: the shortcut bar along the foot, the `?` +//! overlay behind it, and the unread badge that rides in the bar. +//! +//! A child module of `render`, so it still reaches the window's state and +//! `render`'s own helpers directly; only what `render` calls back into is +//! widened to `pub(super)`. + +use super::{ + Alignment, CommandBarMode, Constraint, FocusedPane, Frame, Layout, Line, + MainTui, Modifier, PaneLayout, Paragraph, Rect, RoomsView, Span, accent_style, body_style, dimmed_style, info_style, pack_shortcuts, - pane_block, plain_title, primary_style, render_download_stats, - warning_style, -}; -use ratatui::{ - Frame, - layout::{Alignment, Constraint, Layout, Position, Rect}, - style::Modifier, - text::{Line, Span}, - widgets::Paragraph, + pane_block, plain_title, warning_style, }; /// The keys overlay, grouped by where a key applies. Two columns, because a @@ -29,7 +23,9 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ ("z", "zoom the focused pane"), ("w", "hide the focused pane"), ("Esc", "leave zoom"), + ("W", "every pane back, zoom off, sizes reset"), ("click", "focus a pane"), + ("drag", "resize panes by their borders"), ], ), ( @@ -37,6 +33,7 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ &[ ("s", "search the network"), ("b", "browse a user's files"), + ("B", "browse what you share yourself"), ("m", "compose a private message"), ("i", "inbox"), ("o", "settings"), @@ -55,6 +52,14 @@ const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ ("0 / $", "its start / its end"), ], ), + ( + "Settings popup", + &[ + ("Enter", "change the password, log out, edit the folder"), + ("a / d", "add / remove a shared folder"), + ("r", "re-index the shares"), + ], + ), ]; const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ @@ -62,6 +67,7 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ "Searches", &[ ("Enter", "show its results"), + ("S", "run the search again"), ("d", "remove the search"), ("C", "clear every search"), ("c", "chat rooms"), @@ -86,6 +92,8 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ ("r", "retry a failed one"), ("d", "delete a queued or finished one"), ("c", "clear every finished one"), + ("C", "clear every one, cancelling the live ones"), + ("b", "browse that user"), ], ), ( @@ -95,200 +103,83 @@ const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ ("^u ^d", "half a page of them"), ("Home End / g G", "oldest / newest, or first / last row"), ("Tab / Shift-Tab", "next room, chat or user"), + ("/", "filter the messages, the room list or the tree"), + ("u", "filter a room's member list"), + ], + ), + ( + "Browse popup", + &[ + ("โ† โ†’ / h l", "close / open a folder, or step out / in"), + ("J K", "next / previous folder"), + ("H L", "close / open every folder"), + ("/", "filter by path, Enter keeps it, Esc clears"), + ("Enter", "open a folder, or download a file"), + ("d", "download a file, or a folder's files"), + ("r", "ask again after a timeout, re-index your own"), + ("o", "shared folders (your own tab)"), ], ), ]; -impl MainTui { - pub(super) fn render(&mut self, frame: &mut Frame) { - let shortcuts = self.shortcut_rows(frame.area().width); - let mut constraints = vec![ - Constraint::Length(3), // Status bar - Constraint::Fill(1), // Main content - ]; - if self.state.command_bar_active { - constraints.push(Constraint::Length(3)); // Command bar +/// What the bar offers while a filter is being typed, wherever that is, +/// with the keys that still move around there. +fn filter_keys( + moves: &[(&'static str, &'static str)], +) -> Vec<(&'static str, &'static str)> { + let mut keys = vec![("Type", "filter")]; + keys.extend_from_slice(moves); + keys.extend([("Enter", "keep filter"), ("Esc", "clear filter")]); + keys +} + +/// What the bar offers in the settings popup: the keys that always apply, +/// plus what Enter does on the row the selection rests on. +fn settings_shortcuts( + settings: &crate::models::SettingsState, +) -> Vec<(&'static str, &'static str)> { + use crate::models::{SettingsMode, SettingsRow}; + match settings.mode { + SettingsMode::ConfirmingLogout => { + vec![("y", "log out"), ("n/Esc", "stay")] } - constraints.push(Constraint::Length(shortcuts.len() as u16 + 2)); - let main_chunks = Layout::vertical(constraints).split(frame.area()); - - let daemon = self.client.daemon_endpoint(); - render_download_stats( - frame, - main_chunks[0], - &self.state.downloads, - self.state.active_downloads_count, - daemon.as_deref(), - ); - - self.render_content(frame, main_chunks[1]); - - // Render command bar if active (vim-style, above shortcuts) - if self.state.command_bar_active { - self.render_command_bar(frame, main_chunks[2]); - self.render_shortcuts(frame, main_chunks[3], shortcuts); - } else { - self.render_shortcuts(frame, main_chunks[2], shortcuts); + SettingsMode::NewPassword => { + vec![ + ("Type", "password"), + ("Enter", "repeat it"), + ("Esc", "cancel"), + ] } - - self.render_overlays(frame); - } - - /// Results across the top, where a long file name has the whole width; - /// Searches, Downloads and Info in a row underneath. A hidden pane drops - /// out of its row and the rest widen; a zoomed one has the area alone. - fn render_content(&mut self, frame: &mut Frame, area: Rect) { - // Only what is drawn can be clicked. - self.state.searches_pane_area = None; - self.state.results_pane_area = None; - self.state.downloads_pane_area = None; - - if self.state.layout.zoomed { - self.render_pane(frame, area, self.state.focused_pane); - return; + SettingsMode::RepeatPassword(_) => { + vec![ + ("Type", "password"), + ("Enter", "change it"), + ("Esc", "cancel"), + ] } - - // Transfers carry the most columns, so they get the most width. - let row_panes: Vec<(FocusedPane, u16)> = - [(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)] - .into_iter() - .filter(|(pane, _)| self.state.layout.is_visible(*pane)) - .collect(); - - let row_area = if self.state.layout.is_visible(FocusedPane::Results) { - // With only Info left in the row, the results deserve more of - // the height. - let row_weight = if row_panes.is_empty() { 1 } else { 2 }; - let [top, bottom] = Layout::vertical([ - Constraint::Fill(3), - Constraint::Fill(row_weight), - ]) - .areas(area); - self.render_pane(frame, top, FocusedPane::Results); - bottom - } else { - area - }; - - let mut constraints: Vec = row_panes - .iter() - .map(|(_, weight)| Constraint::Fill(*weight)) - .collect(); - constraints.push(Constraint::Fill(6)); // Info - let chunks = Layout::horizontal(constraints).split(row_area); - for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { - self.render_pane(frame, *chunk, *pane); + SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => { + vec![("Type", "path"), ("Enter", "save"), ("Esc", "cancel")] } - self.render_info_pane(frame, chunks[row_panes.len()]); - } - - fn render_pane( - &mut self, - frame: &mut Frame, - area: Rect, - pane: FocusedPane, - ) { - let focused = self.state.focused_pane == pane; - match pane { - FocusedPane::Searches => { - self.state.searches_pane_area = Some(area); - render_searches_pane( - frame, - area, - &self.state.searches, - &mut self.state.searches_table_state, - focused, - self.state.searches_query_offset, - ); - } - FocusedPane::Results => { - self.state.results_pane_area = Some(area); - self.render_results_pane(frame, area, focused); - } - FocusedPane::Downloads => { - self.state.downloads_pane_area = Some(area); - render_downloads_pane( - frame, - area, - &self.state.downloads, - &self.state.uploads, - &mut self.state.downloads_table_state, - focused, - self.state.downloads_name_offset, - ); - } - } - } - - fn render_results_pane( - &mut self, - frame: &mut Frame, - area: Rect, - focused: bool, - ) { - // When a filter is active the rendered rows are a subset, so the pane - // also needs the mapping back to unfiltered indices to render the - // selection checkboxes correctly. - let (results_items, results_original_indices) = - if self.state.results_filter_query.is_empty() { - (&self.state.results_items, None) - } else { - ( - &self.state.results_filtered_items, - Some(self.state.results_filtered_indices.as_slice()), - ) - }; - - let active_search_query = self - .state - .selected_search_index - .and_then(|idx| self.state.searches.get(idx)) - .map(|search| search.query.as_str()); - - render_results_pane( - frame, - area, - ResultsPaneParams { - items: results_items, - table_state: &mut self.state.results_table_state, - selected_indices: &self.state.results_selected_indices, - original_indices: results_original_indices, - filter_query: &self.state.results_filter_query, - is_filtering: self.state.results_is_filtering, - focused, - active_search_query, - name_offset: self.state.results_name_offset, + SettingsMode::Navigate => vec![ + ("โ†‘โ†“", "move"), + match settings.selected_row() { + SettingsRow::ChangePassword => ("Enter", "change password"), + SettingsRow::Logout => ("Enter", "log out"), + SettingsRow::DownloadDir => ("Enter", "edit folder"), + SettingsRow::Share(_) => ("d", "remove share"), }, - ); - } - - /// The Info pane follows the focus: the highlighted result, or the - /// highlighted transfer. - fn render_info_pane(&self, frame: &mut Frame, area: Rect) { - let selected = if self.state.focused_pane == FocusedPane::Results { - self.highlighted_result().map(InfoSubject::Result) - } else { - selected_transfer( - self.state.downloads_table_state.selected(), - &self.state.downloads, - &self.state.uploads, - ) - }; - render_download_info_pane( - frame, - area, - selected, - matches!( - self.state.focused_pane, - FocusedPane::Results | FocusedPane::Downloads - ), - ); + ("a", "add share"), + ("r", "re-index"), + ("Esc", "close"), + ], } +} +impl MainTui { /// The keys list, two columns side by side where the terminal is wide /// enough and one on top of the other where it is not, scrolling when /// even that is taller than the window. - fn render_help_popup(&mut self, frame: &mut Frame) { + pub(super) fn render_help_popup(&mut self, frame: &mut Frame) { let screen = frame.area(); // Borders and padding on either side of the text. let frame_width = 4; @@ -332,128 +223,6 @@ impl MainTui { } } - fn render_overlays(&mut self, frame: &mut Frame) { - // Messages inbox overlays everything when open. - if self.state.show_messages { - self.render_messages_popup(frame); - } - - // Browse tree overlays everything when open. - if self.state.show_browse && !self.state.browse.is_empty() { - let area = centered_rect(80, 80, frame.area()); - self.state.popup_area = Some(area); - frame.render_widget(ratatui::widgets::Clear, area); - render_browse_pane( - frame, - area, - &self.state.browse, - &mut self.state.browse_table_state, - self.spinner_state, - ); - } - - // Settings popup overlays everything when open. - if self.state.settings.is_some() { - self.render_settings_popup(frame); - } - - // Chat rooms overlay everything when open. - if self.state.show_rooms { - let area = centered_rect(85, 80, frame.area()); - self.state.popup_area = Some(area); - frame.render_widget(ratatui::widgets::Clear, area); - render_rooms_pane( - frame, - area, - &mut self.state.rooms, - &mut self.state.rooms_list_table_state, - ); - } - - // The keys list only opens from the main view, so it is on top of - // nothing; drawn last so that stays true if that ever changes. - if self.state.show_help { - self.render_help_popup(frame); - } - } - - fn render_settings_popup(&self, frame: &mut Frame) { - use crate::models::SettingsMode; - let Some(settings) = self.state.settings.as_ref() else { - return; - }; - let area = centered_rect(70, 60, frame.area()); - frame.render_widget(ratatui::widgets::Clear, area); - - let mut lines: Vec = Vec::new(); - let marker = |selected: bool| if selected { "> " } else { " " }; - let entry = |selected: bool, label: &str, value: String| { - Line::from(vec![ - Span::styled(marker(selected).to_string(), accent_style()), - Span::styled(label.to_string(), dimmed_style()), - Span::styled(value, primary_style()), - ]) - }; - - lines.push(if settings.mode == SettingsMode::EditingDownloadDir { - Line::from(vec![ - Span::styled("> ", accent_style()), - Span::styled("Download folder: ", dimmed_style()), - Span::styled(settings.input.clone(), primary_style()), - Span::styled("โ–", accent_style()), - ]) - } else { - entry( - settings.selected == 0, - "Download folder: ", - settings.download_dir.clone(), - ) - }); - lines.push(Line::from("")); - lines.push(Line::styled( - format!("Shared folders ({}):", settings.share_dirs.len()), - dimmed_style(), - )); - for (i, dir) in settings.share_dirs.iter().enumerate() { - lines.push(entry(settings.selected == i + 1, "", dir.clone())); - } - if settings.share_dirs.is_empty() { - lines.push(Line::styled( - " (nothing shared โ€” press 'a' to add a folder)", - dimmed_style(), - )); - } - if settings.mode == SettingsMode::AddingShare { - lines.push(Line::from(vec![ - Span::styled(" Add share: ", dimmed_style()), - Span::styled(settings.input.clone(), primary_style()), - Span::styled("โ–", accent_style()), - ])); - } - if let Some(status) = &settings.status { - lines.push(Line::from("")); - lines.push(Line::styled(status.clone(), warning_style())); - } - - let block = pane_block(true).title(plain_title("Settings", true)); - frame.render_widget( - ratatui::widgets::Paragraph::new(lines) - .block(block) - .wrap(ratatui::widgets::Wrap { trim: false }), - area, - ); - } - - fn render_messages_popup(&mut self, frame: &mut Frame) { - // The per-conversation chat box supersedes the old flat message list; - // it renders the messages and the compose line itself. - let area = centered_rect(70, 60, frame.area()); - frame.render_widget(ratatui::widgets::Clear, area); - self.state.popup_area = Some(area); - let own = self.client.username(); - render_chat_pane(frame, area, &mut self.state, &own); - } - /// Context shortcuts for the chat-rooms popup. fn rooms_shortcuts(&self) -> Vec<(&'static str, &'static str)> { if self.state.rooms.composing { @@ -481,10 +250,15 @@ impl MainTui { ] } } + RoomsView::Chat if self.state.rooms.filtering.is_some() => { + filter_keys(&[("PgUp/PgDn", "scroll"), ("โ†‘โ†“", "pick user")]) + } RoomsView::Chat => vec![ ("Enter", "say"), ("PgUp/PgDn", "scroll"), ("โ†‘โ†“", "pick user"), + ("/", "find in chat"), + ("u", "find user"), ("b", "browse user"), ("m", "message user"), ("Tab", "switch room"), @@ -497,7 +271,7 @@ impl MainTui { /// The keys every pane shares for moving between and resizing panes, /// ending the bar the same way wherever the focus sits. fn pane_shortcuts(&self) -> Vec<(&'static str, &'static str)> { - vec![ + let mut keys = vec![ ("Tab/1-3", "pane"), ( "z", @@ -508,9 +282,13 @@ impl MainTui { }, ), ("w", "hide"), - ("?", "keys"), - ("q", "quit"), - ] + ]; + // Only once a pane is hidden or zoomed is there a layout to reset. + if self.state.layout != PaneLayout::default() { + keys.push(("W", "reset layout")); + } + keys.extend([("?", "keys"), ("q", "quit")]); + keys } /// Which shortcuts the bar offers, which is purely a question of what is @@ -518,15 +296,8 @@ impl MainTui { fn shortcuts(&self) -> Vec<(&'static str, &'static str)> { if self.state.show_help { vec![("?/Esc", "close")] - } else if self.state.settings.is_some() { - vec![ - ("โ†‘โ†“", "move"), - ("Enter/e", "edit download dir"), - ("a", "add share"), - ("d", "remove share"), - ("r", "re-index"), - ("Esc", "close"), - ] + } else if let Some(settings) = self.state.settings.as_ref() { + settings_shortcuts(settings) } else if self.state.show_messages { if self.state.chat_composing { vec![ @@ -534,11 +305,14 @@ impl MainTui { ("Enter", "send"), ("Esc", "stop typing"), ] + } else if self.state.chat_filtering { + filter_keys(&[("PgUp/PgDn", "scroll")]) } else { vec![ ("Enter", "type"), ("PgUp/PgDn", "scroll"), ("โ†‘โ†“/Tab", "switch chat"), + ("/", "find"), ("m", "new chat"), ("i/Esc", "close"), ] @@ -546,17 +320,40 @@ impl MainTui { } else if self.state.show_rooms { self.rooms_shortcuts() } else if self.state.show_browse { - vec![ - ("โ†‘โ†“", "move"), - ("PgUp/PgDn", "page"), - ("โ†’โ†", "expand/collapse"), - ("Enter", "open/download"), - ("d", "download folder"), - ("Tab", "switch user"), - ("r", "retry"), - ("w", "close tab"), - ("Esc", "hide"), - ] + if self.state.browse.active_tab().is_some_and(|b| b.filtering) { + // Typing goes to the filter, so no letter keys are on offer. + filter_keys(&[("โ†‘/โ†“", "navigate"), ("PgUp/PgDn", "page")]) + } else { + // Nothing of ours is downloadable from ourselves, and `r` + // re-scans the disk instead of re-asking a peer. + let acts = if self.state.browse.active_is_own() { + [ + ("Enter", "open folder"), + ("r", "re-index"), + ("o", "shared folders"), + ] + } else { + [ + ("Enter", "open/download"), + ("d", "download folder"), + ("r", "retry"), + ] + }; + let mut keys = vec![ + ("โ†‘โ†“", "move"), + ("J/K", "next/prev folder"), + ("โ†’โ†", "expand/collapse"), + ("H/L", "collapse/expand all"), + ("/", "filter"), + ]; + keys.extend(acts); + keys.extend([ + ("Tab", "switch user"), + ("w", "close tab"), + ("Esc", "hide"), + ]); + keys + } } else if self.state.command_bar_active { match self.state.command_bar_mode { CommandBarMode::Search => vec![ @@ -581,19 +378,15 @@ impl MainTui { && self.state.focused_pane == FocusedPane::Results { // Typing goes to the filter, so no letter keys are on offer here. - vec![ - ("Type", "filter"), - ("โ†‘/โ†“", "navigate"), - ("PgUp/PgDn", "page"), - ("Enter", "keep filter"), - ("Esc", "clear filter"), - ] + filter_keys(&[("โ†‘/โ†“", "navigate"), ("PgUp/PgDn", "page")]) } else { let mut keys = match self.state.focused_pane { FocusedPane::Searches => vec![ ("s", "search"), + ("S", "search again"), ("Enter", "results"), ("d", "remove"), + ("C", "clear all"), ("m", "message"), ("i", "inbox"), ("c", "chat"), @@ -616,6 +409,7 @@ impl MainTui { ("r", "retry failed"), ("d", "delete queued/done"), ("c", "clear finished"), + ("C", "clear all"), ("b", "browse user"), ("h/l", "scroll name"), ], @@ -627,7 +421,7 @@ impl MainTui { /// The legend packed into rows for a window `width` wide, so a narrow /// terminal grows the bar instead of cutting keys off its right edge. - fn shortcut_rows(&self, width: u16) -> Vec> { + pub(super) fn shortcut_rows(&self, width: u16) -> Vec> { // Borders and padding, and the unread badge's column when it shows. let badge = self.unread_badge().width() as u16; let reserved = 4 + if badge > 0 { badge + 1 } else { 0 }; @@ -646,7 +440,7 @@ impl MainTui { ) } - fn render_shortcuts( + pub(super) fn render_shortcuts( &self, frame: &mut Frame, area: Rect, @@ -686,40 +480,6 @@ impl MainTui { ); } } - - fn render_command_bar(&self, frame: &mut Frame, area: Rect) { - let prefix = match self.state.command_bar_mode { - CommandBarMode::Search => "search: ", - CommandBarMode::Message => "message (to: recipient text): ", - CommandBarMode::Browse => "browse user: ", - }; - let block = pane_block(true); - // Derive from the block's inner rect so borders and padding stay in - // one place; the cursor follows whatever the pane reserves. - let inner = block.inner(area); - let prefix_width = prefix.chars().count() as u16; - let input_width = inner.width.saturating_sub(prefix_width); - let (visible_input, cursor_column) = visible_input_at_cursor( - &self.state.command_bar_input, - self.state.command_bar_cursor_position, - input_width, - ); - let command_line = Line::from(vec![ - Span::styled(prefix, accent_style()), - Span::styled(visible_input, primary_style()), - ]); - - frame.render_widget(Paragraph::new(command_line).block(block), area); - - if inner.width > 0 && inner.height > 0 { - let cursor_x = inner - .x - .saturating_add(prefix_width) - .saturating_add(cursor_column) - .min(inner.x.saturating_add(inner.width.saturating_sub(1))); - frame.set_cursor_position(Position::new(cursor_x, inner.y)); - } - } } /// Columns between the two halves of the keys list. @@ -776,22 +536,6 @@ fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec> { lines } -/// A `Rect` centered within `area`, sized to the given percentages. -fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { - let vertical = Layout::vertical([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(area); - Layout::horizontal([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(vertical[1])[1] -} - /// Build the shortcuts-bar unread badge: `โœ‰ N` for unread private messages and /// `๐Ÿ’ฌ N` for unread chat-room messages. Returns an empty line when nothing is /// unread so the caller can skip it. @@ -824,33 +568,6 @@ fn unread_indicator( Line::from(Span::styled(format!("โŸจ {} โŸฉ", counts.join(" ")), style)) } -fn visible_input_at_cursor( - input: &str, - cursor_position: usize, - width: u16, -) -> (String, u16) { - if width == 0 { - return (String::new(), 0); - } - - let cursor_position = input.floor_char_boundary(cursor_position); - let cursor_character_index = input[..cursor_position].chars().count(); - let max_cursor_column = usize::from(width.saturating_sub(1)); - let start_character_index = - cursor_character_index.saturating_sub(max_cursor_column); - - let visible_input = input - .chars() - .skip(start_character_index) - .take(usize::from(width)) - .collect(); - let cursor_column = cursor_character_index - .saturating_sub(start_character_index) - .min(max_cursor_column); - - (visible_input, cursor_column as u16) -} - #[cfg(test)] mod tests { use super::unread_indicator; diff --git a/soulseek-rs/src/ui/main_tui/rooms.rs b/soulseek-rs/src/ui/main_tui/rooms.rs index 86c033a..a5cc811 100644 --- a/soulseek-rs/src/ui/main_tui/rooms.rs +++ b/soulseek-rs/src/ui/main_tui/rooms.rs @@ -1,6 +1,6 @@ use super::MainTui; -use super::input::{jumped, list_jump, scroll_log}; -use crate::models::RoomsView; +use super::input::{FilterEdit, edit_filter, jumped, list_jump, scroll_log}; +use crate::models::{ChatFilter, RoomsView}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; impl MainTui { @@ -30,6 +30,14 @@ impl MainTui { self.handle_room_filter_input(key); return; } + // So does typing a filter over the log or the member list; the + // other keys still move around. + if self.state.rooms.view == RoomsView::Chat + && self.state.rooms.filtering.is_some() + && self.handle_chat_filter_input(key) + { + return; + } match self.state.rooms.view { RoomsView::List => self.handle_rooms_list_input(key), @@ -39,7 +47,7 @@ impl MainTui { fn handle_rooms_list_input(&mut self, key: KeyEvent) { let len = self.state.rooms.filtered_rooms().len(); - if let Some(jump) = list_jump(key, self.popup_page()) { + if let Some(jump) = list_jump(key, self.popup_page(1)) { self.state.rooms.list_selected = jumped(self.state.rooms.list_selected, len, jump); return; @@ -80,25 +88,18 @@ impl MainTui { } fn handle_room_filter_input(&mut self, key: KeyEvent) { - match key.code { - KeyCode::Esc => { - self.state.rooms.list_is_filtering = false; - self.state.rooms.list_filter.clear(); + let Some(edit) = edit_filter(&mut self.state.rooms.list_filter, key) + else { + return self.handle_rooms_list_input(key); + }; + match edit { + FilterEdit::Changed | FilterEdit::Cleared => { self.state.rooms.list_selected = 0; } - KeyCode::Enter => { - self.state.rooms.list_is_filtering = false; - self.join_selected_room(); - } - KeyCode::Char(c) => { - self.state.rooms.list_filter.push(c); - self.state.rooms.list_selected = 0; - } - KeyCode::Backspace => { - self.state.rooms.list_filter.pop(); - self.state.rooms.list_selected = 0; - } - _ => {} + FilterEdit::Kept => self.join_selected_room(), + } + if edit != FilterEdit::Changed { + self.state.rooms.list_is_filtering = false; } } @@ -113,11 +114,24 @@ impl MainTui { if key.modifiers.contains(KeyModifiers::CONTROL) { return; } + let filtered = !self.state.rooms.log_filter.is_empty() + || !self.state.rooms.user_filter.is_empty(); match key.code { KeyCode::Char('q') => self.state.show_rooms = false, + // Esc peels back one level: the filters first, then the list. + KeyCode::Esc if filtered => { + self.state.rooms.set_log_filter(String::new()); + self.state.rooms.set_user_filter(String::new()); + } KeyCode::Esc | KeyCode::Char('l') => { self.state.rooms.view = RoomsView::List; } + KeyCode::Char('/') => { + self.state.rooms.filtering = Some(ChatFilter::Log); + } + KeyCode::Char('u') => { + self.state.rooms.filtering = Some(ChatFilter::Users); + } KeyCode::Tab => self.state.rooms.next_tab(), KeyCode::BackTab => self.state.rooms.prev_tab(), KeyCode::Char('x') => self.leave_active_room(), @@ -137,6 +151,33 @@ impl MainTui { } } + /// The keys that edit whichever filter is being typed, the log's or the + /// member list's. Says whether `key` was one of them. + fn handle_chat_filter_input(&mut self, key: KeyEvent) -> bool { + let rooms = &mut self.state.rooms; + let over_log = rooms.filtering == Some(ChatFilter::Log); + let mut filter = if over_log { + rooms.log_filter.clone() + } else { + rooms.user_filter.clone() + }; + let Some(edit) = edit_filter(&mut filter, key) else { + return false; + }; + if edit != FilterEdit::Changed { + rooms.filtering = None; + } + // Keeping a filter leaves the view where it is. + if edit != FilterEdit::Kept { + if over_log { + rooms.set_log_filter(filter); + } else { + rooms.set_user_filter(filter); + } + } + true + } + /// Browse the shares of the member highlighted in the active room. Closes /// the rooms popup so the browse tree is the sole overlay. fn browse_selected_room_user(&mut self) { diff --git a/soulseek-rs/src/ui/main_tui/search.rs b/soulseek-rs/src/ui/main_tui/search.rs index d574d11..e2892cf 100644 --- a/soulseek-rs/src/ui/main_tui/search.rs +++ b/soulseek-rs/src/ui/main_tui/search.rs @@ -5,11 +5,18 @@ use crate::models::{ }; use chrono::Local; use std::{ - sync::{Arc, atomic::AtomicBool}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, thread, time::Instant, }; +/// How long after a search went out a repeat of `S` counts as the same +/// press: key autorepeat is faster than anyone means to search. +const RERUN_HOLD: std::time::Duration = std::time::Duration::from_secs(1); + impl MainTui { pub(super) fn remove_search_at_index(&mut self, index: usize) { if index >= self.state.searches.len() { @@ -18,9 +25,7 @@ impl MainTui { // Cancel the search if it's active if let Some(search) = self.state.searches.get(index) { - search - .cancel_flag - .store(true, std::sync::atomic::Ordering::Relaxed); + search.cancel_flag.store(true, Ordering::Relaxed); } // Check if we're removing the currently active search @@ -66,9 +71,7 @@ impl MainTui { pub(super) fn clear_all_searches(&mut self) { // Cancel all active searches for search in &self.state.searches { - search - .cancel_flag - .store(true, std::sync::atomic::Ordering::Relaxed); + search.cancel_flag.store(true, Ordering::Relaxed); } // And drop them from the session, or the next sync brings every one @@ -205,6 +208,34 @@ impl MainTui { } pub(super) fn start_search(&mut self, query: String) { + let entry = self.launch_search(query); + self.state.searches.push(entry); + self.show_search(self.state.searches.len() - 1); + } + + /// Run the search at `index` again, in its row: a restored query comes + /// back with nothing under it, and this saves typing it out. A run still + /// collecting is stopped first, or two threads would fill one set. + pub(super) fn rerun_search_at_index(&mut self, index: usize) { + let Some(previous) = self.state.searches.get(index) else { + return; + }; + // A held key repeats, and every repeat would put another search on + // the wire: a run that only just went out is left to run. + if previous.status == SearchStatus::Active + && previous.start_time.elapsed() < RERUN_HOLD + { + return; + } + previous.cancel_flag.store(true, Ordering::Relaxed); + self.state.searches[index] = self.launch_search(previous.query.clone()); + self.show_search(index); + } + + /// Put `query` on the wire and hand back the entry that tracks it. The + /// session keys results by query, so a repeat starts its set afresh, and + /// the entry's empty results match that. + fn launch_search(&self, query: String) -> SearchEntry { let cancel_flag = Arc::new(AtomicBool::new(false)); let search_entry = SearchEntry { query: query.clone(), @@ -216,41 +247,29 @@ impl MainTui { cancel_flag: cancel_flag.clone(), }; - self.state.searches.push(search_entry); - let search_index = self.state.searches.len() - 1; - self.state.searches_table_state.select(Some(search_index)); - - // Make this search the active one - self.state.selected_search_index = Some(search_index); - - // Initialize results display (empty at first) - self.state.results_items.clear(); - self.state.results_filtered_items.clear(); - self.state.results_filtered_indices.clear(); - self.state.results_selected_indices.clear(); - self.state.results_table_state.select(Some(0)); - self.state.results_name_offset = 0; - - // Switch focus to Results pane, bringing it back if it was hidden - self.state.focus_pane(FocusedPane::Results); - let client = self.client.clone(); let timeout = self.search_timeout; + // Results are polled in update_search_results, not returned here. thread::spawn(move || { - match client.search_with_cancel( - &query, - timeout, - Some(cancel_flag.clone()), - ) { - Ok(_results) => { - // Results will be polled in update_search_results - } - Err(e) => { - soulseek_rs::warn!("Search failed: {e}"); - } + if let Err(e) = + client.search_with_cancel(&query, timeout, Some(cancel_flag)) + { + soulseek_rs::warn!("Search failed: {e}"); } }); + + search_entry + } + + /// Make the search at `index` the one the Results pane shows, starting + /// from an empty display, filter and all, for what is about to arrive. + fn show_search(&mut self, index: usize) { + self.clear_results_pane(); + self.state.searches_table_state.select(Some(index)); + self.state.selected_search_index = Some(index); + self.state.results_table_state.select(Some(0)); + self.state.focus_pane(FocusedPane::Results); } /// Show the searches the session knows about, not just this window's. diff --git a/soulseek-rs/src/ui/main_tui/settings.rs b/soulseek-rs/src/ui/main_tui/settings.rs index 42c60a3..40717bc 100644 --- a/soulseek-rs/src/ui/main_tui/settings.rs +++ b/soulseek-rs/src/ui/main_tui/settings.rs @@ -3,12 +3,21 @@ //! socket when the session is a daemon's). use super::MainTui; -use crate::models::{SettingsAction, SettingsState}; +use crate::models::{AccountInfo, SettingsAction, SettingsState}; +use crate::persist::secret::KeyringStore; use ratatui::crossterm::event::KeyEvent; impl MainTui { pub(super) fn open_settings(&mut self) { + // Read once here, not while drawing: see `AccountInfo`. + let account = AccountInfo { + username: self.client.username(), + shares: self.share_counts(), + listen_port: self.client.listen_port(), + daemon: self.client.daemon_endpoint(), + }; self.state.settings = Some(SettingsState::new( + account, self.download_dir.clone(), self.client.shared_directories(), )); @@ -23,9 +32,28 @@ impl MainTui { SettingsAction::Close => self.state.settings = None, SettingsAction::Apply => self.apply_settings(), SettingsAction::Reindex => self.reindex_shares(), + SettingsAction::ChangePassword(password) => { + self.change_password(&password); + } + SettingsAction::Logout => { + self.state.exit = Some(crate::models::TuiExit::Logout); + } } } + fn change_password(&mut self, password: &str) { + let status = match crate::persist::secret::change_password( + self.client.as_ref(), + &KeyringStore, + password, + ) { + Ok(None) => "Password changed".into(), + Ok(Some(e)) => format!("Password changed, but not stored: {e}"), + Err(e) => format!("Could not change it: {e}"), + }; + self.set_settings_status(status); + } + /// Persist the edited settings and apply them live. /// /// Attached to a daemon, both folders name paths on the *daemon's* @@ -71,9 +99,10 @@ impl MainTui { let mut status = if !refused.is_empty() { format!("Could not apply {}", refused.join("; ")) } else if dropped > 0 { + self.refresh_share_counts(); format!("Applied ({dropped} invalid path(s) ignored)") } else { - format!("Applied ยท sharing {}", self.share_counts()) + format!("Applied ยท sharing {}", self.refresh_share_counts()) }; if dir_applied { self.download_dir.clone_from(&download_dir); @@ -99,6 +128,17 @@ impl MainTui { } } self.set_settings_status(status); + self.load_own_shares(); + } + + /// Bring the account block's share count back in step, and hand the same + /// count to the caller: counting is a round trip worth making once. + fn refresh_share_counts(&mut self) -> String { + let counts = self.share_counts(); + if let Some(settings) = self.state.settings.as_mut() { + settings.account.shares.clone_from(&counts); + } + counts } fn set_settings_status(&mut self, status: String) { @@ -108,16 +148,22 @@ impl MainTui { } /// Re-scan the current share paths (picks up files changed on disk). - fn reindex_shares(&mut self) { + // ponytail: the scan runs on the UI thread, so a very large share set + // freezes the window for its duration. Move it to a worker if that bites. + pub(super) fn reindex_shares(&mut self) { let dirs = self.client.shared_directories(); let result = self.client.set_shared_directories(dirs); - let counts = self.share_counts(); - if let Some(settings) = self.state.settings.as_mut() { - settings.status = Some(match result { + // Only the settings pane shows these, and `r` in the share view + // calls us with the pane closed โ€” where the counts would be a + // round-trip against the daemon for a string nobody reads. + if self.state.settings.is_some() { + let counts = self.refresh_share_counts(); + self.set_settings_status(match result { Ok(()) => format!("Re-indexed ยท sharing {counts}"), Err(e) => format!("Re-index failed: {e}"), }); } + self.load_own_shares(); } fn share_counts(&self) -> String { diff --git a/soulseek-rs/src/ui/main_tui/tests/browse.rs b/soulseek-rs/src/ui/main_tui/tests/browse.rs new file mode 100644 index 0000000..f50e313 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/browse.rs @@ -0,0 +1,172 @@ +//! The browse popup, over a peer's shares and over our own. + +use super::*; + +#[test] +fn shift_b_shows_what_this_session_shares_and_r_re_indexes_it() { + let session = Arc::new(TalkativeSession::default()); + *session.listing.lock().expect("not poisoned") = + vec![soulseek_rs::SharedDirectory { + name: "Music\\Album".to_string(), + files: vec![soulseek_rs::SharedFileEntry { + name: "track.mp3".to_string(), + size: 4096, + attributes: Vec::new(), + }], + }]; + let mut tui = attach(session.clone()); + + press(&mut tui, KeyCode::Char('B')); + assert!(tui.state.show_browse, "the popup opens"); + let tab = tui.state.browse.active_tab().expect("tab"); + assert!(tab.own, "on this session's own index"); + assert_eq!(tab.username, "tester"); + assert_eq!(tab.file_count, 1); + let screen = screen_of(&mut tui); + assert!(screen.contains("My shares"), "{screen}"); + press(&mut tui, KeyCode::Char('L')); + let screen = screen_of(&mut tui); + assert!( + screen.contains("Album") && screen.contains("track.mp3"), + "the virtual path a peer would see, folder by folder: {screen}" + ); + + // Nothing here is downloadable from ourselves. + press(&mut tui, KeyCode::Char('d')); + assert!(tui.state.downloads.is_empty()); + + // `r` re-scans the disk rather than re-asking a peer, and the tab + // picks up what the fresh scan found. + session.listing.lock().expect("not poisoned").clear(); + press(&mut tui, KeyCode::Char('r')); + assert!( + session.shares_set.lock().expect("not poisoned").is_some(), + "the session was asked to re-index" + ); + assert_eq!( + tui.state.browse.active_tab().expect("tab").file_count, + 0, + "the view follows the new index" + ); + + // A second B focuses the tab it already opened. + press(&mut tui, KeyCode::Esc); + press(&mut tui, KeyCode::Char('B')); + assert_eq!(tui.state.browse.tabs.len(), 1); +} + +#[test] +fn o_leaves_the_share_view_for_the_folders_that_feed_it() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + press(&mut tui, KeyCode::Char('B')); + press(&mut tui, KeyCode::Char('o')); + assert!(!tui.state.show_browse, "the popup steps aside"); + assert!(tui.state.settings.is_some(), "for the settings pane"); +} + +#[test] +fn page_keys_walk_the_browse_tree_and_ctrl_d_does_not_download() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session); + browsed(&mut tui, 40); + let rows = tui.state.browse.active_tab().expect("tab").rows().len(); + assert_eq!(rows, 41, "the folder and its files"); + + press(&mut tui, KeyCode::End); + let selected = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").selected_row + }; + assert_eq!(selected(&tui), 40); + press(&mut tui, KeyCode::Home); + assert_eq!(selected(&tui), 0); + press(&mut tui, KeyCode::PageDown); + let page = selected(&tui); + assert!(page > 1 && page < 40, "a page of rows: {page}"); + ctrl(&mut tui, 'd'); + assert!(selected(&tui) > page, "ctrl-d pages"); + assert!( + tui.state.downloads.is_empty() + && tui.state.downloads_receiver_channel.is_none(), + "and does not download the folder" + ); + press(&mut tui, KeyCode::Char('G')); + assert_eq!(selected(&tui), 40); + assert!(tui.state.show_browse, "still open"); +} + +#[test] +fn folder_keys_and_the_filter_move_through_a_browsed_tree() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + tui.state.browse.open("bob"); + let listing = vec![ + soulseek_rs::SharedDirectory { + name: "music\\alpha".to_string(), + files: vec![ + soulseek_rs::SharedFileEntry { + name: "a1.mp3".to_string(), + size: 1, + attributes: Vec::new(), + }, + soulseek_rs::SharedFileEntry { + name: "a2.flac".to_string(), + size: 2, + attributes: Vec::new(), + }, + ], + }, + soulseek_rs::SharedDirectory { + name: "music\\beta".to_string(), + files: vec![soulseek_rs::SharedFileEntry { + name: "b1.mp3".to_string(), + size: 3, + attributes: Vec::new(), + }], + }, + ]; + tui.state + .browse + .active_tab_mut() + .expect("tab") + .load(&listing); + tui.state.show_browse = true; + let _ = screen_of(&mut tui); + let selected = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").selected_row + }; + let rows = |tui: &MainTui| { + tui.state.browse.active_tab().expect("tab").rows().len() + }; + + press(&mut tui, KeyCode::Char('L')); + assert_eq!(rows(&tui), 6, "every folder open"); + press(&mut tui, KeyCode::Char('J')); + press(&mut tui, KeyCode::Char('J')); + assert_eq!(selected(&tui), 4, "beta, skipping alpha's files"); + press(&mut tui, KeyCode::Char('K')); + assert_eq!(selected(&tui), 1, "back to alpha"); + press(&mut tui, KeyCode::Char('H')); + assert_eq!(rows(&tui), 1, "just music"); + + press(&mut tui, KeyCode::Char('/')); + for c in "flac".chars() { + press(&mut tui, KeyCode::Char(c)); + } + assert_eq!(rows(&tui), 3, "music, alpha, a2.flac"); + let screen = screen_of(&mut tui); + assert!(screen.contains("filter: flac_"), "{screen}"); + ctrl(&mut tui, 'd'); + assert_eq!( + tui.state.browse.active_tab().expect("tab").filter(), + "flac", + "a control chord pages, it does not type" + ); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Char('j')); + press(&mut tui, KeyCode::Char('j')); + assert_eq!(selected(&tui), 2, "j moves again once the filter is kept"); + press(&mut tui, KeyCode::Esc); + assert_eq!(rows(&tui), 1, "Esc clears the filter first"); + assert!(tui.state.show_browse); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_browse, "then hides the popup"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/layout.rs b/soulseek-rs/src/ui/main_tui/tests/layout.rs new file mode 100644 index 0000000..8381637 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/layout.rs @@ -0,0 +1,386 @@ +//! The window itself: which panes are up, how big, and what the chrome +//! says while they move. + +use super::*; + +#[test] +fn the_results_pane_has_the_whole_width_to_itself() { + let mut tui = furnished_tui(); + let screen = screen_of(&mut tui); + + let results = row_with(&screen, "[2] Results"); + assert!(results.starts_with('โ•ญ'), "{results}"); + assert!(results.trim_end().ends_with('โ•ฎ'), "{results}"); + assert!(!results.contains("[1]"), "nothing beside it: {results}"); + + // The other three share the row underneath, in this order. + let row = row_with(&screen, "[1] Searches"); + let searches = row.find("[1]").expect("searches"); + let downloads = row.find("[3]").expect("downloads"); + let info = row.find("[Info]").expect("info"); + assert!(searches < downloads && downloads < info, "{row}"); +} + +#[test] +fn tab_and_shift_tab_walk_the_panes_in_legend_order() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + press(&mut tui, KeyCode::BackTab); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); +} + +#[test] +fn w_hides_the_focused_pane_and_its_number_brings_it_back() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + + press(&mut tui, KeyCode::Char('w')); + + let screen = screen_of(&mut tui); + assert!(!screen.contains("[1] Searches"), "{screen}"); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(screen.contains("[3] Downloads"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + assert_eq!(tui.state.searches_pane_area, None, "nothing to click"); + // The transfers widen into the space. + let row = row_with(&screen, "[3] Downloads"); + assert!(row.starts_with('โ•ญ'), "{row}"); + + press(&mut tui, KeyCode::Char('1')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("[1] Searches"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); +} + +#[test] +fn hiding_the_results_gives_the_row_the_whole_height() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + let screen = screen_of(&mut tui); + assert!(!screen.contains("[2] Results"), "{screen}"); + // The row now starts right under the status bar. + let status = screen + .lines() + .position(|line| line.contains("Status")) + .expect("status bar"); + let row = screen + .lines() + .position(|line| line.contains("[1] Searches")) + .expect("searches"); + assert_eq!(row, status + 3, "{screen}"); +} + +#[test] +fn the_last_pane_standing_cannot_be_hidden() { + // Results goes, then Downloads; Searches is what is left, and a + // third press leaves it there. + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('w')); + let screen = screen_of(&mut tui); + assert!(screen.contains("[1] Searches"), "{screen}"); + assert!(!screen.contains("[3] Downloads"), "{screen}"); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); +} + +#[test] +fn z_zooms_the_focused_pane_and_esc_or_z_leaves_it() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('z')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(!screen.contains("[1] Searches"), "{screen}"); + assert!(!screen.contains("[3] Downloads"), "{screen}"); + assert!(!screen.contains("[Info]"), "{screen}"); + assert!(screen.contains("[z โ†’ unzoom]"), "{screen}"); + + // Tab while zoomed switches which pane fills the window. + press(&mut tui, KeyCode::Tab); + let screen = screen_of(&mut tui); + assert!(screen.contains("[3] Downloads"), "{screen}"); + assert!(!screen.contains("[2] Results"), "{screen}"); + + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.layout.zoomed); + let screen = screen_of(&mut tui); + assert!(screen.contains("[2] Results"), "{screen}"); + assert!(screen.contains("[Info]"), "{screen}"); + + press(&mut tui, KeyCode::Char('z')); + press(&mut tui, KeyCode::Char('z')); + assert!(!tui.state.layout.zoomed, "z toggles"); +} + +#[test] +fn a_search_brings_a_hidden_results_pane_back() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('w')); + assert!(!tui.state.layout.is_visible(FocusedPane::Results)); + + press(&mut tui, KeyCode::Char('s')); + press(&mut tui, KeyCode::Char('x')); + press(&mut tui, KeyCode::Enter); + + assert!(tui.state.layout.is_visible(FocusedPane::Results)); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); +} + +#[test] +fn question_mark_opens_the_keys_list_and_closes_it_again() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('?')); + + let screen = screen_of(&mut tui); + assert!(screen.contains("Keys"), "{screen}"); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); + assert!(screen.contains("clear every finished one"), "{screen}"); + + // Other keys are swallowed while it is open. + press(&mut tui, KeyCode::Char('q')); + assert!(tui.state.exit.is_none(), "q closes the list, not the app"); + assert!(!tui.state.show_help); + + press(&mut tui, KeyCode::Char('?')); + press(&mut tui, KeyCode::Char('w')); + assert!(tui.state.show_help, "w did not reach the panes"); + assert!(tui.state.layout.is_visible(FocusedPane::Results)); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_help); +} + +#[test] +fn on_a_small_terminal_the_keys_list_stacks_and_scrolls() { + let mut tui = furnished_tui(); + press(&mut tui, KeyCode::Char('?')); + + let screen = screen_sized(&mut tui, 80, 24); + let panes = row_with(&screen, "Panes"); + assert!(!panes.contains("Searches"), "one column: {panes}"); + assert!(screen.contains("focus a pane, hidden or not"), "{screen}"); + assert!(!screen.contains("next room, chat or user"), "{screen}"); + + press(&mut tui, KeyCode::End); + let screen = screen_sized(&mut tui, 80, 24); + assert!(screen.contains("next room, chat or user"), "{screen}"); + assert!(!screen.contains("next / previous pane"), "{screen}"); + + press(&mut tui, KeyCode::Home); + let screen = screen_sized(&mut tui, 80, 24); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); + + // A wide window shows both columns at once, nothing to scroll. It + // has to be tall enough to hold the taller column outright; in a + // shorter one End follows the tail and the headings scroll off. + press(&mut tui, KeyCode::End); + let screen = screen_sized(&mut tui, 160, 44); + let panes = row_with(&screen, "Panes"); + assert!(panes.contains("Searches"), "two columns: {panes}"); + assert!(screen.contains("Tab / Shift-Tab"), "{screen}"); +} + +#[test] +fn a_click_where_a_pane_just_hid_does_not_focus_it() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + // Hidden, but not yet redrawn: the old area is still on record. + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Char('w')); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); + tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left, + ), + column: searches.x + 1, + row: searches.y + 1, + modifiers: KeyModifiers::NONE, + }); + assert_eq!(tui.state.focused_pane, FocusedPane::Results); +} + +#[test] +fn the_shortcut_bar_wraps_instead_of_cutting_keys_off() { + let mut tui = furnished_tui(); + let screen = screen_of(&mut tui); + assert!(screen.contains("[q โ†’ quit]"), "{screen}"); + let bar_rows = screen.lines().filter(|line| line.contains(" โ†’ ")).count(); + assert_eq!(bar_rows, 2, "{screen}"); + + let wide = screen_sized(&mut tui, 300, 40); + let bar_rows = wide.lines().filter(|line| line.contains(" โ†’ ")).count(); + assert_eq!(bar_rows, 1, "{wide}"); +} + +#[test] +fn the_searches_pane_lists_its_clear_all_and_rerun_keys() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[C โ†’ clear all]"), "{screen}"); + assert!(screen.contains("[S โ†’ search again]"), "{screen}"); + press(&mut tui, KeyCode::Char('?')); + let screen = screen_of(&mut tui); + assert!(screen.contains("run the search again"), "{screen}"); +} + +#[test] +fn the_downloads_pane_lists_its_clear_everything_key() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Downloads; + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[c โ†’ clear finished]"), "{screen}"); + assert!(screen.contains("[C โ†’ clear all]"), "{screen}"); + press(&mut tui, KeyCode::Char('?')); + let screen = screen_of(&mut tui); + assert!(screen.contains("cancelling the live ones"), "{screen}"); +} + +#[test] +fn shift_w_brings_every_pane_back_and_leaves_zoom() { + let mut tui = furnished_tui(); + tui.state.focused_pane = FocusedPane::Searches; + assert!(!screen_sized(&mut tui, 300, 40).contains("[W โ†’ ")); + press(&mut tui, KeyCode::Char('w')); + press(&mut tui, KeyCode::Char('z')); + assert!(tui.state.layout.zoomed); + assert!(!tui.state.layout.is_visible(FocusedPane::Searches)); + let screen = screen_sized(&mut tui, 300, 40); + assert!(screen.contains("[W โ†’ reset layout]"), "{screen}"); + press(&mut tui, KeyCode::Char('W')); + assert!(!tui.state.layout.zoomed); + assert!( + FocusedPane::ALL + .iter() + .all(|p| tui.state.layout.is_visible(*p)) + ); +} + +#[test] +fn clicking_where_a_hidden_pane_was_focuses_whatever_is_there_now() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + let inside = ratatui::crossterm::event::MouseEvent { + kind: ratatui::crossterm::event::MouseEventKind::Down( + ratatui::crossterm::event::MouseButton::Left, + ), + column: searches.x + 1, + row: searches.y + 1, + modifiers: KeyModifiers::NONE, + }; + tui.handle_mouse_event(inside); + assert_eq!(tui.state.focused_pane, FocusedPane::Searches); + + press(&mut tui, KeyCode::Char('w')); + let _ = screen_of(&mut tui); + tui.handle_mouse_event(inside); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); +} + +#[test] +fn dragging_the_border_under_results_resizes_its_row() { + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let results = tui.state.results_pane_area.expect("laid out"); + let divider = tui.state.hsplit_divider.expect("a divider"); + let before = results.height; + let focused = tui.state.focused_pane; + + // Two drags: the row shrinks by the rows the pointer moved up. + mouse_drag_row( + &mut tui, + (results.x + 1, divider.y), + (results.x + 1, divider.y - 2), + ); + assert_eq!(tui.state.layout.top_height, Some(before - 2)); + assert_eq!( + tui.state.focused_pane, focused, + "a grab is not a focus click" + ); + + let _ = screen_of(&mut tui); + assert_eq!( + tui.state.results_pane_area.expect("laid out").height, + before - 2 + ); + + // A drag far past the row stops at the smallest pane, not zero. + mouse_drag_row( + &mut tui, + (results.x + 1, divider.y - 2), + (results.x + 1, 0), + ); + assert!(tui.state.layout.top_height.expect("resized") >= 3); + + press(&mut tui, KeyCode::Char('W')); + assert_eq!(tui.state.layout.top_height, None, "W resets the layout"); +} + +#[test] +fn dragging_a_row_panes_right_edge_gives_it_width() { + use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + + let mut tui = furnished_tui(); + let _ = screen_of(&mut tui); + let searches = tui.state.searches_pane_area.expect("laid out"); + let seam = tui + .state + .vsplit_dividers + .iter() + .find(|(pane, _)| *pane == FocusedPane::Searches) + .map(|(_, rect)| *rect) + .expect("a seam beside Searches"); + tui.state.focused_pane = FocusedPane::Downloads; + + mouse_drag_row( + &mut tui, + (seam.x, searches.y + 1), + (seam.x + 4, searches.y + 1), + ); + assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); + assert_eq!(tui.state.focused_pane, FocusedPane::Downloads); + + // The widths are applied, and Info narrows to whatever is left. + let _ = screen_of(&mut tui); + assert_eq!( + tui.state.searches_pane_area.expect("laid out").width, + searches.width + 4 + ); + assert!(tui.state.info_pane_area.expect("laid out").width >= 3); + + // Releasing the button ends the drag: later motions just move the + // pointer. + mouse( + &mut tui, + MouseEventKind::Drag(MouseButton::Left), + seam.x + 40, + searches.y + 1, + ); + assert_eq!(tui.state.layout.searches_width, Some(searches.width + 4)); +} + +#[test] +fn the_info_pane_describes_the_highlighted_result() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = + "@@x\\Music\\Album\\Second Track.mp3".to_string(); + tui.state.results_items[1].bitrate = Some(320); + tui.state.results_table_state.select(Some(1)); + + let screen = screen_of(&mut tui); + assert!(screen.contains("@@x/Music/Album"), "{screen}"); + assert!(screen.contains("Bitrate"), "{screen}"); + + tui.state.focused_pane = FocusedPane::Downloads; + let screen = screen_of(&mut tui); + assert!(!screen.contains("@@x/Music/Album"), "{screen}"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/mod.rs b/soulseek-rs/src/ui/main_tui/tests/mod.rs new file mode 100644 index 0000000..b203767 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/mod.rs @@ -0,0 +1,560 @@ +//! The window driven the way a terminal drives it: keys and mouse events in, +//! a rendered screen out, over a stand-in session. +//! +//! This file is the harness the topic modules below share โ€” the session +//! double, the window constructors, and the press/screen helpers. + +mod browse; +mod layout; +mod navigation; +mod rooms; +mod searches; +mod session; +mod settings; +mod transfers; + +use super::*; + +use crate::daemon::proto::ChatMessageDto; + +use crate::models::{FocusedPane, MessageDirection, RoomsView, SearchStatus}; + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// A stand-in for a daemon: it already holds a conversation, a transfer +/// queue, and a search cache, none of which this window created. +#[derive(Default)] +struct TalkativeSession { + history: Vec, + downloads: Vec, + searches: std::sync::Mutex>, + /// What `try_get_search_results` hands back to the window. + results: std::sync::Mutex>, + /// Every query a window put on the wire, in order. + searched: std::sync::Mutex>, + /// How many times a window asked for the search list. + all_searches_calls: std::sync::atomic::AtomicUsize, + /// How many times a window pulled a full result set across. Against a + /// daemon each pull is the whole set over the socket, so the tests + /// count them. + result_fetches: std::sync::atomic::AtomicUsize, + /// Whether this session is a daemon's. Local sessions do not sync. + shared: bool, + /// What `set_download_directory` was last asked for, if anything. + download_dir_set: std::sync::Mutex>, + /// What `set_shared_directories` was last asked for, if anything. + shares_set: std::sync::Mutex>>, + /// What `change_password` was last asked for, if anything. + password_set: std::sync::Mutex>, + /// The share index this session serves to peers. + listing: std::sync::Mutex>, + download_cancelled: std::sync::Mutex>, + upload_cancelled: std::sync::Mutex>, + download_removed: std::sync::Mutex>, +} + +fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download { + at_status(username, filename, soulseek_rs::DownloadStatus::Queued) +} + +fn completed(username: &str, filename: &str) -> soulseek_rs::types::Download { + at_status(username, filename, soulseek_rs::DownloadStatus::Completed) +} + +fn uploading(username: &str, filename: &str) -> soulseek_rs::types::UploadInfo { + soulseek_rs::types::UploadInfo { + username: username.to_string(), + filename: filename.to_string(), + size: 10, + bytes_sent: 1, + speed_bytes_per_sec: 1.0, + status: soulseek_rs::types::UploadStatus::InProgress, + } +} + +fn at_status( + username: &str, + filename: &str, + status: soulseek_rs::DownloadStatus, +) -> soulseek_rs::types::Download { + let (sender, _) = std::sync::mpsc::channel(); + soulseek_rs::types::Download { + username: username.to_string(), + filename: filename.to_string(), + token: 1, + size: 4096, + download_directory: "/tmp".to_string(), + status, + sender, + queue_position: None, + metadata: soulseek_rs::types::DownloadMetadata::default(), + } +} + +fn key(ch: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE) +} + +impl SessionApi for TalkativeSession { + fn message_history(&self) -> Vec { + self.history.clone() + } + fn daemon_endpoint(&self) -> Option { + self.shared.then(|| "test-daemon".to_string()) + } + fn username(&self) -> String { + "tester".to_string() + } + fn listen_port(&self) -> Option { + None + } + fn session_loss(&self) -> Option { + None + } + fn search_with_cancel( + &self, + query: &str, + _timeout: Duration, + _cancel: Option>, + ) -> soulseek_rs::Result> { + self.searched + .lock() + .expect("not poisoned") + .push(query.to_string()); + Ok(Vec::new()) + } + fn get_search_results(&self, _key: &str) -> Vec { + Vec::new() + } + fn all_searches(&self) -> Vec { + self.all_searches_calls + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.searches.lock().expect("not poisoned").clone() + } + fn forget_search(&self, query: &str) -> bool { + let mut searches = self.searches.lock().expect("not poisoned"); + let before = searches.len(); + searches.retain(|search| search.query != query); + searches.len() != before + } + fn get_search_results_count(&self, _key: &str) -> usize { + 0 + } + fn try_get_search_results( + &self, + _key: &str, + ) -> Option> { + self.result_fetches + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + Some(self.results.lock().expect("not poisoned").clone()) + } + fn start_wishlist_search(&self, _query: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn wishlist_interval(&self) -> Duration { + Duration::from_mins(12) + } + fn download( + &self, + _filename: String, + _username: String, + _size: u64, + _directory: String, + ) -> soulseek_rs::Result<( + soulseek_rs::types::Download, + std::sync::mpsc::Receiver, + )> { + Err(soulseek_rs::SoulseekRs::NotConnected) + } + fn download_with_metadata( + &self, + _filename: String, + _username: String, + _size: u64, + _directory: String, + _metadata: soulseek_rs::types::DownloadMetadata, + ) -> soulseek_rs::Result<( + soulseek_rs::types::Download, + std::sync::mpsc::Receiver, + )> { + Err(soulseek_rs::SoulseekRs::NotConnected) + } + fn get_all_downloads(&self) -> Vec { + self.downloads.clone() + } + fn pause_download(&self, _u: &str, _f: &str) -> bool { + false + } + fn resume_download(&self, _u: &str, _f: &str) -> bool { + false + } + fn remove_queued_download(&self, _u: &str, _f: &str) -> bool { + false + } + fn remove_download(&self, u: &str, f: &str) -> bool { + self.download_removed + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); + true + } + fn uploads(&self) -> Vec { + Vec::new() + } + fn take_upload_events(&self) -> Vec { + Vec::new() + } + fn cancel_upload(&self, u: &str, f: &str) -> bool { + self.upload_cancelled + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); + true + } + fn cancel_download(&self, u: &str, f: &str) -> bool { + self.download_cancelled + .lock() + .expect("not poisoned") + .push((u.to_string(), f.to_string())); + true + } + fn set_upload_slots(&self, _slots: usize) {} + fn check_privileges(&self) -> soulseek_rs::Result<()> { + Ok(()) + } + fn own_privilege_seconds(&self) -> Option { + None + } + fn request_room_list(&self) -> soulseek_rs::Result<()> { + Ok(()) + } + fn join_room(&self, _room: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn leave_room(&self, _room: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn say_in_room( + &self, + _room: &str, + _message: &str, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn room_members(&self, _room: &str) -> Vec { + Vec::new() + } + fn room_member_stats( + &self, + _room: &str, + ) -> Vec { + Vec::new() + } + fn take_room_events(&self) -> Vec { + Vec::new() + } + fn send_private_message( + &self, + _u: &str, + _m: &str, + ) -> soulseek_rs::Result<()> { + Ok(()) + } + fn take_private_messages(&self) -> Vec { + Vec::new() + } + fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn take_browse_result( + &self, + _username: &str, + ) -> Option> { + None + } + fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn watch_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn unwatch_user(&self, _username: &str) -> soulseek_rs::Result<()> { + Ok(()) + } + fn watched_users(&self) -> Vec { + Vec::new() + } + fn user_info(&self, _username: &str) -> Option { + None + } + fn shared_counts(&self) -> (u32, u32) { + (3, 12) + } + fn change_password(&self, password: &str) -> soulseek_rs::Result<()> { + *self.password_set.lock().expect("not poisoned") = + Some(password.to_string()); + Ok(()) + } + fn shared_listing(&self) -> Vec { + self.listing.lock().expect("not poisoned").clone() + } + fn shared_directories(&self) -> Vec { + Vec::new() + } + fn set_shared_directories( + &self, + directories: Vec, + ) -> soulseek_rs::Result<()> { + *self.shares_set.lock().expect("not poisoned") = Some(directories); + Ok(()) + } + fn set_download_directory( + &self, + directory: String, + ) -> soulseek_rs::Result<()> { + *self.download_dir_set.lock().expect("not poisoned") = Some(directory); + Ok(()) + } +} + +fn tui(history: Vec) -> MainTui { + with_session(TalkativeSession { + history, + shared: true, + ..TalkativeSession::default() + }) +} + +fn shared(searches: Vec) -> TalkativeSession { + TalkativeSession { + searches: std::sync::Mutex::new(searches), + shared: true, + ..TalkativeSession::default() + } +} + +fn search_of(query: &str, files: usize, age: u64) -> crate::api::SessionSearch { + crate::api::SessionSearch { + query: query.to_string(), + files, + started_secs_ago: Some(age), + } +} + +fn with_session(session: TalkativeSession) -> MainTui { + attach(Arc::new(session)) +} + +fn attach(session: Arc) -> MainTui { + MainTui::new( + session, + "/tmp".to_string(), + Duration::from_secs(1), + None, + None, + ) +} + +fn files_from_bob(names: &[&str]) -> soulseek_rs::SearchResult { + soulseek_rs::SearchResult { + token: 0, + files: names + .iter() + .map(|name| soulseek_rs::types::File { + username: "bob".to_string(), + name: (*name).to_string(), + size: 42, + attribs: std::collections::HashMap::new(), + }) + .collect(), + slots: 1, + speed: 0, + username: "bob".to_string(), + } +} + +/// An attached window that selected a two-file search and has already +/// ticked past its one necessary fetch. +fn window_that_pulled_two_files() -> (Arc, MainTui) { + let session = Arc::new(TalkativeSession { + results: std::sync::Mutex::new(vec![files_from_bob(&[ + "@@x\\a.mp3", + "@@x\\b.mp3", + ])]), + ..shared(vec![search_of("aphex twin", 2, 9_999)]) + }); + let mut tui = attach(session.clone()); + tui.state.selected_search_index = Some(0); + tui.update_search_results(); + tui.update_search_results(); + (session, tui) +} + +fn results(n: usize) -> Vec { + (0..n) + .map(|i| crate::models::FileDisplayData { + filename: format!("@@x\\Music\\{i:02}.mp3"), + username: "bob".to_string(), + ..Default::default() + }) + .collect() +} + +fn results_tui(n: usize) -> MainTui { + let mut tui = with_session(TalkativeSession::default()); + tui.state.results_items = results(n); + tui.state.focused_pane = FocusedPane::Results; + // 88 cells wide gives the name column 11 cells and the folder 7. + tui.state.results_pane_area = + Some(ratatui::layout::Rect::new(0, 0, 88, 13)); + tui +} + +fn press(tui: &mut MainTui, code: KeyCode) { + tui.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); +} + +fn ctrl(tui: &mut MainTui, ch: char) { + tui.handle_key_event(KeyEvent::new( + KeyCode::Char(ch), + KeyModifiers::CONTROL, + )); +} + +fn selected(tui: &MainTui) -> Option { + tui.state.results_table_state.selected() +} + +fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String { + let backend = ratatui::backend::TestBackend::new(width, height); + let mut terminal = ratatui::Terminal::new(backend).expect("backend"); + terminal.draw(|frame| tui.render(frame)).expect("draw"); + terminal.backend().to_string() +} + +fn screen_of(tui: &mut MainTui) -> String { + screen_sized(tui, 160, 40) +} + +fn mouse( + tui: &mut MainTui, + kind: ratatui::crossterm::event::MouseEventKind, + column: u16, + row: u16, +) { + tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { + kind, + column, + row, + modifiers: KeyModifiers::NONE, + }); +} + +fn mouse_drag_row(tui: &mut MainTui, from: (u16, u16), to: (u16, u16)) { + use ratatui::crossterm::event::{MouseButton, MouseEventKind}; + mouse(tui, MouseEventKind::Down(MouseButton::Left), from.0, from.1); + mouse(tui, MouseEventKind::Drag(MouseButton::Left), to.0, to.1); + mouse(tui, MouseEventKind::Up(MouseButton::Left), to.0, to.1); +} + +/// The screen row carrying a pane's legend, `[2] Results` say, without +/// the quotes the test backend wraps each row in. +fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str { + let line = screen + .lines() + .find(|line| line.contains(needle)) + .unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}")); + let start = line.find('"').map_or(0, |i| i + 1); + let end = line.rfind('"').unwrap_or(line.len()); + &line[start..end] +} + +/// A window with a search, its results, and a transfer, so every pane +/// has something to show. +fn furnished_tui() -> MainTui { + let mut tui = results_tui(3); + tui.state.searches.push(crate::models::SearchEntry { + query: "aphex twin".to_string(), + status: SearchStatus::Completed, + results: results(3), + known_files: 3, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.selected_search_index = Some(0); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "@@x\\Music\\00.mp3"), + receiver: None, + }); + tui +} + +fn browsed(tui: &mut MainTui, files: usize) { + tui.state.browse.open("bob"); + let listing = vec![soulseek_rs::SharedDirectory { + name: "Music".to_string(), + files: (0..files) + .map(|i| soulseek_rs::SharedFileEntry { + name: format!("{i:02}.mp3"), + size: 1, + attributes: Vec::new(), + }) + .collect(), + }]; + tui.state + .browse + .active_tab_mut() + .expect("tab") + .load(&listing); + tui.state.show_browse = true; + let _ = screen_of(tui); +} + +fn in_a_room(tui: &mut MainTui, messages: usize) { + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::Joined { + room: "jazz".to_string(), + users: (0..30).map(|i| format!("user{i:02}")).collect(), + }, + None, + ); + tui.state.rooms.focus_or_open("jazz"); + for i in 0..messages { + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::Message { + room: "jazz".to_string(), + username: "alice".to_string(), + message: format!("line {i:03}"), + }, + Some("jazz"), + ); + } + tui.state.show_rooms = true; + let _ = screen_of(tui); +} + +/// The queries the window has sent so far, once at least `count` of them +/// are out: a search goes to the session from a worker thread, so a test +/// has to give it a moment. +fn searched(session: &TalkativeSession, count: usize) -> Vec { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let seen = session.searched.lock().expect("not poisoned").clone(); + if seen.len() >= count || std::time::Instant::now() > deadline { + return seen; + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +/// A window over last session's search list: the queries came back from +/// disk, with nothing under them. +fn restored(session: Arc, queries: &[&str]) -> MainTui { + let mut tui = attach(session); + let queries: Vec = + queries.iter().map(ToString::to_string).collect(); + restore_searches(&mut tui.state, &queries); + tui.state.focused_pane = FocusedPane::Searches; + tui +} diff --git a/soulseek-rs/src/ui/main_tui/tests/navigation.rs b/soulseek-rs/src/ui/main_tui/tests/navigation.rs new file mode 100644 index 0000000..fca9606 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/navigation.rs @@ -0,0 +1,236 @@ +//! Moving through a list: rows, pages, and the sideways scroll that +//! walks a name too long for its column. + +use super::*; + +#[test] +fn end_and_home_jump_to_the_last_and_first_result() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::End); + assert_eq!(selected(&tui), Some(29)); + press(&mut tui, KeyCode::Home); + assert_eq!(selected(&tui), Some(0)); + press(&mut tui, KeyCode::Char('G')); + assert_eq!(selected(&tui), Some(29)); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(selected(&tui), Some(0)); +} + +#[test] +fn page_keys_move_a_pane_of_rows_and_stop_at_the_edges() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::PageDown); + assert_eq!(selected(&tui), Some(10)); + ctrl(&mut tui, 'f'); + assert_eq!(selected(&tui), Some(20)); + press(&mut tui, KeyCode::PageDown); + assert_eq!(selected(&tui), Some(29), "clamps instead of wrapping"); + press(&mut tui, KeyCode::PageUp); + assert_eq!(selected(&tui), Some(19)); + ctrl(&mut tui, 'b'); + assert_eq!(selected(&tui), Some(9)); + press(&mut tui, KeyCode::PageUp); + assert_eq!(selected(&tui), Some(0)); + assert!( + !tui.state.show_browse && !tui.state.command_bar_active, + "ctrl-b pages up instead of opening browse" + ); +} + +#[test] +fn ctrl_d_and_ctrl_u_move_half_a_pane() { + let mut tui = results_tui(30); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), Some(5)); + ctrl(&mut tui, 'u'); + assert_eq!(selected(&tui), Some(0)); +} + +#[test] +fn navigation_keys_do_nothing_without_results() { + let mut tui = results_tui(0); + tui.state.results_table_state.select(None); + press(&mut tui, KeyCode::End); + press(&mut tui, KeyCode::PageDown); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), None); +} + +#[test] +fn l_and_h_scroll_the_highlighted_name_up_to_its_end() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.results_name_offset, 8); + press(&mut tui, KeyCode::Right); + assert_eq!(tui.state.results_name_offset, 16); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.results_name_offset, 30, "stops at the end"); + press(&mut tui, KeyCode::Char('h')); + assert_eq!(tui.state.results_name_offset, 22); + press(&mut tui, KeyCode::Left); + assert_eq!(tui.state.results_name_offset, 14); + press(&mut tui, KeyCode::Char('0')); + assert_eq!(tui.state.results_name_offset, 0); + press(&mut tui, KeyCode::Char('h')); + assert_eq!(tui.state.results_name_offset, 0); +} + +#[test] +fn scrolling_follows_the_highlighted_row_not_the_longest_one() { + let mut tui = results_tui(3); + tui.state.results_items[0].filename = "short.mp3".to_string(); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_items[2].filename = "short.mp3".to_string(); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 0, "row 0 fits already"); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + tui.state.results_table_state.select(Some(2)); + press(&mut tui, KeyCode::Char('h')); + assert_eq!( + tui.state.results_name_offset, 0, + "a step left lands within the row now highlighted" + ); +} + +#[test] +fn picking_another_search_starts_its_results_unscrolled() { + let mut tui = results_tui(3); + tui.state.results_items[1].filename = "x".repeat(40); + tui.state.results_table_state.select(Some(1)); + press(&mut tui, KeyCode::Char('$')); + assert_eq!(tui.state.results_name_offset, 30); + + tui.state.searches.push(crate::models::SearchEntry { + query: "other".to_string(), + status: SearchStatus::Active, + results: results(2), + known_files: 2, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.searches_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Enter); + assert_eq!(tui.state.results_name_offset, 0); +} + +#[test] +fn ctrl_keys_are_ignored_outside_the_results_pane() { + let mut tui = results_tui(3); + tui.state.searches.push(crate::models::SearchEntry { + query: "keep me".to_string(), + status: SearchStatus::Active, + results: Vec::new(), + known_files: 0, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + tui.state.searches_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Searches; + ctrl(&mut tui, 'd'); + assert_eq!(tui.state.searches.len(), 1, "ctrl-d is not d"); + ctrl(&mut tui, 'b'); + assert!(!tui.state.command_bar_active, "ctrl-b is not b"); + ctrl(&mut tui, 'q'); + assert!(tui.state.exit.is_none(), "ctrl-q is not q"); +} + +#[test] +fn ctrl_keys_page_while_a_filter_is_being_typed() { + let mut tui = results_tui(30); + press(&mut tui, KeyCode::Char('/')); + press(&mut tui, KeyCode::Char('m')); + ctrl(&mut tui, 'd'); + assert_eq!(selected(&tui), Some(5)); + assert_eq!(tui.state.results_filter_query, "m", "no d appended"); + press(&mut tui, KeyCode::End); + assert_eq!(selected(&tui), Some(29)); +} + +#[test] +fn space_after_a_filter_that_matches_nothing_does_not_panic() { + let mut tui = results_tui(3); + press(&mut tui, KeyCode::Char('/')); + press(&mut tui, KeyCode::Char('z')); + press(&mut tui, KeyCode::Enter); + assert!(tui.state.results_filtered_items.is_empty()); + press(&mut tui, KeyCode::Char(' ')); + assert!(tui.state.results_selected_indices.is_empty()); +} + +#[test] +fn l_and_h_scroll_a_transfer_name_and_a_query_too() { + let mut tui = furnished_tui(); + tui.state.downloads[0].download.filename = "y".repeat(120); + tui.state.searches[0].query = "z".repeat(60); + let _ = screen_of(&mut tui); + + tui.state.focused_pane = FocusedPane::Downloads; + press(&mut tui, KeyCode::Char('l')); + assert_eq!(tui.state.downloads_name_offset, 8); + press(&mut tui, KeyCode::Char('$')); + let end = tui.state.downloads_name_offset; + assert!(end > 8, "the end is past a step: {end}"); + let screen = screen_of(&mut tui); + assert!(row_with(&screen, "โ€ฆyyyy").contains("โ€ฆyyyy"), "{screen}"); + press(&mut tui, KeyCode::Char('0')); + assert_eq!(tui.state.downloads_name_offset, 0); + + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Right); + assert_eq!(tui.state.searches_query_offset, 8); + press(&mut tui, KeyCode::End); + assert_eq!( + tui.state.searches_table_state.selected(), + Some(0), + "End is still a row key, $ is the name key" + ); + press(&mut tui, KeyCode::Char('$')); + assert!(tui.state.searches_query_offset > 8); + let screen = screen_of(&mut tui); + assert!(screen.contains("โ€ฆzzzz"), "{screen}"); + assert!( + tui.state.results_name_offset == 0, + "each list scrolls on its own" + ); +} + +#[test] +fn page_keys_work_in_the_transfers_and_searches_lists_too() { + let mut tui = furnished_tui(); + for i in 0..30 { + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", &format!("{i}.mp3")), + receiver: None, + }); + } + tui.state.focused_pane = FocusedPane::Downloads; + tui.state.downloads_table_state.select(Some(0)); + let _ = screen_of(&mut tui); // lays the panes out, which sets the page size + + press(&mut tui, KeyCode::End); + assert_eq!(tui.state.downloads_table_state.selected(), Some(30)); + press(&mut tui, KeyCode::Home); + assert_eq!(tui.state.downloads_table_state.selected(), Some(0)); + press(&mut tui, KeyCode::PageDown); + let page = tui.state.downloads_table_state.selected().expect("row"); + assert!(page > 1 && page < 30, "a pane of rows, not one: {page}"); + ctrl(&mut tui, 'd'); + assert_eq!(tui.state.downloads.len(), 31, "ctrl-d pages, d deletes"); + assert!(tui.state.downloads_table_state.selected() > Some(page)); + + tui.state.focused_pane = FocusedPane::Searches; + press(&mut tui, KeyCode::Char('G')); + assert_eq!(tui.state.searches_table_state.selected(), Some(0)); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(tui.state.searches_table_state.selected(), Some(0)); + assert_eq!(tui.state.searches.len(), 1, "g and G only move"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/rooms.rs b/soulseek-rs/src/ui/main_tui/tests/rooms.rs new file mode 100644 index 0000000..4cd7af0 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/rooms.rs @@ -0,0 +1,164 @@ +//! Rooms and conversations: their logs, their lists, and their filters. + +use super::*; + +#[test] +fn slash_and_u_filter_a_rooms_log_and_its_members() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + in_a_room(&mut tui, 30); + press(&mut tui, KeyCode::Char('u')); + for c in "user2".chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Char('j')); + assert_eq!( + tui.state.rooms.selected_user().as_deref(), + Some("user21"), + "j moves within the matching members" + ); + let screen = screen_of(&mut tui); + assert!(screen.contains("Users 10/30"), "{screen}"); + + press(&mut tui, KeyCode::Char('/')); + for c in "line 01".chars() { + press(&mut tui, KeyCode::Char(c)); + } + let screen = screen_of(&mut tui); + assert!(screen.contains("line 010"), "{screen}"); + assert!(!screen.contains("line 029"), "{screen}"); + assert!(screen.contains("filter: line 01_"), "{screen}"); + ctrl(&mut tui, 'u'); + assert_eq!(tui.state.rooms.log_filter, "line 01", "^u scrolls"); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Esc); + assert!(tui.state.rooms.log_filter.is_empty()); + assert!(tui.state.rooms.user_filter.is_empty()); + assert_eq!(tui.state.rooms.view, RoomsView::Chat, "still in the room"); + press(&mut tui, KeyCode::Esc); + assert_eq!(tui.state.rooms.view, RoomsView::List, "then the list"); +} + +#[test] +fn slash_filters_the_open_conversation() { + let mut tui = attach(Arc::new(TalkativeSession::default())); + for text in ["hello there", "see you", "hello again"] { + tui.state.messages.push(crate::models::ChatMessage { + direction: MessageDirection::Incoming, + peer: "bob".to_string(), + text: text.to_string(), + at: chrono::Local::now(), + }); + } + tui.state.chat_peer = Some("bob".to_string()); + tui.state.show_messages = true; + press(&mut tui, KeyCode::Char('/')); + for c in "hello".chars() { + press(&mut tui, KeyCode::Char(c)); + } + let screen = screen_of(&mut tui); + assert!(screen.contains("hello again"), "{screen}"); + assert!(!screen.contains("see you"), "{screen}"); + press(&mut tui, KeyCode::Enter); + press(&mut tui, KeyCode::Esc); + assert!(tui.state.chat_filter.is_empty() && tui.state.show_messages); + press(&mut tui, KeyCode::Esc); + assert!(!tui.state.show_messages); +} + +#[test] +fn page_up_scrolls_a_room_log_back_and_end_returns_to_the_newest() { + let mut tui = with_session(TalkativeSession::default()); + in_a_room(&mut tui, 120); + + let screen = screen_of(&mut tui); + assert!(screen.contains("line 119"), "tails by default: {screen}"); + assert!(!screen.contains("line 000"), "{screen}"); + + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(!screen.contains("line 119"), "scrolled back: {screen}"); + + press(&mut tui, KeyCode::Home); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 000"), "the oldest: {screen}"); + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 000"), "clamped at the top: {screen}"); + + press(&mut tui, KeyCode::End); + let screen = screen_of(&mut tui); + assert!(screen.contains("line 119"), "{screen}"); + + // The member list keeps its own keys, and ctrl-x is not x. + press(&mut tui, KeyCode::Down); + assert_eq!(tui.state.rooms.user_selected, 1); + ctrl(&mut tui, 'x'); + assert_eq!(tui.state.rooms.open.len(), 1, "still in the room"); +} + +#[test] +fn page_keys_move_through_the_room_list() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.rooms.apply_event( + soulseek_rs::RoomEvent::List( + (0..60) + .map(|i| soulseek_rs::types::RoomInfo { + name: format!("room{i:02}"), + user_count: 60 - i, + }) + .collect(), + ), + None, + ); + tui.state.show_rooms = true; + let _ = screen_of(&mut tui); + + press(&mut tui, KeyCode::End); + assert_eq!(tui.state.rooms.list_selected, 59); + press(&mut tui, KeyCode::PageUp); + assert!(tui.state.rooms.list_selected < 59); + press(&mut tui, KeyCode::Char('g')); + assert_eq!(tui.state.rooms.list_selected, 0); + ctrl(&mut tui, 'f'); + assert!(tui.state.rooms.list_selected > 1); +} + +#[test] +fn page_up_scrolls_a_conversation_and_switching_chats_resets_it() { + let mut tui = tui((0..80) + .map(|i| ChatMessageDto { + peer: "bob".into(), + outgoing: i % 2 == 0, + text: format!("msg {i:03}"), + at: i, + }) + .chain(std::iter::once(ChatMessageDto { + peer: "carol".into(), + outgoing: false, + text: "hello".into(), + at: 100, + })) + .collect()); + tui.state.chat_peer = Some("bob".to_string()); + tui.state.show_messages = true; + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 079"), "{screen}"); + + press(&mut tui, KeyCode::PageUp); + let screen = screen_of(&mut tui); + assert!(!screen.contains("msg 079"), "scrolled back: {screen}"); + press(&mut tui, KeyCode::Char('g')); + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 000"), "{screen}"); + + press(&mut tui, KeyCode::Tab); + assert_eq!(tui.state.active_chat_peer(), Some("carol")); + assert!( + tui.state.chat_view.following(), + "a fresh chat starts at its end" + ); + press(&mut tui, KeyCode::BackTab); + let screen = screen_of(&mut tui); + assert!(screen.contains("msg 079"), "{screen}"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/searches.rs b/soulseek-rs/src/ui/main_tui/tests/searches.rs new file mode 100644 index 0000000..0d40e7f --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/searches.rs @@ -0,0 +1,89 @@ +//! Running a search the list already holds. + +use super::*; + +#[test] +fn shift_s_runs_the_highlighted_search_again_in_its_place() { + // The list survives a restart, but a query on it was only ever a + // reminder: seeing what the network has now meant typing it again. + let session = Arc::new(TalkativeSession::default()); + let mut tui = + restored(session.clone(), &["boards of canada", "aphex twin"]); + tui.state.searches_table_state.select(Some(1)); + + press(&mut tui, KeyCode::Char('S')); + + assert_eq!( + searched(&session, 1), + vec!["aphex twin".to_string()], + "the highlighted query went out again, untyped" + ); + let queries: Vec<&str> = tui + .state + .searches + .iter() + .map(|s| s.query.as_str()) + .collect(); + assert_eq!( + queries, + ["boards of canada", "aphex twin"], + "it keeps its row: no duplicate, nothing moved" + ); + assert_eq!(tui.state.searches[1].status, SearchStatus::Active); + assert_eq!(tui.state.selected_search_index, Some(1)); + assert_eq!( + tui.state.focused_pane, + FocusedPane::Results, + "and the window turns to where the results will land" + ); + assert!(tui.state.results_items.is_empty()); +} + +#[test] +fn shift_s_stops_a_run_still_collecting_before_starting_over() { + let mut tui = furnished_tui(); + tui.state.searches[0].status = SearchStatus::Active; + tui.state.searches[0].start_time = std::time::Instant::now() + .checked_sub(Duration::from_secs(2)) + .expect("two seconds ago"); + let previous = tui.state.searches[0].cancel_flag.clone(); + tui.state.focused_pane = FocusedPane::Searches; + tui.state.searches_table_state.select(Some(0)); + + press(&mut tui, KeyCode::Char('S')); + + assert!( + previous.load(std::sync::atomic::Ordering::Relaxed), + "the worker that was collecting is told to stop" + ); + let fresh = &tui.state.searches[0]; + assert!(!fresh.cancel_flag.load(std::sync::atomic::Ordering::Relaxed)); + assert!(fresh.results.is_empty(), "stale rows do not linger"); + assert_eq!(tui.state.searches.len(), 1); +} + +#[test] +fn shift_s_is_only_a_key_over_the_searches_list() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = restored(session.clone(), &["aphex twin"]); + + // Over Results the same key means nothing, so a stray shift does not + // throw a finished search away. + tui.state.focused_pane = FocusedPane::Results; + press(&mut tui, KeyCode::Char('S')); + assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); + std::thread::sleep(Duration::from_millis(50)); + assert!(searched(&session, 0).is_empty(), "nothing went out"); +} + +#[test] +fn a_held_shift_s_is_one_search_not_one_per_repeat() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = restored(session.clone(), &["aphex twin"]); + press(&mut tui, KeyCode::Char('S')); + press(&mut tui, KeyCode::Char('1')); + press(&mut tui, KeyCode::Char('S')); + assert_eq!(searched(&session, 1).len(), 1); + std::thread::sleep(Duration::from_millis(50)); + assert_eq!(searched(&session, 1).len(), 1, "the repeat is ignored"); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/session.rs b/soulseek-rs/src/ui/main_tui/tests/session.rs new file mode 100644 index 0000000..a409630 --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/session.rs @@ -0,0 +1,289 @@ +//! A window over a session it did not start: what a daemon's state +//! looks like arriving in a window, and what the window pushes back. + +use super::*; + +#[test] +fn an_attached_session_opens_with_the_conversation_already_in_it() { + // A TUI attached to a daemon has no state file of its own; the + // conversation has to come from the session, or it starts blank while + // the daemon has been collecting messages for hours. + let tui = tui(vec![ + ChatMessageDto { + peer: "bob".into(), + outgoing: false, + text: "hi".into(), + at: 1, + }, + ChatMessageDto { + peer: "bob".into(), + outgoing: true, + text: "hello".into(), + at: 2, + }, + ]); + + assert_eq!(tui.state.messages.len(), 2); + assert_eq!(tui.state.messages[0].peer, "bob"); + assert_eq!(tui.state.messages[0].direction, MessageDirection::Incoming); + assert_eq!( + tui.state.messages[1].direction, + MessageDirection::Outgoing, + "both halves of the conversation, not just what arrived" + ); + assert_eq!(tui.state.chat_peers(), ["bob"]); +} + +#[test] +fn a_window_shows_transfers_it_did_not_start() { + // Two windows on one daemon are two views of one queue. A transfer + // queued in the other one has to appear here, or closing a window + // would look like losing its downloads. + let mut tui = with_session(TalkativeSession { + downloads: vec![queued("bob", "@@x\\theirs.mp3")], + shared: true, + ..TalkativeSession::default() + }); + assert!( + tui.state.downloads.is_empty(), + "nothing until the first poll" + ); + + tui.update_downloads(); + + assert_eq!(tui.state.downloads.len(), 1); + assert_eq!(tui.state.downloads[0].download.filename, "@@x\\theirs.mp3"); + assert!( + tui.state.downloads[0].receiver.is_none(), + "someone else's transfer has no channel of ours" + ); + + // Polling again must not double it up. + tui.update_downloads(); + assert_eq!(tui.state.downloads.len(), 1); +} + +#[test] +fn a_transfer_the_session_forgets_leaves_every_window() { + let mut tui = with_session(TalkativeSession { + downloads: vec![queued("bob", "gone.mp3")], + shared: true, + ..TalkativeSession::default() + }); + tui.update_downloads(); + assert_eq!(tui.state.downloads.len(), 1); + + // The session no longer has it โ€” cancelled from the other window. + tui.client = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + tui.update_downloads(); + assert!( + tui.state.downloads.is_empty(), + "a queue is shared in both directions" + ); +} + +#[test] +fn a_window_shows_searches_run_in_another() { + let mut tui = + with_session(shared(vec![search_of("their query", 7, 9_999)])); + tui.update_search_results(); + + assert_eq!(tui.state.searches.len(), 1); + assert_eq!(tui.state.searches[0].query, "their query"); + + // And it is not added a second time on the next frame. + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); +} + +#[test] +fn a_search_another_window_is_still_running_shows_as_active() { + // It had said "Done" the moment it appeared, because an imported + // search carried no notion of still collecting โ€” so the other window + // looked finished while results were still arriving. + let mut tui = with_session(shared(vec![search_of("still going", 3, 0)])); + tui.update_search_results(); + + assert_eq!(tui.state.searches[0].status, SearchStatus::Active); + assert_eq!( + tui.state.searches[0].known_files, 3, + "and its count comes across before the results do" + ); +} + +#[test] +fn a_search_that_has_run_its_window_shows_as_done() { + let mut tui = with_session(shared(vec![search_of("finished", 12, 9_999)])); + tui.update_search_results(); + assert_eq!(tui.state.searches[0].status, SearchStatus::Completed); +} + +#[test] +fn removing_a_search_removes_it_from_the_session() { + // Removing it only from this window would last until the next sync, + // which would put it straight back โ€” and in a shared session it would + // come back for every other window too. + let mut tui = with_session(shared(vec![search_of("unwanted", 1, 9_999)])); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); + + tui.remove_search_at_index(0); + tui.update_search_results(); + + assert!(tui.state.searches.is_empty(), "and it stays gone"); +} + +#[test] +fn clearing_every_search_clears_them_in_the_session_too() { + let mut tui = with_session(shared(vec![ + search_of("one", 1, 9_999), + search_of("two", 2, 9_999), + ])); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 2); + + tui.clear_all_searches(); + tui.update_search_results(); + + assert!( + tui.state.searches.is_empty(), + "clearing has to reach the session, or every row comes back" + ); +} + +#[test] +fn a_search_dismissed_in_another_window_leaves_this_one() { + let session = shared(vec![search_of("theirs", 4, 9_999)]); + let mut tui = with_session(session); + tui.update_search_results(); + assert_eq!(tui.state.searches.len(), 1); + + // The other window dismissed it. + tui.client.forget_search("theirs"); + tui.update_search_results(); + + assert!(tui.state.searches.is_empty()); +} + +#[test] +fn a_tick_asks_the_session_for_its_searches_once() { + // Against a daemon every call is a round trip, and the daemon answers + // it by walking its whole search cache โ€” twice per frame was half the + // cost of attaching at all. + let session = Arc::new(shared(vec![search_of("q", 0, 9_999)])); + let mut tui = attach(session.clone()); + + tui.update_search_results(); + + assert_eq!( + session + .all_searches_calls + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "one sync means one listing, not one per use of it" + ); +} + +#[test] +fn a_selected_search_with_nothing_new_is_not_pulled_across_again() { + // The result set only accumulates, so an unchanged count means a + // fetch would hand back what this window already holds โ€” against a + // daemon that is the entire result set over the socket, every frame, + // forever. + let (session, tui) = window_that_pulled_two_files(); + + assert_eq!(tui.state.searches[0].results.len(), 2); + assert_eq!( + session + .result_fetches + .load(std::sync::atomic::Ordering::Relaxed), + 1, + "the set crosses once; after that the unchanged count spares it" + ); +} + +#[test] +fn fresh_results_still_reach_a_window_that_skipped_a_fetch() { + let (session, mut tui) = window_that_pulled_two_files(); + + // A late responder lands in the session. + *session.searches.lock().expect("not poisoned") = + vec![search_of("aphex twin", 3, 9_999)]; + *session.results.lock().expect("not poisoned") = + vec![files_from_bob(&["@@x\\a.mp3", "@@x\\b.mp3", "@@x\\c.mp3"])]; + tui.update_search_results(); + + assert_eq!( + tui.state.searches[0].results.len(), + 3, + "a changed count must bring the new results across" + ); +} + +#[test] +fn a_local_window_fetches_results_without_counting_on_the_session() { + // Locally nothing maintains known_files, so the count gate must stay + // out of the way: a search whose count still reads zero has to get + // its results anyway. + let session = Arc::new(TalkativeSession { + results: std::sync::Mutex::new(vec![files_from_bob(&[ + "@@x\\a.mp3", + "@@x\\b.mp3", + ])]), + shared: false, + ..TalkativeSession::default() + }); + let mut tui = attach(session); + tui.state.searches.push(crate::models::SearchEntry { + query: "mine".to_string(), + status: SearchStatus::Active, + results: Vec::new(), + known_files: 0, + owned: true, + start_time: std::time::Instant::now(), + cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }); + + tui.update_search_results(); + + assert_eq!( + tui.state.searches[0].results.len(), + 2, + "a zero count matching zero held rows must not read as \ + nothing-to-fetch on a local session" + ); +} + +#[test] +fn a_window_with_its_own_session_is_left_alone() { + // Nothing to share, so nothing is synced โ€” and in particular the + // completed transfers restored from disk, which the session has never + // heard of, are not mistaken for cancelled and deleted. + let mut tui = with_session(TalkativeSession { + downloads: Vec::new(), + shared: false, + ..TalkativeSession::default() + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "from-disk.mp3"), + receiver: None, + }); + + tui.update_downloads(); + + assert_eq!( + tui.state.downloads.len(), + 1, + "a local window's history must survive the tick" + ); +} + +#[test] +fn a_local_session_starts_from_its_own_snapshot_instead() { + // Locally the session knows no history and the state file is the whole + // record, so nothing is invented. + assert!(tui(Vec::new()).state.messages.is_empty()); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/settings.rs b/soulseek-rs/src/ui/main_tui/tests/settings.rs new file mode 100644 index 0000000..795267b --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/settings.rs @@ -0,0 +1,201 @@ +//! The settings popup: the account it names, the folders it applies, +//! and the password and logout it can reach. + +use super::*; + +/// Attached, both folders in the settings form name paths on the +/// *daemon's* machine: they go over the socket exactly as typed, and +/// nothing is created or validated against this machine's filesystem. +#[test] +fn applying_settings_attached_pushes_both_folders_to_the_daemon() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let scratch = tempfile::tempdir().expect("a temp dir"); + let config_path = scratch.path().join("config.toml"); + let mut tui = MainTui::new( + session.clone(), + "/daemon/old".to_string(), + Duration::from_secs(1), + None, + Some(config_path.clone()), + ); + + let daemon_only = scratch.path().join("only-on-the-daemon"); + let daemon_dir = daemon_only.to_str().expect("utf-8").to_string(); + tui.open_settings(); + { + let settings = tui.state.settings.as_mut().expect("open"); + settings.download_dir.clone_from(&daemon_dir); + settings.share_dirs = vec!["/not-here-either".to_string()]; + } + tui.apply_settings(); + + assert_eq!( + *session.download_dir_set.lock().expect("not poisoned"), + Some(daemon_dir.clone()), + "the folder is pushed to the daemon" + ); + assert_eq!( + *session.shares_set.lock().expect("not poisoned"), + Some(vec!["/not-here-either".to_string()]), + "shares go over as typed โ€” the daemon's filesystem judges them" + ); + assert!( + !daemon_only.exists(), + "no directory is created on this machine for a daemon's path" + ); + assert_eq!(tui.download_dir, daemon_dir); + + let saved = crate::persist::config::FileConfig::load(&config_path) + .expect("config readable"); + assert_eq!( + saved.download_dir.as_deref(), + Some(daemon_dir.as_str()), + "the change still lands in config.toml for the next restart" + ); +} + +/// Locally the same form works on this machine: the folder is created +/// here and share paths that do not exist here are dropped before the +/// session hears about them. +#[test] +fn applying_settings_locally_stays_on_this_machine() { + let session = Arc::new(TalkativeSession { + shared: false, + ..TalkativeSession::default() + }); + let scratch = tempfile::tempdir().expect("a temp dir"); + let config_path = scratch.path().join("config.toml"); + let share = scratch.path().join("music"); + std::fs::create_dir_all(&share).expect("share dir"); + let mut tui = MainTui::new( + session.clone(), + "/tmp".to_string(), + Duration::from_secs(1), + None, + Some(config_path), + ); + + let downloads = scratch.path().join("downloads"); + let downloads_dir = downloads.to_str().expect("utf-8").to_string(); + tui.open_settings(); + { + let settings = tui.state.settings.as_mut().expect("open"); + settings.download_dir.clone_from(&downloads_dir); + settings.share_dirs = vec![ + share.to_str().expect("utf-8").to_string(), + "/definitely-not-a-real-share".to_string(), + ]; + } + tui.apply_settings(); + + // The seam is told in both modes; a local session accepts and takes + // the directory per download instead of storing it. + assert_eq!( + *session.download_dir_set.lock().expect("not poisoned"), + Some(downloads_dir.clone()) + ); + assert_eq!( + *session.shares_set.lock().expect("not poisoned"), + Some(vec![share.to_str().expect("utf-8").to_string()]), + "paths missing on this machine are dropped before applying" + ); + assert!(downloads.is_dir(), "the folder is created here"); + assert_eq!(tui.download_dir, downloads_dir); +} + +/// The account block answers "who is this session, and what is it +/// offering" without a trip to another screen. +#[test] +fn the_settings_popup_names_the_account_and_its_shares() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + + let screen = screen_sized(&mut tui, 100, 30); + assert!(screen.contains("tester"), "{screen}"); + assert!(screen.contains("12 files in 3 folders"), "{screen}"); +} + +/// Typed twice the same way, the new password reaches the session โ€” and +/// attached to a daemon it is the daemon that stores it. +#[test] +fn a_password_typed_twice_reaches_the_session() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); // the change-password row is first + for _ in 0..2 { + for c in "hunter2".chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!( + *session.password_set.lock().expect("not poisoned"), + Some("hunter2".to_string()) + ); + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!(settings.status.as_deref(), Some("Password changed")); +} + +/// A mistyped repeat is caught here rather than on the server, where the +/// account would be left with a password nobody knows. +#[test] +fn a_password_typed_two_different_ways_is_not_sent() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session.clone()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Enter); + for typed in ["hunter2", "hunter3"] { + for c in typed.chars() { + press(&mut tui, KeyCode::Char(c)); + } + press(&mut tui, KeyCode::Enter); + } + + assert_eq!(*session.password_set.lock().expect("not poisoned"), None); +} + +/// Logging out ends the window, but as a log-out: the caller brings the +/// login screen back rather than returning to the shell. +#[test] +fn logging_out_asks_first_and_then_closes_the_window() { + let mut tui = with_session(TalkativeSession::default()); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); // change password -> log out + press(&mut tui, KeyCode::Enter); + assert!(tui.state.exit.is_none(), "the confirm comes first"); + + press(&mut tui, KeyCode::Char('y')); + assert_eq!(tui.state.exit, Some(TuiExit::Logout)); +} + +/// Attached, the login is the daemon's: there is no logout row to land +/// on, so the row below the password is the download folder. +#[test] +fn an_attached_window_offers_no_logout() { + let session = Arc::new(TalkativeSession { + shared: true, + ..TalkativeSession::default() + }); + let mut tui = attach(session); + press(&mut tui, KeyCode::Char('o')); + press(&mut tui, KeyCode::Down); + press(&mut tui, KeyCode::Enter); + + let settings = tui.state.settings.as_ref().expect("still open"); + assert_eq!( + settings.mode, + crate::models::SettingsMode::EditingDownloadDir + ); + assert!(tui.state.exit.is_none()); +} diff --git a/soulseek-rs/src/ui/main_tui/tests/transfers.rs b/soulseek-rs/src/ui/main_tui/tests/transfers.rs new file mode 100644 index 0000000..6957d3c --- /dev/null +++ b/soulseek-rs/src/ui/main_tui/tests/transfers.rs @@ -0,0 +1,239 @@ +//! What the transfer lists do when a key lands on a row. + +use super::*; + +#[test] +fn x_cancels_the_highlighted_download() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "song.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert_eq!( + *session.download_cancelled.lock().expect("not poisoned"), + vec![("bob".to_string(), "song.mp3".to_string())] + ); + assert!( + session + .upload_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "the download row cancels no upload" + ); +} + +#[test] +fn shift_c_clears_the_whole_transfer_list() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "waiting.mp3"), + receiver: None, + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "carol", + "live.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("dave", "done.mp3"), + receiver: None, + }); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('C')); + + assert!(tui.state.downloads.is_empty(), "every row goes"); + assert_eq!( + *session.download_cancelled.lock().expect("not poisoned"), + vec![ + ("bob".to_string(), "waiting.mp3".to_string()), + ("carol".to_string(), "live.mp3".to_string()), + ], + "live downloads are cancelled; the finished one is not" + ); + assert_eq!( + *session.download_removed.lock().expect("not poisoned"), + vec![ + ("bob".to_string(), "waiting.mp3".to_string()), + ("carol".to_string(), "live.mp3".to_string()), + ("dave".to_string(), "done.mp3".to_string()), + ], + "the session forgets every download, not just this window's rows" + ); + assert_eq!( + *session.upload_cancelled.lock().expect("not poisoned"), + vec![("alice".to_string(), "served.mp3".to_string())], + "a streaming upload is cancelled too" + ); +} + +#[test] +fn d_clears_a_cancelled_row() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "gone.mp3", + soulseek_rs::DownloadStatus::Cancelled, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('d')); + + assert!(tui.state.downloads.is_empty(), "the row goes"); +} + +#[test] +fn x_on_a_finished_download_asks_nothing() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("bob", "done.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert!( + session + .download_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "a finished download is not cancelled" + ); +} + +#[test] +fn x_on_an_upload_row_still_cancels_the_upload() { + let session = Arc::new(TalkativeSession::default()); + let mut tui = attach(session.clone()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "song.mp3"), + receiver: None, + }); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(1)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('x')); + + assert_eq!( + *session.upload_cancelled.lock().expect("not poisoned"), + vec![("alice".to_string(), "served.mp3".to_string())] + ); + assert!( + session + .download_cancelled + .lock() + .expect("not poisoned") + .is_empty(), + "the upload row cancels no download" + ); +} + +#[test] +fn b_browses_the_user_of_the_highlighted_download() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: queued("bob", "song.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('b')); + + assert!(tui.state.show_browse, "the browse popup opens"); + assert_eq!( + tui.state.browse.active_tab().map(|b| b.username.as_str()), + Some("bob"), + "the download's user is the one browsed" + ); + assert!( + !tui.state.command_bar_active, + "b on a transfer goes straight to the user, not the prompt" + ); +} + +#[test] +fn b_on_an_upload_row_browses_that_user() { + let mut tui = with_session(TalkativeSession::default()); + tui.state.uploads.push(uploading("alice", "served.mp3")); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('b')); + + assert_eq!( + tui.state.browse.active_tab().map(|b| b.username.as_str()), + Some("alice") + ); +} + +#[test] +fn d_clears_a_finished_row_but_not_a_running_one() { + // Finished transfers are what a list fills up with after a restart, so + // `d` has to be able to dismiss one โ€” restored or not. A running + // transfer is not ours to discard, and stays put. + let mut tui = with_session(TalkativeSession::default()); + tui.state.downloads.push(crate::models::DownloadEntry { + download: completed("bob", "done.mp3"), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + tui.state.focused_pane = FocusedPane::Downloads; + + tui.handle_key_event(key('d')); + + assert!(tui.state.downloads.is_empty(), "the row goes too"); + assert_eq!(tui.state.downloads_table_state.selected(), None); + + tui.state.downloads.push(crate::models::DownloadEntry { + download: at_status( + "bob", + "still.mp3", + soulseek_rs::DownloadStatus::InProgress { + bytes_downloaded: 1, + total_bytes: 2, + speed_bytes_per_sec: 1.0, + }, + ), + receiver: None, + }); + tui.state.downloads_table_state.select(Some(0)); + + tui.handle_key_event(key('d')); + + assert_eq!(tui.state.downloads.len(), 1, "it is not disposable"); +} diff --git a/soulseek-rs/src/ui/mod.rs b/soulseek-rs/src/ui/mod.rs index dbafefe..fd9542f 100644 --- a/soulseek-rs/src/ui/mod.rs +++ b/soulseek-rs/src/ui/mod.rs @@ -1,11 +1,13 @@ mod downloads; pub mod login; mod main_tui; +mod paging; mod panes; mod styles; mod utils; pub use downloads::render_download_stats; -pub use main_tui::launch_main_tui; +pub use main_tui::{MainTui, launch_main_tui}; +pub use paging::*; pub use styles::*; pub use utils::*; diff --git a/soulseek-rs/src/ui/paging.rs b/soulseek-rs/src/ui/paging.rs new file mode 100644 index 0000000..d91cb4a --- /dev/null +++ b/soulseek-rs/src/ui/paging.rs @@ -0,0 +1,104 @@ +//! How a pane's table shows one page of a list that may run to tens of +//! thousands of rows: which rows those are, and how ratatui is handed just +//! those. Paging and drawing both count rows here, so they agree on what a +//! page is. + +use crate::ui::pane_block; +use ratatui::{ + Frame, + layout::Rect, + widgets::{Table, TableState}, +}; +use std::ops::Range; + +/// Rows a list in `area` shows at once, less the `chrome` rows above it: a +/// table header, a popup's tab bar. At least one, so a page key always +/// moves, even before the first draw. +#[must_use] +pub fn page_of(area: Option, chrome: u16) -> usize { + area.map_or(0, |area| { + usize::from(pane_block(false).inner(area).height.saturating_sub(chrome)) + }) + .max(1) +} + +/// The page a table over `area` shows of `len` rows, `chrome` rows of the +/// area being above them: where the last frame left off, moved only as far +/// as keeps the selection in view. A selection past the end lands on the +/// last row, as the table would have put it. +pub fn page_window( + state: &mut TableState, + len: usize, + area: Rect, + chrome: u16, +) -> Range { + if let Some(selected) = state.selected_mut() { + *selected = (*selected).min(len.saturating_sub(1)); + } + visible_range( + state.offset(), + state.selected(), + len, + page_of(Some(area), chrome), + ) +} + +/// Draw `table`, holding the rows of the page that starts at `start`, with +/// `state` shifted onto that page. The page start is kept for the next +/// frame. +pub fn render_page( + frame: &mut Frame, + table: Table, + area: Rect, + state: &mut TableState, + start: usize, +) { + let mut page_state = TableState::default() + .with_selected(state.selected().map(|selected| selected - start)); + frame.render_stateful_widget(table, area, &mut page_state); + *state.offset_mut() = start; +} + +/// The page a `height`-tall table shows, by ratatui's rule (its +/// `visible_rows` is private): the offset, moved only as far as keeps the +/// selection in view. A selection past the end counts as the last row. +fn visible_range( + offset: usize, + selected: Option, + len: usize, + height: usize, +) -> Range { + let height = height.max(1); + let last = len.saturating_sub(1); + let start = match selected.map(|selected| selected.min(last)) { + Some(selected) => { + offset.clamp((selected + 1).saturating_sub(height), selected) + } + None => offset.min(last), + }; + start..(start + height).min(len) +} + +#[cfg(test)] +mod tests { + use super::{page_window, visible_range}; + use ratatui::{layout::Rect, widgets::TableState}; + + #[test] + fn the_page_follows_the_selection_and_is_never_longer_than_the_pane() { + assert_eq!(visible_range(500, Some(505), 10_000, 10), 500..510); + assert_eq!(visible_range(0, Some(9_999), 10_000, 10), 9_990..10_000); + assert_eq!(visible_range(500, Some(499), 10_000, 10), 499..509); + assert_eq!(visible_range(9_995, None, 10_000, 10), 9_995..10_000); + assert_eq!(visible_range(0, Some(9), 3, 2), 1..3); + } + + #[test] + fn a_window_pulls_a_selection_past_the_end_onto_the_last_row() { + // 6 rows tall: 2 border, 1 header, 3 of the list. + let area = Rect::new(0, 0, 40, 6); + let mut state = TableState::default().with_selected(Some(9)); + assert_eq!(page_window(&mut state, 5, area, 1), 2..5); + assert_eq!(state.selected(), Some(4)); + } +} diff --git a/soulseek-rs/src/ui/panes/browse_pane.rs b/soulseek-rs/src/ui/panes/browse_pane.rs index c5348d4..cc95259 100644 --- a/soulseek-rs/src/ui/panes/browse_pane.rs +++ b/soulseek-rs/src/ui/panes/browse_pane.rs @@ -1,8 +1,9 @@ -use crate::models::{BrowseState, BrowseStatus, BrowseTabs}; +use crate::models::{BrowseState, BrowseStatus, BrowseTabs, DownloadEntry}; use crate::ui::{ - HIGHLIGHT_SYMBOL, body_style, dimmed_style, error_style, format_bytes, - get_spinner_char, highlight_style, pane_block, primary_style, - row_highlight_style, warning_style, + HIGHLIGHT_SYMBOL, body_style, dimmed_style, download_status_glyph, + error_style, filter_title, format_bytes, get_spinner_char, highlight_style, + page_window, pane_block, primary_style, render_page, row_highlight_style, + warning_style, }; use ratatui::{ Frame, @@ -11,15 +12,23 @@ use ratatui::{ text::{Line, Span}, widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, }; +use soulseek_rs::DownloadStatus; +use soulseek_rs::types::Download; +use std::collections::HashMap; /// Render the browse popup: a tab bar of browsed users (when more than one is /// open) above the active user's collapsible shared-file tree. +/// +/// `downloads` is the window's transfer list; a file row whose download is +/// there shows the same status glyph the Downloads pane gives it, and a +/// folder row summarises the downloads of the files queued below it. pub fn render_browse_pane( frame: &mut Frame, area: Rect, tabs: &BrowseTabs, table_state: &mut TableState, spinner_state: usize, + downloads: &[DownloadEntry], ) { let Some(active) = tabs.active_tab() else { return; @@ -36,7 +45,14 @@ pub fn render_browse_pane( area }; - render_browse_one(frame, tree_area, active, table_state, spinner_state); + render_browse_one( + frame, + tree_area, + active, + table_state, + spinner_state, + downloads, + ); } fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) { @@ -47,7 +63,8 @@ fn render_browse_tabs(frame: &mut Frame, area: Rect, tabs: &BrowseTabs) { } else { dimmed_style() }; - spans.push(Span::styled(format!(" {} ", tab.username), style)); + let you = if tab.own { " (you)" } else { "" }; + spans.push(Span::styled(format!(" {}{you} ", tab.username), style)); spans.push(Span::raw(" ")); } frame.render_widget(Paragraph::new(Line::from(spans)), area); @@ -60,13 +77,35 @@ fn render_browse_one( browse: &BrowseState, table_state: &mut TableState, spinner_state: usize, + downloads: &[DownloadEntry], ) { + // Our own index is not downloadable and re-scans instead of re-asking, + // so it names itself and offers its own keys. + let heading = if browse.own { + "My shares".to_string() + } else { + format!("Browse {}", browse.username) + }; let title = match browse.status { - BrowseStatus::Loaded => format!( - " Browse {} โ€” {} files, {} folders (Enter/d: download, Tab: user, w: close, Esc: hide) ", - browse.username, browse.file_count, browse.folder_count - ), - _ => format!(" Browse {} ", browse.username), + BrowseStatus::Loaded => { + let keys = if browse.own { + "/: filter, r: re-index, o: folders, w: close, Esc: hide" + } else { + "Enter/d: download, /: filter, Tab: user, w: close, Esc: hide" + }; + let shown = + browse.rows().iter().filter(|row| !row.is_folder).count(); + filter_title( + &format!("{heading} โ€” {shown}/{} files", browse.file_count), + browse.filter(), + browse.filtering, + &format!( + " {heading} โ€” {} files, {} folders ({keys}) ", + browse.file_count, browse.folder_count + ), + ) + } + _ => format!(" {heading} "), }; let block = pane_block(true).title(title); @@ -83,7 +122,12 @@ fn render_browse_one( ); } BrowseStatus::Empty => { - let text = format!("{} is not sharing any files.", browse.username); + let text = if browse.own { + "You are not sharing any files. Press o to add a folder." + .to_string() + } else { + format!("{} is not sharing any files.", browse.username) + }; frame.render_widget( Paragraph::new(text).style(dimmed_style()).block(block), area, @@ -105,30 +149,108 @@ fn render_browse_one( ]; frame.render_widget(Paragraph::new(text).block(block), area); } + BrowseStatus::Loaded + if browse.rows().is_empty() && !browse.filter().is_empty() => + { + let text = format!("Nothing here matches '{}'.", browse.filter()); + frame.render_widget( + Paragraph::new(text).style(dimmed_style()).block(block), + area, + ); + } BrowseStatus::Loaded => { - let rows: Vec = browse - .rows() + let all = browse.rows(); + // Queued files carry the download's full share path, so a row + // matches a transfer of this user by path alone. + let transfers: Vec<&Download> = downloads + .iter() + .filter(|entry| entry.download.username == browse.username) + .map(|entry| &entry.download) + .collect(); + let status_of: HashMap<&str, &DownloadStatus> = transfers + .iter() + .map(|download| (download.filename.as_str(), &download.status)) + .collect(); + table_state.select(Some(browse.selected_row)); + let window = page_window(table_state, all.len(), area, 0); + let start = window.start; + let rows: Vec = all[window] .iter() .map(|row| { let indent = " ".repeat(row.depth); let (label, size) = if row.is_folder { - let glyph = if row.expanded { "โ–พ" } else { "โ–ธ" }; + // A folder's download is its files' downloads, so + // the folder reports the one status that describes + // the files queued below it. + let below: Vec<&Download> = transfers + .iter() + .copied() + .filter(|d| under_folder(&d.filename, &row.path)) + .collect(); + let transfer = folder_status(&below); + let marker = transfer.map_or_else( + || Span::raw(""), + |status| { + let (glyph, style) = + download_status_glyph(status); + Span::styled(format!("{glyph} "), style) + }, + ); + let open = if row.expanded { "โ–พ" } else { "โ–ธ" }; + let size = if matches!( + transfer, + Some(DownloadStatus::InProgress { .. }) + ) { + format!("{}%", folder_percent(&below)) + } else { + String::new() + }; ( - Cell::from(format!("{indent}{glyph} {}", row.name)) - .style( + Cell::from(Line::from(vec![ + Span::raw(indent), + marker, + Span::styled( + format!("{open} {}", row.name), primary_style() .add_modifier(Modifier::BOLD), ), - Cell::from(String::new()), + ])), + Cell::from(size).style(warning_style()), ) } else { + let status = status_of.get(row.path.as_str()).copied(); + // The two spaces before a file's name become the + // transfer's glyph while its download lives in the + // list: โ‹ฏ queued, โ†ฏ active, โธ paused, โœ“ done. + let marker = status.map_or_else( + || Span::raw(" "), + |status| { + let (glyph, style) = + download_status_glyph(status); + Span::styled(format!("{glyph} "), style) + }, + ); + let plain_size = + row.size.map(format_bytes).unwrap_or_default(); + let size_text = match status { + Some(DownloadStatus::InProgress { + bytes_downloaded, + total_bytes, + .. + }) if *total_bytes > 0 => { + let percent = + bytes_downloaded * 100 / total_bytes; + format!("{plain_size} {percent}%") + } + _ => plain_size, + }; ( - Cell::from(format!("{indent} {}", row.name)) - .style(body_style()), - Cell::from( - row.size.map(format_bytes).unwrap_or_default(), - ) - .style(warning_style()), + Cell::from(Line::from(vec![ + Span::raw(indent), + marker, + Span::styled(row.name.clone(), body_style()), + ])), + Cell::from(size_text).style(warning_style()), ) }; Row::new(vec![label, size]) @@ -142,8 +264,224 @@ fn render_browse_one( .highlight_spacing(HighlightSpacing::Always) .block(block); - table_state.select(Some(browse.selected_row)); - frame.render_stateful_widget(table, area, table_state); + render_page(frame, table, area, table_state, start); } } } + +/// Whether `file` is one of the folder's own files in a share path. +fn under_folder(file: &str, folder: &str) -> bool { + file.strip_prefix(folder) + .is_some_and(|rest| rest.starts_with('\\')) +} + +/// How loudly a status speaks for a whole folder: moving work first, +/// then paused, then queued, then a failure worth surfacing; done only +/// says so once every file is. +const fn status_rank(status: &DownloadStatus) -> u8 { + match status { + DownloadStatus::InProgress { .. } => 5, + DownloadStatus::Paused { .. } => 4, + DownloadStatus::Queued => 3, + DownloadStatus::Failed(_) | DownloadStatus::TimedOut => 2, + DownloadStatus::Cancelled => 1, + DownloadStatus::Completed => 0, + } +} + +/// The status that describes a folder's download: the loudest of its +/// files', or nothing while none of them is in the transfer list. +fn folder_status<'a>(files: &[&'a Download]) -> Option<&'a DownloadStatus> { + files + .iter() + .map(|download| &download.status) + .max_by_key(|status| status_rank(status)) +} + +/// How far a folder's queued bytes have come, whole percents. +fn folder_percent(files: &[&Download]) -> u8 { + let bytes: u64 = files.iter().map(|d| d.bytes_downloaded()).sum(); + let total: u64 = files.iter().map(|d| d.size).sum(); + bytes.saturating_mul(100).checked_div(total).unwrap_or(0) as u8 +} + +#[cfg(test)] +mod tests { + use super::render_browse_pane; + use crate::models::{BrowseState, BrowseTabs, DownloadEntry}; + use crate::ui::{GLYPH_ACTIVE, GLYPH_DONE}; + use ratatui::{Terminal, backend::TestBackend, widgets::TableState}; + use soulseek_rs::types::{Download, DownloadMetadata, DownloadStatus}; + use soulseek_rs::{SharedDirectory, SharedFileEntry}; + use std::sync::mpsc; + + fn share() -> SharedDirectory { + SharedDirectory { + name: "share".to_string(), + files: ["active.mp3", "done.mp3", "plain.mp3"] + .iter() + .map(|name| SharedFileEntry { + name: (*name).to_string(), + size: 4096, + attributes: Vec::new(), + }) + .collect(), + } + } + + fn download(filename: &str, status: DownloadStatus) -> DownloadEntry { + let (sender, _receiver) = mpsc::channel(); + DownloadEntry { + download: Download { + username: "bob".to_string(), + // Queued files carry the full share path, as in the tree. + filename: format!("share\\{filename}"), + token: 1, + size: 4096, + download_directory: "/tmp".to_string(), + status, + sender, + queue_position: None, + metadata: DownloadMetadata::default(), + }, + receiver: None, + } + } + + /// One tab drawn onto a `width`-wide buffer. + fn screen( + browse: BrowseState, + downloads: &[DownloadEntry], + width: u16, + ) -> String { + let mut tabs = BrowseTabs::default(); + tabs.tabs.push(browse); + let mut state = TableState::default(); + let mut terminal = + Terminal::new(TestBackend::new(width, 8)).expect("backend"); + terminal + .draw(|frame| { + render_browse_pane( + frame, + frame.area(), + &tabs, + &mut state, + 0, + downloads, + ); + }) + .expect("draw"); + terminal.backend().to_string() + } + + fn screen_with(downloads: &[DownloadEntry]) -> String { + let mut browse = BrowseState::loading("bob".to_string()); + browse.load(&[share()]); + screen(browse, downloads, 60) + } + + #[test] + fn a_downloading_file_carries_its_transfer_glyph() { + let screen = screen_with(&[ + download( + "active.mp3", + DownloadStatus::InProgress { + bytes_downloaded: 2048, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + download("done.mp3", DownloadStatus::Completed), + ]); + assert!( + screen.contains(&format!("{GLYPH_ACTIVE} active.mp3")), + "{screen}" + ); + assert!( + screen.contains(&format!("{GLYPH_DONE} done.mp3")), + "{screen}" + ); + assert!( + screen.contains("50%"), + "the size cell tells the progress: {screen}" + ); + assert!( + screen.contains(" plain.mp3"), + "an unqueued row keeps the two plain spaces: {screen}" + ); + } + + #[test] + fn a_folder_marks_with_its_files_download() { + // One file moving, one done: the folder row reports the job with + // the loudest status below it, and the bytes' percent beside it. + let screen = screen_with(&[ + download( + "active.mp3", + DownloadStatus::InProgress { + bytes_downloaded: 1024, + total_bytes: 4096, + speed_bytes_per_sec: 1.0, + }, + ), + download("done.mp3", DownloadStatus::Completed), + ]); + assert!( + screen.contains(&format!("{GLYPH_ACTIVE} โ–พ share")), + "{screen}" + ); + // 1024 of the 8192 bytes the two files hold has moved โ€” the done + // one brings its own 4096 โ€” so the folder sits at 62%. + assert!(screen.contains("62%"), "{screen}"); + } + + #[test] + fn a_folder_is_done_when_every_file_is() { + let screen = screen_with(&[ + download("active.mp3", DownloadStatus::Completed), + download("done.mp3", DownloadStatus::Completed), + ]); + assert!( + screen.contains(&format!("{GLYPH_DONE} โ–พ share")), + "{screen}" + ); + assert!(!screen.contains(GLYPH_ACTIVE), "{screen}"); + } + + /// The own tab, rendered with `directories` as its index. Wider than the + /// peer screens because the title carries the keys it offers. + fn own_screen(directories: &[SharedDirectory]) -> String { + let mut browse = BrowseState::loading_own("tester".to_string()); + browse.load(directories); + screen(browse, &[], 90) + } + + #[test] + fn the_own_tab_names_itself_and_offers_no_download() { + let screen = own_screen(&[share()]); + assert!(screen.contains("My shares"), "{screen}"); + assert!(!screen.contains("Browse tester"), "{screen}"); + assert!(screen.contains("re-index"), "r re-scans instead: {screen}"); + assert!( + !screen.contains("download"), + "nothing here is downloadable from ourselves: {screen}" + ); + assert!(screen.contains("plain.mp3"), "the tree still shows"); + } + + #[test] + fn sharing_nothing_says_how_to_start() { + let screen = own_screen(&[]); + assert!(screen.contains("not sharing any files"), "{screen}"); + assert!(screen.contains("Press o to add a folder"), "{screen}"); + } + + #[test] + fn another_user_s_transfers_mark_nothing() { + let mut theirs = download("active.mp3", DownloadStatus::Completed); + theirs.download.username = "carol".to_string(); + let screen = screen_with(&[theirs]); + assert!(!screen.contains(GLYPH_ACTIVE), "{screen}"); + assert!(!screen.contains(GLYPH_DONE), "{screen}"); + } +} diff --git a/soulseek-rs/src/ui/panes/chat_pane.rs b/soulseek-rs/src/ui/panes/chat_pane.rs index 5a2ff83..92b716b 100644 --- a/soulseek-rs/src/ui/panes/chat_pane.rs +++ b/soulseek-rs/src/ui/panes/chat_pane.rs @@ -1,7 +1,7 @@ -use crate::models::{AppState, ChatMessage, LogView, MessageDirection}; +use crate::models::{AppState, MessageDirection, contains_filter}; use crate::ui::{ - accent_style, border_style, dimmed_style, highlight_style, info_style, - primary_style, wrap_chat_line, + accent_style, border_style, dimmed_style, filter_title, highlight_style, + info_style, primary_style, wrap_chat_line, }; use ratatui::{ Frame, @@ -24,7 +24,16 @@ pub fn render_chat_pane( let peer = peer.as_deref(); let title = peer.map_or_else( || " Messages (m: compose, i/Esc: close) ".to_string(), - |peer| format!(" {peer} (โ†‘โ†“/Tab: switch, m: toโ€ฆ, i/Esc: close) "), + |peer| { + filter_title( + peer, + &state.chat_filter, + state.chat_filtering, + &format!( + " {peer} (โ†‘โ†“/Tab: switch, /: find, m: toโ€ฆ, i/Esc: close) " + ), + ) + }, ); let block = Block::default() .borders(Borders::ALL) @@ -52,14 +61,7 @@ pub fn render_chat_pane( return; }; - render_messages( - frame, - body[0], - &state.messages, - &mut state.chat_view, - peer, - own_username, - ); + render_messages(frame, body[0], state, peer, own_username); render_compose(frame, chunks[1], state); } @@ -102,34 +104,37 @@ fn render_peers( fn render_messages( frame: &mut Frame, area: Rect, - messages: &[ChatMessage], - view: &mut LogView, + state: &mut AppState, peer: &str, own_username: &str, ) { + let AppState { + messages, + chat_view: view, + chat_wrapped: wrapped, + chat_filter: filter, + .. + } = state; let width = area.width as usize; - let lines: Vec = messages - .iter() - .filter(|m| m.peer == peer) - .flat_map(|m| { - let (sender, sender_style) = match m.direction { - MessageDirection::Incoming => (peer, info_style()), - MessageDirection::Outgoing => (own_username, accent_style()), - }; - wrap_chat_line( - vec![ - Span::styled( - m.at.format("%H:%M ").to_string(), - dimmed_style(), - ), - Span::styled(format!("<{sender}> "), sender_style), - ], - &m.text, - primary_style(), - width, - ) - }) - .collect(); + let key = format!("{peer}\n{filter}"); + let lines = wrapped.rows(width, &key, messages, |m| { + if m.peer != peer || !contains_filter(&m.text, filter) { + return Vec::new(); + } + let (sender, sender_style) = match m.direction { + MessageDirection::Incoming => (peer, info_style()), + MessageDirection::Outgoing => (own_username, accent_style()), + }; + wrap_chat_line( + vec![ + Span::styled(m.at.format("%H:%M ").to_string(), dimmed_style()), + Span::styled(format!("<{sender}> "), sender_style), + ], + &m.text, + primary_style(), + width, + ) + }); // The newest rows, unless the reader is holding a place in the history. let window = view.window(lines.len(), usize::from(area.height)); diff --git a/soulseek-rs/src/ui/panes/downloads_pane.rs b/soulseek-rs/src/ui/panes/downloads_pane.rs index e624279..98a7878 100644 --- a/soulseek-rs/src/ui/panes/downloads_pane.rs +++ b/soulseek-rs/src/ui/panes/downloads_pane.rs @@ -4,8 +4,8 @@ use crate::ui::{ GLYPH_ACTIVE, GLYPH_DONE, GLYPH_FAILED, GLYPH_QUEUED, HIGHLIGHT_SYMBOL, accent_style, body_style, dimmed_style, download_status_glyph, error_style, format_bytes, format_speed, header_style, inactive_style, info_style, - pane_block, pane_title, primary_style, row_highlight_style, success_style, - warning_style, + page_window, pane_block, pane_title, primary_style, render_page, + row_highlight_style, success_style, warning_style, }; use ratatui::{ Frame, @@ -81,7 +81,15 @@ pub fn render_downloads_pane( .height(1); let name_width = column_width(area, &WIDTHS, NAME_COLUMN); - let mut rows: Vec = downloads + // Downloads come first, uploads after; one page of the two together. + let total = downloads.len() + uploads.len(); + let window = page_window(table_state, total, area, 1); + let start = window.start; + let split = downloads.len(); + let shown_downloads = &downloads[start.min(split)..window.end.min(split)]; + let shown_uploads = + &uploads[start.saturating_sub(split)..window.end.saturating_sub(split)]; + let mut rows: Vec = shown_downloads .iter() .map(|download_entry| { let download = &download_entry.download; @@ -153,7 +161,7 @@ pub fn render_downloads_pane( }) .collect(); - rows.extend(uploads.iter().map(|upload| { + rows.extend(shown_uploads.iter().map(|upload| { let (status_icon, status_style) = match &upload.status { UploadStatus::Queued(_) => (GLYPH_QUEUED, warning_style()), UploadStatus::InProgress => (GLYPH_ACTIVE, accent_style()), @@ -213,5 +221,5 @@ pub fn render_downloads_pane( focused, ))); - frame.render_stateful_widget(table, area, table_state); + render_page(frame, table, area, table_state, start); } diff --git a/soulseek-rs/src/ui/panes/results_pane.rs b/soulseek-rs/src/ui/panes/results_pane.rs index 039c7bf..1b71f59 100644 --- a/soulseek-rs/src/ui/panes/results_pane.rs +++ b/soulseek-rs/src/ui/panes/results_pane.rs @@ -2,8 +2,8 @@ use super::name_scroll::{column_width, end_offset, scroll_text}; use crate::models::FileDisplayData; use crate::ui::{ BYTES_PER_MB, HIGHLIGHT_SYMBOL, body_style, dimmed_style, format_bytes, - header_style, info_style, pane_block, pane_title, row_highlight_style, - success_style, warning_style, + header_style, info_style, page_window, pane_block, pane_title, render_page, + row_highlight_style, success_style, warning_style, }; use ratatui::{ Frame, @@ -15,9 +15,13 @@ use std::collections::HashSet; const VERSION: &str = env!("CARGO_PKG_VERSION"); -const WIDTHS: [Constraint; 7] = [ +// The file name and its folder share whatever width the fixed columns leave, +// the name getting the larger part: it is what a search is after, and a +// folder's tail is a scroll away. +const WIDTHS: [Constraint; 8] = [ Constraint::Length(3), Constraint::Fill(3), + Constraint::Fill(2), Constraint::Length(12), Constraint::Length(15), Constraint::Length(10), @@ -39,12 +43,28 @@ pub struct ResultsPaneParams<'a> { pub name_offset: usize, } -/// Filename is the second column. +/// The file name is the second column, the folder it sits in the third. const NAME_COLUMN: usize = 1; +const FOLDER_COLUMN: usize = 2; +/// A shared path as `(folder, name)`. Peers send backslashes, but a slash +/// counts too. +fn split_path(path: &str) -> (&str, &str) { + path.rsplit_once(['\\', '/']).unwrap_or(("", path)) +} + +/// The offset that brings the end of a result's longer column into view. +/// One offset scrolls the name and the folder together, so it runs until +/// both tails are showing. #[must_use] -pub fn name_end_offset(name: &str, area: Rect) -> usize { - end_offset(name, area, &WIDTHS, NAME_COLUMN) +pub fn name_end_offset(path: &str, area: Rect) -> usize { + let (folder, name) = split_path(path); + end_offset(name, area, &WIDTHS, NAME_COLUMN).max(end_offset( + folder, + area, + &WIDTHS, + FOLDER_COLUMN, + )) } /// Whether the rendered row `display_idx` is selected. `selected_indices` holds @@ -118,6 +138,7 @@ pub fn render_results_pane( let header = Row::new(vec![ Cell::from("โœ“").style(header_style()), Cell::from("Filename").style(header_style()), + Cell::from("Folder").style(header_style()), Cell::from("Size").style(header_style()), Cell::from("User").style(header_style()), Cell::from("Bitrate").style(header_style()), @@ -127,10 +148,13 @@ pub fn render_results_pane( .height(1); let name_width = column_width(area, &WIDTHS, NAME_COLUMN); - let rows: Vec = items - .iter() - .enumerate() + let folder_width = column_width(area, &WIDTHS, FOLDER_COLUMN); + let window = page_window(table_state, items.len(), area, 1); + let start = window.start; + let rows: Vec = (start..) + .zip(&items[window]) .map(|(idx, file)| { + let (folder, name) = split_path(&file.filename); let checkbox = if row_is_selected(idx, original_indices, selected_indices) { "[โœ“]" @@ -159,12 +183,10 @@ pub fn render_results_pane( Row::new(vec![ Cell::from(checkbox).style(checkbox_style), - Cell::from(scroll_text( - &file.filename, - name_offset, - name_width, - )) - .style(body_style()), + Cell::from(scroll_text(name, name_offset, name_width)) + .style(body_style()), + Cell::from(scroll_text(folder, name_offset, folder_width)) + .style(dimmed_style()), Cell::from(format_bytes(file.size)).style(warning_style()), Cell::from(file.username.clone()).style(info_style()), Cell::from(bitrate_str).style(dimmed_style()), @@ -189,7 +211,7 @@ pub fn render_results_pane( .highlight_spacing(HighlightSpacing::Always) .block(pane_block(focused).title(pane_title("2", &title, focused))); - frame.render_stateful_widget(table, area, table_state); + render_page(frame, table, area, table_state, start); } #[cfg(test)] @@ -202,8 +224,18 @@ mod tests { fn render_rows(items: &[FileDisplayData], name_offset: usize) -> String { let mut state = TableState::default(); state.select(Some(0)); + render_with(items, &mut state, name_offset) + } + + fn render_with( + items: &[FileDisplayData], + state: &mut TableState, + name_offset: usize, + ) -> String { + // Wide enough for the name column to hold 11 cells, with 7 for the + // folder beside it, and tall enough for three rows. let mut terminal = - Terminal::new(TestBackend::new(80, 6)).expect("backend"); + Terminal::new(TestBackend::new(88, 6)).expect("backend"); terminal .draw(|frame| { render_results_pane( @@ -211,7 +243,7 @@ mod tests { frame.area(), ResultsPaneParams { items, - table_state: &mut state, + table_state: state, selected_indices: &HashSet::new(), original_indices: None, filter_query: "", @@ -267,6 +299,34 @@ mod tests { assert!(!screen.contains("โ€ฆ\u{301}"), "{screen}"); } + #[test] + fn a_path_shows_its_name_before_its_folder() { + let items = [file("@@abc\\Music\\Album\\01.flac"), file("cover.jpg")]; + let screen = render_rows(&items, 0); + assert!(screen.contains("01.flac @@abc\\M"), "{screen}"); + assert!(screen.contains("cover.jpg "), "{screen}"); + } + + #[test] + fn one_offset_scrolls_the_name_and_the_folder_together() { + let items = [file("@@abc\\Music\\Album\\abcdefghijklmnopqrstuvwxyz")]; + let screen = render_rows(&items, 4); + assert!(screen.contains("โ€ฆefghijklmn โ€ฆc\\Musi"), "{screen}"); + } + + #[test] + fn the_end_offset_is_the_longer_columns() { + // 88 cells wide: 11 for the name, 7 for the folder. + let area = ratatui::layout::Rect::new(0, 0, 88, 6); + let folder = "f".repeat(20); + assert_eq!(super::name_end_offset(&format!("{folder}\\a"), area), 14); + let name = "x".repeat(40); + assert_eq!( + super::name_end_offset(&format!("{folder}\\{name}"), area), + 30 + ); + } + #[test] fn an_unscrolled_name_starts_at_its_beginning() { let items = [file("abcdefghijklmnopqrstuvwxyz0123456789ABCD")]; @@ -275,6 +335,20 @@ mod tests { assert!(!screen.contains("โ€ฆ"), "{screen}"); } + #[test] + fn a_far_selection_is_drawn_from_its_own_page() { + let items: Vec = + (0..10_000).map(|i| file(&format!("{i}.mp3"))).collect(); + let mut state = TableState::default(); + state.select(Some(9_999)); + let screen = render_with(&items, &mut state, 0); + assert!(screen.contains("โ€บ[ ] 9999.mp3"), "{screen}"); + assert!(screen.contains(" [ ] 9997.mp3"), "{screen}"); + assert!(!screen.contains(" [ ] 0.mp3"), "{screen}"); + assert_eq!(state.offset(), 9_997, "the next frame starts here"); + assert_eq!(state.selected(), Some(9_999)); + } + #[test] fn identity_mapping_used_when_no_filter() { let selected: HashSet = std::iter::once(1).collect(); diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs index 28a938f..dd95ed2 100644 --- a/soulseek-rs/src/ui/panes/rooms_pane.rs +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -1,8 +1,11 @@ -use crate::models::{LogView, RoomsState, RoomsView}; +use crate::models::{ + ChatFilter, LogView, OpenRoom, RoomLine, RoomsState, RoomsView, WrappedLog, + contains_filter, matching_users, +}; use crate::ui::{ - HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, - highlight_style, info_style, pane_block, plain_title, primary_style, - row_highlight_style, wrap_chat_line, + HIGHLIGHT_SYMBOL, PANE_PADDING, accent_style, dimmed_style, filter_title, + highlight_style, info_style, page_window, pane_block, plain_title, + primary_style, render_page, row_highlight_style, wrap_chat_line, }; use ratatui::{ Frame, @@ -61,7 +64,10 @@ fn render_list( Cell::from("room").style(dimmed_style()), Cell::from("users").style(dimmed_style()), ]); - let table_rows: Vec = filtered + table_state.select(Some(rooms.list_selected)); + let window = page_window(table_state, filtered.len(), area, 1); + let start = window.start; + let table_rows: Vec = filtered[window] .iter() .map(|r| { let open = rooms.open_index(&r.name).is_some(); @@ -90,14 +96,18 @@ fn render_list( .highlight_spacing(HighlightSpacing::Always) .block(block); - table_state.select(Some(rooms.list_selected.min(filtered.len() - 1))); - frame.render_stateful_widget(table, area, table_state); + render_page(frame, table, area, table_state, start); } fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { - let block = pane_block(true).title( - " Chat rooms (Tab: switch, l: room list, x: leave, Esc: back) ", + let typing_log = rooms.filtering == Some(ChatFilter::Log); + let title = filter_title( + "Chat rooms", + &rooms.log_filter, + typing_log, + " Chat rooms (Tab: switch, /: find, u: find user, l: room list, x: leave, Esc: back) ", ); + let block = pane_block(true).title(title); let inner = block.inner(area); frame.render_widget(block, area); @@ -115,6 +125,9 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { active: active_index, user_selected, composing, + log_filter, + user_filter, + filtering, .. } = rooms; let Some(active) = open.get_mut(*active_index) else { @@ -131,8 +144,28 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &mut RoomsState) { Layout::horizontal([Constraint::Fill(1), Constraint::Length(22)]) .split(chunks[1]); - render_messages(frame, body[0], active.lines.as_slice(), &mut active.view); - render_users(frame, body[1], &active.users, *user_selected); + let OpenRoom { + lines, + view, + wrapped, + users, + .. + } = active; + render_messages(frame, body[0], lines, view, wrapped, log_filter); + let shown = matching_users(users, user_filter); + let typing_users = *filtering == Some(ChatFilter::Users); + let members = if typing_users || !user_filter.is_empty() { + format!( + "Users {}/{} ยท {}{}", + shown.len(), + users.len(), + user_filter, + if typing_users { "_" } else { "" } + ) + } else { + format!("Users ({})", users.len()) + }; + render_users(frame, body[1], &shown, *user_selected, members); // Compose line or hint. if *composing { @@ -179,33 +212,32 @@ fn render_tab_bar(frame: &mut Frame, area: Rect, rooms: &RoomsState) { fn render_messages( frame: &mut Frame, area: Rect, - lines: &[crate::models::RoomLine], + lines: &[RoomLine], view: &mut LogView, + wrapped: &mut WrappedLog, + filter: &str, ) { - // Wrap each message to the pane width, then show the rows the reader is + // Each message wrapped to the pane width, then the rows the reader is // at: the newest ones, unless they are holding a place in the history. let width = area.width as usize; - let rendered: Vec = lines - .iter() - .flat_map(|l| { - let time = - Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style()); - match &l.username { - Some(user) => wrap_chat_line( - vec![ - time, - Span::styled(format!("<{user}> "), info_style()), - ], - &l.text, - primary_style(), - width, - ), - None => { - wrap_chat_line(vec![time], &l.text, dimmed_style(), width) - } - } - }) - .collect(); + let rendered = wrapped.rows(width, filter, lines, |l| { + let sender = l.username.as_deref().unwrap_or_default(); + if !contains_filter(&l.text, filter) && !contains_filter(sender, filter) + { + return Vec::new(); + } + let time = + Span::styled(l.at.format("%H:%M ").to_string(), dimmed_style()); + match &l.username { + Some(user) => wrap_chat_line( + vec![time, Span::styled(format!("<{user}> "), info_style())], + &l.text, + primary_style(), + width, + ), + None => wrap_chat_line(vec![time], &l.text, dimmed_style(), width), + } + }); let window = view.window(rendered.len(), usize::from(area.height)); frame.render_widget(Paragraph::new(rendered[window].to_vec()), area); } @@ -213,14 +245,15 @@ fn render_messages( fn render_users( frame: &mut Frame, area: Rect, - users: &[String], + users: &[&str], selected: usize, + title: String, ) { let block = Block::default() .borders(Borders::LEFT) .border_style(dimmed_style()) .padding(PANE_PADDING) - .title(plain_title(format!("Users ({})", users.len()), false)); + .title(plain_title(title, false)); let inner = block.inner(area); frame.render_widget(block, area); diff --git a/soulseek-rs/src/ui/styles.rs b/soulseek-rs/src/ui/styles.rs index 0acbaac..3e953ee 100644 --- a/soulseek-rs/src/ui/styles.rs +++ b/soulseek-rs/src/ui/styles.rs @@ -38,6 +38,12 @@ pub const GLYPH_DONE: &str = "โœ“"; pub const GLYPH_FAILED: &str = "โœ—"; pub const GLYPH_TIMED_OUT: &str = "โง—"; pub const GLYPH_CURSOR: &str = "โ–ฎ"; + +/// What a password looks like on screen: one bullet per character. +#[must_use] +pub fn mask(secret: &str) -> String { + "โ€ข".repeat(secret.chars().count()) +} pub const HIGHLIGHT_SYMBOL: &str = "โ€บ"; /// Between a key and its action in the legend, spaced. pub const SHORTCUT_ARROW: &str = " โ†’ "; @@ -138,6 +144,23 @@ pub fn plain_title(title: impl Into, focused: bool) -> Line<'static> { )) } +/// A popup's title: `idle` until its list is filtered, then what it is +/// filtered by, a caret after it while that is still being typed, and how +/// the filter ends. +#[must_use] +pub fn filter_title( + head: &str, + filter: &str, + typing: bool, + idle: &str, +) -> String { + if !typing && filter.is_empty() { + return idle.to_string(); + } + let caret = if typing { "_" } else { "" }; + format!(" {head} ยท filter: {filter}{caret} (Enter: keep, Esc: clear) ") +} + /// The standard bordered pane. Chain `.title(..)` for a legend. pub fn pane_block(focused: bool) -> Block<'static> { Block::default() diff --git a/soulseek-rs/tests/cli_e2e.rs b/soulseek-rs/tests/cli_e2e.rs index b6b0cb9..b05bf93 100644 --- a/soulseek-rs/tests/cli_e2e.rs +++ b/soulseek-rs/tests/cli_e2e.rs @@ -11,8 +11,8 @@ mod common; -use common::{CLI_ENV_VARS, free_port, soulfind_binary}; -use soulseek_rs::{Client, ClientSettings, PeerAddress}; +use common::{CLI_ENV_VARS, Soulfind, free_port, login, settle}; +use soulseek_rs::Client; use std::io::Write; use std::net::{TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; @@ -508,8 +508,9 @@ static SERVER_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(()); struct TestServer { host: String, port: u16, - child: Option, - db: Option, + /// The soulfind spawned for this run; `None` against a server named by + /// `SOULSEEK_TEST_SERVER`. + _server: Option, _gate: std::sync::MutexGuard<'static, ()>, /// Daemons started for this server, one per (user, session flags). Dropped /// with the server, which kills them. @@ -640,41 +641,17 @@ impl TestServer { return Some(Self { host: host.to_string(), port, - child: None, - db: None, + _server: None, _gate: gate, daemons: std::sync::Mutex::new(std::collections::HashMap::new()), }); } - let bin = soulfind_binary()?; - let port = free_port()?; - let db = std::env::temp_dir().join(format!("soulfind-cli-{port}.db")); - let _ = std::fs::remove_file(&db); - - let mut child = Command::new(&bin) - .arg("-p") - .arg(port.to_string()) - .arg("-d") - .arg(&db) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .ok()?; - - if wait_until_listening("127.0.0.1", port, Duration::from_secs(5)) - .is_none() - { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - + let server = Soulfind::start()?; Some(Self { host: "127.0.0.1".to_string(), - port, - child: Some(child), - db: Some(db), + port: server.port(), + _server: Some(server), _gate: gate, daemons: std::sync::Mutex::new(std::collections::HashMap::new()), }) @@ -777,31 +754,7 @@ impl TestServer { /// An in-process client, used to stand up the other end of a test. fn client(&self, user: &str, shares: Vec) -> Client { - let port = free_port().expect("peer port"); - let mut client = Client::with_settings(ClientSettings { - username: user.to_string(), - password: "pw".to_string(), - server_address: PeerAddress::new(self.host.clone(), self.port), - enable_listen: true, - listen_port: port, - shared_directories: shares, - version: soulseek_rs::ClientVersion::default(), - }); - client.connect().expect("peer connect"); - assert!(client.login().expect("peer login"), "peer should log in"); - client - } -} - -impl Drop for TestServer { - fn drop(&mut self) { - if let Some(child) = self.child.as_mut() { - let _ = child.kill(); - let _ = child.wait(); - } - if let Some(db) = self.db.as_ref() { - let _ = std::fs::remove_file(db); - } + login(&self.host, self.port, user, shares) } } @@ -829,17 +782,7 @@ macro_rules! server_or_skip { match TestServer::resolve() { Some(server) => server, None => { - let required = std::env::var("SOULSEEK_E2E_REQUIRED") - .is_ok_and(|v| v != "0" && !v.is_empty()); - assert!( - !required, - "SOULSEEK_E2E_REQUIRED is set but no soulfind server could \ - be started (set SOULFIND_BIN or SOULSEEK_TEST_SERVER)" - ); - println!( - "cli e2e skipped: no soulfind server (set SOULFIND_BIN or \ - SOULSEEK_TEST_SERVER to run)" - ); + common::no_server("cli e2e"); return; } } @@ -936,11 +879,6 @@ fn split_session_flags(args: &[&str]) -> (Vec, Vec) { (session, command) } -/// Wait out the SetWaitPort registrations so peer lookups resolve. -fn settle() { - std::thread::sleep(Duration::from_secs(1)); -} - /// Like [`cli`], but keeping a config file so a command that reads or writes /// one (the wishlist) sees it. /// @@ -2361,6 +2299,87 @@ fn shares_status_reports_what_the_network_will_see() { assert!(record["folders"].as_u64().is_some_and(|n| n >= 1)); } +/// The index, file by file, in the records `browse` gives for a peer โ€” the +/// point being that the two are comparable: what the network sees of us +/// reads exactly like what it sees of anyone else. +#[test] +fn shares_files_lists_the_index_as_a_peer_would_receive_it() { + let server = server_or_skip!(); + let share = Scratch::new("share"); + std::fs::create_dir_all(share.path().join("album")).expect("subfolder"); + std::fs::write(share.path().join("top.bin"), probe_bytes()) + .expect("share file"); + std::fs::write(share.path().join("album").join("deep.bin"), probe_bytes()) + .expect("share file"); + + let listed = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + ], + ); + assert_eq!(code(&listed), EXIT_OK, "stderr: {}", stderr(&listed)); + + let paths: Vec = records(&listed) + .iter() + .map(|line| { + serde_json::from_str::(line).expect("JSON") + ["path"] + .as_str() + .expect("a path") + .to_string() + }) + .collect(); + assert_eq!(paths.len(), 2, "both files, got {paths:?}"); + assert!( + paths.iter().any(|p| p.ends_with("album\\deep.bin")), + "the nested file carries the backslash path peers download by: \ + {paths:?}" + ); + + let first = json_record(&listed); + assert_eq!(first["user"], "cli_e2e_share_files"); + assert!(first["size"].as_u64().is_some_and(|size| size > 0)); + + // The filter narrows to matching paths, and finding nothing is exit 4 + // rather than an empty success. + let narrowed = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + "--filter", + "DEEP", + ], + ); + assert_eq!(code(&narrowed), EXIT_OK, "stderr: {}", stderr(&narrowed)); + assert_eq!(records(&narrowed).len(), 1, "case-insensitively"); + + let missing = cli( + &server, + "cli_e2e_share_files", + &[ + "--shared-dir", + &share.display(), + "--json", + "shares", + "files", + "--filter", + "nothing-like-this", + ], + ); + assert_eq!(code(&missing), EXIT_NO_RESULTS); +} + #[test] fn shares_reindex_picks_up_a_file_added_after_startup() { let server = server_or_skip!(); diff --git a/soulseek-rs/tests/common/mod.rs b/soulseek-rs/tests/common/mod.rs index 0ecb1f5..03ada03 100644 --- a/soulseek-rs/tests/common/mod.rs +++ b/soulseek-rs/tests/common/mod.rs @@ -3,7 +3,11 @@ #![allow(dead_code)] +use soulseek_rs::{Client, ClientSettings, PeerAddress}; +use std::net::TcpStream; use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; /// Every environment variable the binary reads, cleared for each run so a /// developer's shell (or a stray `.env`) cannot change what a test observes โ€” @@ -44,3 +48,117 @@ pub fn free_port() -> Option { .ok() .map(|addr| addr.port()) } + +/// A soulfind spawned for one test, on a port nobody else holds, with a +/// throwaway database that goes with it. +pub struct Soulfind { + child: Child, + db: PathBuf, + port: u16, +} + +impl Soulfind { + pub fn start() -> Option { + let port = free_port()?; + let db = std::env::temp_dir().join(format!("soulfind-{port}.db")); + let _ = std::fs::remove_file(&db); + let child = Self::spawn(port, &db)?; + Some(Self { child, db, port }) + } + + fn spawn(port: u16, db: &Path) -> Option { + let mut child = Command::new(soulfind_binary()?) + .args(["-d", db.to_str()?, "-p", &port.to_string()]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + // Whoever answers on the port has to be this child: one that + // lost the port and died would otherwise pass as up. + if child.try_wait().ok().flatten().is_some() { + return None; + } + if TcpStream::connect(("127.0.0.1", port)).is_ok() { + return Some(child); + } + std::thread::sleep(Duration::from_millis(100)); + } + let _ = child.kill(); + let _ = child.wait(); + None + } + + pub const fn port(&self) -> u16 { + self.port + } + + pub fn address(&self) -> String { + format!("127.0.0.1:{}", self.port) + } + + pub fn stop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + + /// Bring it back on the same port and database. + pub fn restart(&mut self) -> Option<()> { + self.stop(); + self.child = Self::spawn(self.port, &self.db)?; + Some(()) + } + + /// A client logged in to this server as `user`, sharing `shares`. + pub fn client(&self, user: &str, shares: Vec) -> Client { + login("127.0.0.1", self.port, user, shares) + } +} + +impl Drop for Soulfind { + fn drop(&mut self) { + self.stop(); + let _ = std::fs::remove_file(&self.db); + } +} + +/// An in-process client logged in to the server at `host:port` as `user`, +/// sharing `shares`: the other end of a test. +/// +/// The listener stays on: a peer delivers search results and transfers over +/// a connection it opens back to us, so a client without one hears nothing. +pub fn login(host: &str, port: u16, user: &str, shares: Vec) -> Client { + let mut client = Client::with_settings(ClientSettings { + username: user.to_string(), + password: "pw".to_string(), + server_address: PeerAddress::new(host.to_string(), port), + enable_listen: true, + listen_port: free_port().expect("peer port"), + shared_directories: shares, + accept_children: false, + version: soulseek_rs::ClientVersion::default(), + }); + client.connect().expect("peer connect"); + assert!(client.login().expect("peer login"), "peer should log in"); + client +} + +/// Wait out the SetWaitPort registrations so peer lookups resolve. +pub fn settle() { + std::thread::sleep(Duration::from_secs(1)); +} + +/// What a suite does without a server: fail when the run said one is +/// required, otherwise say it skipped. The caller returns afterwards. +pub fn no_server(suite: &str) { + let required = std::env::var("SOULSEEK_E2E_REQUIRED") + .is_ok_and(|v| v != "0" && !v.is_empty()); + assert!( + !required, + "SOULSEEK_E2E_REQUIRED is set but no soulfind server could be \ + started for the {suite} suite (set SOULFIND_BIN)" + ); + println!("{suite} skipped: no soulfind server (set SOULFIND_BIN to run)"); +} diff --git a/soulseek-rs/tests/daemon_protocol_e2e.rs b/soulseek-rs/tests/daemon_protocol_e2e.rs index 88e7bca..60fd48f 100644 --- a/soulseek-rs/tests/daemon_protocol_e2e.rs +++ b/soulseek-rs/tests/daemon_protocol_e2e.rs @@ -13,7 +13,7 @@ mod common; -use common::{CLI_ENV_VARS, free_port, soulfind_binary}; +use common::{CLI_ENV_VARS, Soulfind, free_port}; use std::io::{BufRead, BufReader, Write}; use std::net::TcpStream; use std::path::{Path, PathBuf}; @@ -215,17 +215,7 @@ macro_rules! daemon_or_skip { match Daemon::start() { Some(daemon) => daemon, None => { - let required = std::env::var("SOULSEEK_E2E_REQUIRED") - .is_ok_and(|v| v != "0" && !v.is_empty()); - assert!( - !required, - "SOULSEEK_E2E_REQUIRED is set but no daemon could be \ - started (set SOULFIND_BIN)" - ); - println!( - "daemon protocol e2e skipped: no soulfind server (set \ - SOULFIND_BIN to run)" - ); + common::no_server("daemon protocol e2e"); return; } } @@ -431,66 +421,6 @@ fn discover_serves_a_contract_whose_parameters_match_what_is_accepted() { // Fixtures // --------------------------------------------------------------------------- -struct Soulfind { - child: Child, - db: PathBuf, - port: u16, -} - -impl Soulfind { - fn start() -> Option { - let port = free_port()?; - let db = std::env::temp_dir().join(format!("soulfind-proto-{port}.db")); - let _ = std::fs::remove_file(&db); - let child = Self::spawn(port, &db)?; - Some(Self { child, db, port }) - } - - fn spawn(port: u16, db: &Path) -> Option { - let bin = soulfind_binary()?; - - let mut child = Command::new(bin) - .args(["-d", db.to_str()?, "-p", &port.to_string()]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .ok()?; - - let deadline = Instant::now() + Duration::from_secs(10); - while Instant::now() < deadline { - if TcpStream::connect(("127.0.0.1", port)).is_ok() { - return Some(child); - } - std::thread::sleep(Duration::from_millis(100)); - } - let _ = child.kill(); - let _ = child.wait(); - None - } - - fn address(&self) -> String { - format!("127.0.0.1:{}", self.port) - } - - fn stop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } - - fn restart(&mut self) -> Option<()> { - self.stop(); - self.child = Self::spawn(self.port, &self.db)?; - Some(()) - } -} - -impl Drop for Soulfind { - fn drop(&mut self) { - self.stop(); - let _ = std::fs::remove_file(&self.db); - } -} - fn wait_until(timeout: Duration, mut f: impl FnMut() -> bool) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { diff --git a/soulseek-rs/tests/tui_e2e.rs b/soulseek-rs/tests/tui_e2e.rs new file mode 100644 index 0000000..10db6b1 --- /dev/null +++ b/soulseek-rs/tests/tui_e2e.rs @@ -0,0 +1,232 @@ +//! End-to-end tests of the window itself against a real Soulseek server: +//! key presses in, a screen out, with soulfind and a real sharing peer on the +//! other end of the wire. +//! +//! The CLI suite proves the command surface; the unit tests under `ui` prove +//! the window's keys against a stand-in session. What neither covers is the +//! two together โ€” a key that is supposed to put a search on the network and +//! bring its answers back onto the screen. The window is driven in-process +//! through the same handlers the terminal feeds, rendered onto a test +//! backend, and the session is a real client logged in to a real server. +//! +//! Like the rest of the e2e suite this needs a soulfind server and skips +//! without one; `SOULSEEK_E2E_REQUIRED=1` turns that skip into a failure. + +mod common; + +use common::{Soulfind, settle}; +use ratatui::Terminal; +use ratatui::backend::TestBackend; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use soulseek_rs::Client; +use soulseek_rs_tui::MainTui; +use soulseek_rs_tui::models::SearchStatus; +use soulseek_rs_tui::persist::state::StateStore; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +/// How long a search collects for. Short, because every run here has to wait +/// it out; long enough for a peer on loopback to answer many times over. +const SEARCH_WINDOW: Duration = Duration::from_secs(3); + +macro_rules! server_or_skip { + () => { + match Soulfind::start() { + Some(server) => server, + None => { + common::no_server("tui e2e"); + return; + } + } + }; +} + +/// The window under test: the real `MainTui` over a real session, drawn onto +/// a buffer instead of a terminal. +struct Window { + tui: MainTui, + terminal: Terminal, +} + +impl Window { + /// Open the window as the terminal would on start-up, over `state` โ€” the + /// directory a previous run left its search list in. + fn open(client: Arc, state: &Path, downloads: &Path) -> Self { + let tui = MainTui::new( + client, + downloads.display().to_string(), + SEARCH_WINDOW, + Some(StateStore::new(state.to_path_buf())), + None, + ); + let terminal = Terminal::new(TestBackend::new(160, 40)) + .expect("a test backend always opens"); + Self { tui, terminal } + } + + fn press(&mut self, code: KeyCode) { + self.tui + .handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); + } + + /// One turn of the event loop: catch up with the session, then draw. + /// Returns the screen as the user would see it. + fn frame(&mut self) -> String { + self.tui.poll_session(); + self.terminal + .draw(|frame| self.tui.render(frame)) + .expect("draw"); + self.terminal.backend().to_string() + } + + /// Keep the window running until `done` says so, and hand back the + /// screen it stopped on. Past `timeout` the test fails showing the last + /// screen, what the user would have been looking at, under `what`. + fn run_until( + &mut self, + timeout: Duration, + what: &str, + mut done: impl FnMut(&MainTui, &str) -> bool, + ) -> String { + let deadline = Instant::now() + timeout; + loop { + let screen = self.frame(); + if done(&self.tui, &screen) { + return screen; + } + assert!(Instant::now() <= deadline, "{what}:\n{screen}"); + std::thread::sleep(Duration::from_millis(100)); + } + } + + /// File names in the Results pane right now. + fn result_names(&self) -> Vec { + self.tui + .state() + .results_items + .iter() + .map(|file| file.filename.clone()) + .collect() + } +} + +/// Whether the file the sharer offers has reached the Results pane, on +/// screen as well as in the state behind it. +fn shows_the_shared_file(tui: &MainTui, screen: &str) -> bool { + tui.state() + .results_items + .iter() + .any(|file| file.filename.contains("tui_probe_rerun")) + && screen.contains("tui_probe_rerun") +} + +/// Whether the one search has run its window out. +fn finished(tui: &MainTui, _: &str) -> bool { + tui.state().searches[0].status == SearchStatus::Completed +} + +/// The window's answer to "am I actually sharing that?", against a real +/// scan of a real folder rather than a stand-in listing. +#[test] +fn shift_b_shows_the_scanned_index_the_way_a_peer_would_get_it() { + let server = server_or_skip!(); + + let share = tempfile::tempdir().expect("share dir"); + std::fs::create_dir_all(share.path().join("album")).expect("subfolder"); + std::fs::write( + share.path().join("album").join("tui_probe_own.bin"), + [3u8; 32], + ) + .expect("share file"); + + let state = tempfile::tempdir().expect("state dir"); + let downloads = tempfile::tempdir().expect("download dir"); + let me = Arc::new(server.client( + "tui_e2e_own_shares", + vec![share.path().display().to_string()], + )); + let mut window = Window::open(me, state.path(), downloads.path()); + + window.press(KeyCode::Char('B')); + window.press(KeyCode::Char('L')); + let screen = window.frame(); + assert!(screen.contains("My shares"), "{screen}"); + assert!( + screen.contains("album") && screen.contains("tui_probe_own.bin"), + "the scanned tree, folder by folder:\n{screen}" + ); + + window.press(KeyCode::Esc); + assert!(!window.tui.state().show_browse, "Esc hides the popup"); +} + +#[test] +fn shift_s_runs_a_restored_query_again_and_the_answers_land_on_screen() { + let server = server_or_skip!(); + + // The other end of the wire: a peer sharing one file with a name only + // this test searches for. + let share = tempfile::tempdir().expect("share dir"); + std::fs::write(share.path().join("tui_probe_rerun_vexq.bin"), [7u8; 64]) + .expect("share file"); + let _sharer = server + .client("tui_e2e_sharer", vec![share.path().display().to_string()]); + settle(); + + // Last time this user ran the window they searched for "vexq" and quit. + // The list came back with them; the results did not. + let state = tempfile::tempdir().expect("state dir"); + let downloads = tempfile::tempdir().expect("download dir"); + StateStore::new(state.path().to_path_buf()) + .save_search_queries(&["vexq".to_string()]) + .expect("a previous run's search list"); + + let seeker = Arc::new(server.client("tui_e2e_seeker", Vec::new())); + let mut window = Window::open(seeker, state.path(), downloads.path()); + + let screen = window.frame(); + assert!( + screen.contains("vexq"), + "the old query is listed:\n{screen}" + ); + + // One key, no typing: the window opens on the list with the query + // highlighted. + window.press(KeyCode::Char('S')); + window.run_until( + Duration::from_secs(15), + "the sharer's file should reach the screen", + shows_the_shared_file, + ); + // The search runs its window out and settles, keeping what it found. + let screen = window.run_until( + SEARCH_WINDOW + Duration::from_secs(5), + "the search should finish", + finished, + ); + assert!(shows_the_shared_file(&window.tui, &screen)); + let first_run = window.result_names(); + + // Again, now that the query has been on the wire once in this session: + // the same peer has to answer the same searcher a second time, and the + // window has to start the display over rather than stack the answers. + window.press(KeyCode::Char('1')); + window.press(KeyCode::Char('S')); + window.run_until( + Duration::from_secs(15), + "the second run should be answered too", + shows_the_shared_file, + ); + window.run_until( + SEARCH_WINDOW + Duration::from_secs(5), + "the second run should finish", + finished, + ); + assert_eq!( + window.result_names(), + first_run, + "the same answer, once, not the old set with the new one on top" + ); + assert_eq!(window.tui.state().searches.len(), 1, "still one row"); +} diff --git a/website/package.json b/website/package.json index 7bd0f89..2030bb0 100644 --- a/website/package.json +++ b/website/package.json @@ -9,6 +9,7 @@ "build:ssr": "vite build --ssr src/entry-server.tsx --outDir dist-ssr", "prerender": "bun scripts/prerender.ts", "preview": "vite preview", + "test:install": "sh scripts/test-install.sh", "typecheck": "tsc -b", "lint": "eslint .", "lint:fix": "eslint . --fix" diff --git a/website/public/install.sh b/website/public/install.sh index 1a5561a..165d778 100644 --- a/website/public/install.sh +++ b/website/public/install.sh @@ -1,11 +1,15 @@ #!/bin/sh -# Installs the latest soulseek-rs release. +# Installs soulseek-rs from GitHub releases. # +# Latest stable release: # curl -fsSL https://re-invention.nl/soulseek-rs/install.sh | sh # -# Uses Homebrew when it is on the PATH (macOS and Linux), otherwise downloads -# the prebuilt binary for this platform from GitHub releases, verifies its -# sha256, and puts it in /usr/local/bin or ~/.local/bin. +# Latest successful build from develop: +# curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly +# +# Stable installs use Homebrew when it is on PATH (macOS and Linux). Nightly +# installs always download the prebuilt binary for this platform. Direct +# downloads are sha256-verified and replace an existing binary atomically. # # SOULSEEK_RS_INSTALL_DIR overrides where the binary lands. set -eu @@ -17,23 +21,66 @@ die() { printf 'install.sh: %s\n' "$*" >&2 exit 1 } +usage() { + cat <<'EOF' +Usage: install.sh [--nightly] -case "$(uname -s)" in +Install the latest stable soulseek-rs release. Options: + --nightly Install the latest successful build from the develop branch. + -h, --help Show this help. + +When piping the script, pass the option after `sh -s --`: + curl -fsSL https://raw.githubusercontent.com/michel/soulseek-rs/develop/website/public/install.sh | sh -s -- --nightly + +SOULSEEK_RS_INSTALL_DIR overrides the destination directory. +EOF +} + +channel="stable" +while [ "$#" -gt 0 ]; do + case "$1" in + --nightly) channel="nightly" ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + [ "$#" -eq 0 ] || die "unexpected argument '$1' (try --help)" + break + ;; + *) die "unknown option '$1' (try --help)" ;; + esac + shift +done + +if [ "$channel" = "nightly" ]; then + tag="nightly" + release_page="https://github.com/$REPO/releases/tag/nightly" +else + tag="" + release_page="https://github.com/$REPO/releases/latest" +fi + +system=$(uname -s) +case "$system" in Darwin) os="apple-darwin" ;; Linux) os="unknown-linux-musl" ;; MINGW* | MSYS* | CYGWIN* | Windows_NT) - die "no install script for Windows: take the pc-windows-msvc zip from https://github.com/$REPO/releases/latest, or run 'cargo install soulseek-rs'" ;; - *) die "unsupported OS '$(uname -s)': see https://github.com/$REPO/releases/latest" ;; + die "no install script for Windows: take the pc-windows-msvc zip from $release_page, or run 'cargo install soulseek-rs'" ;; + *) die "unsupported OS '$system': see $release_page" ;; esac -case "$(uname -m)" in +machine=$(uname -m) +case "$machine" in x86_64 | amd64) arch="x86_64" ;; arm64 | aarch64) arch="aarch64" ;; - *) die "unsupported architecture '$(uname -m)': releases cover x86_64 and aarch64" ;; + *) die "unsupported architecture '$machine': builds cover x86_64 and aarch64" ;; esac target="$arch-$os" -if command -v brew >/dev/null 2>&1; then +if [ "$channel" = "stable" ] && [ -z "${SOULSEEK_RS_INSTALL_DIR:-}" ] && + command -v brew >/dev/null 2>&1; then say "Homebrew found: installing michel/tap/soulseek-rs (brew also handles upgrades)" exec brew install michel/tap/soulseek-rs fi @@ -46,47 +93,84 @@ else die "need curl or wget" fi -tag=$(fetch "https://api.github.com/repos/$REPO/releases/latest" | - sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1) -[ -n "$tag" ] || die "could not resolve the latest release; downloads are at https://github.com/$REPO/releases/latest" +command -v tar >/dev/null 2>&1 || die "need tar to unpack the download" +if command -v sha256sum >/dev/null 2>&1; then + sha256() { sha256sum "$1"; } +elif command -v shasum >/dev/null 2>&1; then + sha256() { shasum -a 256 "$1"; } +else + die "need sha256sum or shasum to verify the download" +fi + +if [ "$channel" = "stable" ]; then + tag=$(fetch "https://api.github.com/repos/$REPO/releases/latest" | + sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p' | head -n1) + [ -n "$tag" ] || die "could not resolve the latest release; downloads are at $release_page" + case "$tag" in + v*.*.*) ;; + *) die "latest release returned an unexpected tag '$tag'" ;; + esac + case "$tag" in + *[!0-9A-Za-z._+-]*) die "latest release returned an unsafe tag '$tag'" ;; + esac +fi archive="soulseek-rs-$tag-$target.tar.gz" base_url="https://github.com/$REPO/releases/download/$tag" -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT +tmp="" +staged="" +cleanup() { + [ -z "$staged" ] || rm -f "$staged" >/dev/null 2>&1 || : + [ -z "$tmp" ] || rm -rf "$tmp" >/dev/null 2>&1 || : +} +trap cleanup EXIT HUP INT TERM +tmp=$(mktemp -d) || die "could not create a temporary directory" say "Downloading $archive" -fetch "$base_url/$archive" >"$tmp/$archive" -fetch "$base_url/soulseek-rs-$tag-$target.sha256" >"$tmp/expected.sha256" +fetch "$base_url/$archive" >"$tmp/$archive" || die "could not download $archive from $release_page" +fetch "$base_url/soulseek-rs-$tag-$target.sha256" >"$tmp/expected.sha256" || + die "could not download the sha256 checksum from $release_page" -if command -v sha256sum >/dev/null 2>&1; then - got=$(sha256sum "$tmp/$archive") -else - got=$(shasum -a 256 "$tmp/$archive") -fi +got=$(sha256 "$tmp/$archive") || die "could not calculate the sha256 for $archive" got=${got%% *} -want=$(cut -d' ' -f1 <"$tmp/expected.sha256") +want="" +IFS=' ' read -r want _ <"$tmp/expected.sha256" || : +[ -n "$want" ] || die "the sha256 checksum from $release_page was empty" [ "$got" = "$want" ] || die "sha256 mismatch for $archive: expected $want, got $got" -tar -xzf "$tmp/$archive" -C "$tmp" +tar -xzf "$tmp/$archive" -C "$tmp" || die "could not unpack $archive" [ -f "$tmp/soulseek-rs" ] || die "archive $archive did not contain a soulseek-rs binary" dir="${SOULSEEK_RS_INSTALL_DIR:-}" if [ -z "$dir" ]; then dir="/usr/local/bin" - { [ -d "$dir" ] && [ -w "$dir" ]; } || dir="$HOME/.local/bin" + if ! { [ -d "$dir" ] && [ -w "$dir" ]; }; then + [ -n "${HOME:-}" ] || + die "HOME is not set and /usr/local/bin is not writable; set SOULSEEK_RS_INSTALL_DIR" + dir="$HOME/.local/bin" + fi fi -mkdir -p "$dir" +mkdir -p "$dir" || die "could not create install directory '$dir'" +[ -d "$dir" ] || die "install destination '$dir' is not a directory" +[ ! -d "$dir/soulseek-rs" ] || die "install destination '$dir/soulseek-rs' is a directory" -# mv over a copy, not cp over the target: replacing a running binary in place -# fails with "text file busy" on Linux. -cp "$tmp/soulseek-rs" "$dir/.soulseek-rs.new" -chmod 755 "$dir/.soulseek-rs.new" -mv -f "$dir/.soulseek-rs.new" "$dir/soulseek-rs" +staged=$(mktemp "$dir/.soulseek-rs.XXXXXX") || + die "could not create a temporary file in '$dir'" +cp "$tmp/soulseek-rs" "$staged" || die "could not stage the downloaded binary in '$dir'" +chmod 755 "$staged" || die "could not make the downloaded binary executable" +version=$("$staged" --version) || + die "the downloaded binary could not run on this machine; the existing install was not changed" +case "$version" in + soulseek-rs*) ;; + *) die "the downloaded binary returned unexpected version output '$version'; the existing install was not changed" ;; +esac -say "Installed $("$dir/soulseek-rs" --version) to $dir/soulseek-rs" -case ":$PATH:" in +mv -f "$staged" "$dir/soulseek-rs" || die "could not replace '$dir/soulseek-rs'" +staged="" + +say "Installed $version ($channel) to $dir/soulseek-rs" +case ":${PATH:-}:" in *:"$dir":*) ;; *) say "note: $dir is not on your PATH; add it with: export PATH=\"$dir:\$PATH\"" ;; esac diff --git a/website/scripts/test-install.sh b/website/scripts/test-install.sh index 91cae23..4465f20 100644 --- a/website/scripts/test-install.sh +++ b/website/scripts/test-install.sh @@ -1,17 +1,17 @@ #!/bin/sh -# Exercises public/install.sh: target detection, the brew fast path, checksum -# verification, and a real install into a throwaway prefix. Network tests hit -# GitHub; run from a machine that can reach it. +# Hermetic coverage for public/install.sh. Set INSTALL_TEST_NETWORK=stable to +# add a live stable-release install, or =all after a nightly release exists to +# exercise both public channels. set -eu here=$(cd "$(dirname "$0")" && pwd) script="$here/../public/install.sh" tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT +trap 'rm -rf "$tmp"' EXIT HUP INT TERM failures=0 report() { - if [ "$1" = 0 ]; then + if [ "$1" -eq 0 ]; then printf 'ok %s\n' "$2" else printf 'FAIL %s\n' "$2" @@ -20,33 +20,133 @@ report() { } contains() { case "$1" in *"$2"*) true ;; *) false ;; esac; } -# Stub uname reads STUB_OS / STUB_ARCH; stub curl logs every URL it is asked -# for, answers the release-API call with a canned tag, and refuses the rest so -# a detection test stops before downloading anything. +if command -v sha256sum >/dev/null 2>&1; then + fixture_sha256() { sha256sum "$1"; } +else + fixture_sha256() { shasum -a 256 "$1"; } +fi + +mkdir -p "$tmp/fixtures/good" "$tmp/fixtures/missing" "$tmp/fixtures/bad-version" \ + "$tmp/fixtures/cannot-run" +printf '#!/bin/sh\nprintf "soulseek-rs 99.0.0-test\\n"\n' >"$tmp/fixtures/good/soulseek-rs" +printf 'no binary here\n' >"$tmp/fixtures/missing/README.md" +printf '#!/bin/sh\nprintf "not-the-client 99.0.0-test\\n"\n' >"$tmp/fixtures/bad-version/soulseek-rs" +printf '#!/bin/sh\nexit 7\n' >"$tmp/fixtures/cannot-run/soulseek-rs" +chmod +x "$tmp/fixtures/good/soulseek-rs" "$tmp/fixtures/bad-version/soulseek-rs" \ + "$tmp/fixtures/cannot-run/soulseek-rs" +tar -czf "$tmp/fixtures/good.tar.gz" -C "$tmp/fixtures/good" soulseek-rs +tar -czf "$tmp/fixtures/missing.tar.gz" -C "$tmp/fixtures/missing" README.md +tar -czf "$tmp/fixtures/bad-version.tar.gz" -C "$tmp/fixtures/bad-version" soulseek-rs +tar -czf "$tmp/fixtures/cannot-run.tar.gz" -C "$tmp/fixtures/cannot-run" soulseek-rs +printf 'this is not a tar archive\n' >"$tmp/fixtures/corrupt.tar.gz" +fixture_sum=$(fixture_sha256 "$tmp/fixtures/good.tar.gz") +fixture_sum=${fixture_sum%% *} +printf '%s fixture.tar.gz\n' "$fixture_sum" >"$tmp/fixtures/good.sha256" +missing_sum=$(fixture_sha256 "$tmp/fixtures/missing.tar.gz") +missing_sum=${missing_sum%% *} +printf '%s fixture.tar.gz\n' "$missing_sum" >"$tmp/fixtures/missing.sha256" +bad_version_sum=$(fixture_sha256 "$tmp/fixtures/bad-version.tar.gz") +bad_version_sum=${bad_version_sum%% *} +printf '%s fixture.tar.gz\n' "$bad_version_sum" >"$tmp/fixtures/bad-version.sha256" +cannot_run_sum=$(fixture_sha256 "$tmp/fixtures/cannot-run.tar.gz") +cannot_run_sum=${cannot_run_sum%% *} +printf '%s fixture.tar.gz\n' "$cannot_run_sum" >"$tmp/fixtures/cannot-run.sha256" +corrupt_sum=$(fixture_sha256 "$tmp/fixtures/corrupt.tar.gz") +corrupt_sum=${corrupt_sum%% *} +printf '%s fixture.tar.gz\n' "$corrupt_sum" >"$tmp/fixtures/corrupt.sha256" + +# Every fetch is local. The same stub accepts curl's and wget's argument forms, +# records the final URL, and can inject each failure mode the installer handles. mkdir -p "$tmp/stub" cat >"$tmp/stub/uname" <<'EOF' #!/bin/sh +[ -z "${UNAME_LOG:-}" ] || printf '%s\n' "${1:-system}" >>"$UNAME_LOG" case "${1:-}" in - -m) echo "$STUB_ARCH" ;; - *) echo "$STUB_OS" ;; + -m) printf '%s\n' "$STUB_ARCH" ;; + *) printf '%s\n' "$STUB_OS" ;; esac EOF -cat >"$tmp/stub/curl" <"$tmp/stub/fetch" <<'EOF' #!/bin/sh -for a; do url=\$a; done -echo "\$url" >>"$tmp/curl.log" -case "\$url" in - *api.github.com*) echo '{"tag_name": "v12.0.0"}' ;; +url="" +for argument do url=$argument; done +[ -z "${FETCH_LOG:-}" ] || printf '%s\n' "$url" >>"$FETCH_LOG" +case "$url" in + *api.github.com*) + [ "${API_MODE:-ok}" = "ok" ] || exit 22 + printf '{"tag_name": "%s"}\n' "${API_TAG:-v12.0.0}" + ;; + *.sha256) + case "${CHECKSUM_MODE:-good}" in + good) cat "$FIXTURE_DIR/${ARCHIVE_MODE:-good}.sha256" ;; + bad) printf '%064d fixture.tar.gz\n' 0 ;; + empty) : ;; + fail) exit 22 ;; + *) exit 64 ;; + esac + ;; + *.tar.gz) + case "${ARCHIVE_MODE:-good}" in + good) cat "$FIXTURE_DIR/good.tar.gz" ;; + missing) cat "$FIXTURE_DIR/missing.tar.gz" ;; + bad-version) cat "$FIXTURE_DIR/bad-version.tar.gz" ;; + cannot-run) cat "$FIXTURE_DIR/cannot-run.tar.gz" ;; + corrupt) cat "$FIXTURE_DIR/corrupt.tar.gz" ;; + fail) exit 22 ;; + *) exit 64 ;; + esac + ;; *) exit 22 ;; esac EOF -chmod +x "$tmp/stub/uname" "$tmp/stub/curl" -base_path="/usr/bin:/bin" +cat >"$tmp/brew-stub" <<'EOF' +#!/bin/sh +printf '%s\n' "$*" >"$BREW_LOG" +[ "${BREW_MODE:-ok}" = ok ] || exit 23 +EOF +chmod +x "$tmp/stub/uname" "$tmp/stub/fetch" "$tmp/brew-stub" +ln -s "$tmp/stub/fetch" "$tmp/stub/curl" + +base_path="$tmp/stub:/usr/bin:/bin" +fetch_log="$tmp/fetch.log" +uname_log="$tmp/uname.log" +brew_log="$tmp/brew.log" + +fixture_install() { + destination=$1 + shift + : >"$fetch_log" + STUB_OS="${STUB_OS:-Darwin}" STUB_ARCH="${STUB_ARCH:-arm64}" \ + FETCH_LOG="$fetch_log" FIXTURE_DIR="$tmp/fixtures" \ + SOULSEEK_RS_INSTALL_DIR="$destination" PATH="$base_path" \ + /bin/sh "$script" "$@" +} + +# Argument parsing happens before platform detection, including when piped via +# `sh -s --`. Help must therefore work even on an unsupported machine. +: >"$uname_log" +out=$(STUB_OS=Plan9 STUB_ARCH=mips UNAME_LOG="$uname_log" PATH="$base_path" \ + /bin/sh "$script" --help 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && contains "$out" "sh -s -- --nightly" && [ ! -s "$uname_log" ]; then res=0; fi +report "$res" "--help works without probing the machine" + +out=$(PATH="$base_path" /bin/sh "$script" --unknown 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unknown option '--unknown'"; then res=0; fi +report "$res" "unknown options fail with guidance" + +out=$(PATH="$base_path" /bin/sh "$script" -- nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unexpected argument 'nightly'"; then res=0; fi +report "$res" "positional arguments are rejected" resolved_target() { - : >"$tmp/curl.log" - STUB_OS="$1" STUB_ARCH="$2" PATH="$tmp/stub:$base_path" sh "$script" >/dev/null 2>&1 || true - grep -o 'soulseek-rs-v12\.0\.0-[a-z0-9_-]*\.tar\.gz' "$tmp/curl.log" | head -n1 + : >"$fetch_log" + STUB_OS="$1" STUB_ARCH="$2" ARCHIVE_MODE=fail FETCH_LOG="$fetch_log" \ + FIXTURE_DIR="$tmp/fixtures" SOULSEEK_RS_INSTALL_DIR="$tmp/detection" \ + PATH="$base_path" /bin/sh "$script" >/dev/null 2>&1 || true + sed -n 's#^.*/\(soulseek-rs-v12\.0\.0-[a-z0-9_-]*\.tar\.gz\)$#\1#p' "$fetch_log" | head -n1 } for spec in \ @@ -54,102 +154,246 @@ for spec in \ "Darwin x86_64 x86_64-apple-darwin" \ "Linux aarch64 aarch64-unknown-linux-musl" \ "Linux amd64 x86_64-unknown-linux-musl"; do - # spec is three space-separated fields, so the splitting is the point + # The record deliberately relies on POSIX field splitting. # shellcheck disable=SC2086 set -- $spec res=1 if [ "$(resolved_target "$1" "$2")" = "soulseek-rs-v12.0.0-$3.tar.gz" ]; then res=0; fi - report $res "$1/$2 resolves to $3" + report "$res" "$1/$2 resolves to $3" done -out=$(STUB_OS="MINGW64_NT-10.0" STUB_ARCH=x86_64 PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? +out=$(STUB_OS=MINGW64_NT-10.0 STUB_ARCH=x86_64 PATH="$base_path" \ + /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? res=1 -if [ "$rc" != 0 ] && contains "$out" "pc-windows-msvc"; then res=0; fi -report $res "Windows refuses and points at the msvc zip" +if [ "$rc" -ne 0 ] && contains "$out" "pc-windows-msvc" && contains "$out" "releases/tag/nightly"; then res=0; fi +report "$res" "Windows refuses and links to the selected channel" -out=$(STUB_OS=Linux STUB_ARCH=armv7l PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? +out=$(STUB_OS=Linux STUB_ARCH=armv7l PATH="$base_path" /bin/sh "$script" 2>&1) && rc=0 || rc=$? res=1 -if [ "$rc" != 0 ] && contains "$out" "unsupported architecture"; then res=0; fi -report $res "armv7l refuses as unsupported" +if [ "$rc" -ne 0 ] && contains "$out" "unsupported architecture 'armv7l'"; then res=0; fi +report "$res" "unsupported architectures fail before downloading" -# Brew fast path: a stub brew must be exec'd with the tap formula, and no URL -# may be fetched. -cat >"$tmp/stub/brew" <"$tmp/brew.log" -EOF -chmod +x "$tmp/stub/brew" -: >"$tmp/curl.log" -STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/stub:$base_path" sh "$script" >/dev/null 2>&1 +out=$(STUB_OS=Plan9 STUB_ARCH=x86_64 PATH="$base_path" /bin/sh "$script" 2>&1) && rc=0 || rc=$? res=1 -if [ "$(cat "$tmp/brew.log")" = "install michel/tap/soulseek-rs" ] && [ ! -s "$tmp/curl.log" ]; then res=0; fi -report $res "brew on PATH wins and skips the download" -rm -f "$tmp/stub/brew" +if [ "$rc" -ne 0 ] && contains "$out" "unsupported OS 'Plan9'"; then res=0; fi +report "$res" "unsupported operating systems fail before downloading" -# Offline happy path: stub curl serves a locally built archive whose sha256 -# matches, and the installed fake binary must answer --version. -mkdir -p "$tmp/payload" "$tmp/bin" -printf '#!/bin/sh\necho "soulseek-rs 99.0.0-test"\n' >"$tmp/payload/soulseek-rs" -chmod +x "$tmp/payload/soulseek-rs" -tar -czf "$tmp/payload.tar.gz" -C "$tmp/payload" soulseek-rs -sum=$(shasum -a 256 "$tmp/payload.tar.gz" | cut -d' ' -f1) -cat >"$tmp/stub/curl" <>"$tmp/curl.log" -case "\$url" in - *api.github.com*) echo '{"tag_name": "v12.0.0"}' ;; - *.tar.gz) cat "$tmp/payload.tar.gz" ;; - *.sha256) echo "$sum *payload" ;; - *) exit 22 ;; +# Stable uses Homebrew when available. Nightly must bypass it because the tap +# intentionally tracks stable releases only. +mkdir -p "$tmp/with-brew" +ln -s "$tmp/stub/uname" "$tmp/with-brew/uname" +ln -s "$tmp/brew-stub" "$tmp/with-brew/brew" +ln -s "$tmp/stub/fetch" "$tmp/with-brew/curl" +: >"$fetch_log" +BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 FETCH_LOG="$fetch_log" \ + PATH="$tmp/with-brew:/usr/bin:/bin" /bin/sh "$script" >/dev/null 2>&1 +res=1 +if [ "$(cat "$brew_log")" = "install michel/tap/soulseek-rs" ] && [ ! -s "$fetch_log" ]; then res=0; fi +report "$res" "stable uses Homebrew without touching GitHub" + +out=$(BREW_MODE=fail BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 \ + PATH="$tmp/with-brew:/usr/bin:/bin" /bin/sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 23 ]; then res=0; fi +report "$res" "Homebrew failures propagate to the caller" + +nightly_dir="$tmp/nightly install/bin" +: >"$brew_log" +out=$(BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 \ + FETCH_LOG="$fetch_log" FIXTURE_DIR="$tmp/fixtures" \ + SOULSEEK_RS_INSTALL_DIR="$nightly_dir" PATH="$tmp/with-brew:/usr/bin:/bin" \ + /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$nightly_dir/soulseek-rs" ] && + contains "$out" "(nightly)" && [ ! -s "$brew_log" ] && + ! grep -q 'api.github.com' "$fetch_log" && + grep -q '/nightly/soulseek-rs-nightly-aarch64-apple-darwin.tar.gz$' "$fetch_log"; then res=0; fi +report "$res" "--nightly bypasses Homebrew and the stable-release API" + +custom_stable_dir="$tmp/custom stable/bin" +: >"$brew_log" +out=$(BREW_LOG="$brew_log" STUB_OS=Darwin STUB_ARCH=arm64 \ + FETCH_LOG="$fetch_log" FIXTURE_DIR="$tmp/fixtures" \ + SOULSEEK_RS_INSTALL_DIR="$custom_stable_dir" PATH="$tmp/with-brew:/usr/bin:/bin" \ + /bin/sh "$script" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$custom_stable_dir/soulseek-rs" ] && + [ ! -s "$brew_log" ] && grep -q 'api.github.com' "$fetch_log"; then res=0; fi +report "$res" "custom install directory overrides Homebrew" + +stable_dir="$tmp/stable/bin" +out=$(fixture_install "$stable_dir" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$stable_dir/soulseek-rs" ] && + [ "$("$stable_dir/soulseek-rs" --version)" = "soulseek-rs 99.0.0-test" ] && + contains "$out" "(stable)" && + grep -q '/v12.0.0/soulseek-rs-v12.0.0-aarch64-apple-darwin.tar.gz$' "$fetch_log"; then res=0; fi +report "$res" "verified stable archive installs and runs" + +res=1 +if contains "$out" "$stable_dir is not on your PATH"; then res=0; fi +report "$res" "a destination off PATH gets a usable PATH hint" + +# A minimal PATH with wget but no curl proves the fallback instead of merely +# exercising a wget-shaped stub while curl still wins command discovery. +mkdir -p "$tmp/wget-path" +for tool in cat chmod cp gzip head mkdir mktemp mv rm sed shasum sha256sum tar; do + tool_path=$(command -v "$tool" 2>/dev/null || true) + [ -z "$tool_path" ] || ln -s "$tool_path" "$tmp/wget-path/$tool" +done +ln -s "$tmp/stub/uname" "$tmp/wget-path/uname" +ln -s "$tmp/stub/fetch" "$tmp/wget-path/wget" +wget_dir="$tmp/wget/bin" +: >"$fetch_log" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 FETCH_LOG="$fetch_log" \ + FIXTURE_DIR="$tmp/fixtures" SOULSEEK_RS_INSTALL_DIR="$wget_dir" \ + PATH="$tmp/wget-path" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -eq 0 ] && [ -x "$wget_dir/soulseek-rs" ]; then res=0; fi +report "$res" "wget-only installation works" + +# Failures before replacement must preserve an existing install and leave no +# fixed-name staging file behind. +preserve_dir="$tmp/preserve/bin" +mkdir -p "$preserve_dir" +printf '#!/bin/sh\nprintf "soulseek-rs old\\n"\n' >"$preserve_dir/soulseek-rs" +chmod +x "$preserve_dir/soulseek-rs" +out=$(CHECKSUM_MODE=bad fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "sha256 mismatch" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "checksum mismatch preserves the installed binary" + +out=$(CHECKSUM_MODE=empty fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "checksum" && contains "$out" "empty"; then res=0; fi +report "$res" "empty checksum is rejected" + +out=$(ARCHIVE_MODE=missing fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "did not contain a soulseek-rs binary" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "archive without the binary preserves the installed binary" + +out=$(ARCHIVE_MODE=bad-version fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unexpected version output" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "wrong executable preserves the installed binary" + +out=$(ARCHIVE_MODE=cannot-run fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not run on this machine" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "non-running executable preserves the installed binary" + +out=$(ARCHIVE_MODE=corrupt fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not unpack" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "corrupt archive preserves the installed binary" + +out=$(ARCHIVE_MODE=fail fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not download soulseek-rs-nightly" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "archive download failure preserves the installed binary" + +out=$(CHECKSUM_MODE=fail fixture_install "$preserve_dir" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not download the sha256 checksum" && + [ "$("$preserve_dir/soulseek-rs")" = "soulseek-rs old" ]; then res=0; fi +report "$res" "checksum download failure preserves the installed binary" + +directory_target="$tmp/directory-target/bin" +mkdir -p "$directory_target/soulseek-rs" +out=$(fixture_install "$directory_target" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "is a directory" && + [ ! -e "$directory_target/soulseek-rs/.soulseek-rs.new" ]; then res=0; fi +report "$res" "a directory at the binary path is never written into" + +out=$(API_MODE=fail fixture_install "$tmp/api-failure" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "could not resolve the latest release"; then res=0; fi +report "$res" "stable API failure is explained" + +: >"$fetch_log" +out=$(API_TAG='../nightly' fixture_install "$tmp/unsafe-tag" 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "unexpected tag" && + [ "$(wc -l <"$fetch_log" | tr -d ' ')" = 1 ]; then res=0; fi +report "$res" "unexpected stable tag is rejected before asset download" + +mkdir -p "$tmp/no-fetch" +ln -s "$tmp/stub/uname" "$tmp/no-fetch/uname" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/no-fetch" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "need curl or wget"; then res=0; fi +report "$res" "missing downloader is reported" + +mkdir -p "$tmp/no-tar" +ln -s "$tmp/stub/uname" "$tmp/no-tar/uname" +ln -s "$tmp/stub/fetch" "$tmp/no-tar/curl" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/no-tar" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "need tar"; then res=0; fi +report "$res" "missing tar is reported before downloading" + +mkdir -p "$tmp/no-sha" +ln -s "$tmp/stub/uname" "$tmp/no-sha/uname" +ln -s "$tmp/stub/fetch" "$tmp/no-sha/curl" +tar_path=$(command -v tar) +ln -s "$tar_path" "$tmp/no-sha/tar" +out=$(STUB_OS=Darwin STUB_ARCH=arm64 PATH="$tmp/no-sha" /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? +res=1 +if [ "$rc" -ne 0 ] && contains "$out" "need sha256sum or shasum"; then res=0; fi +report "$res" "missing checksum tool is reported before downloading" + +# Two callers can update the same path without sharing a predictable staging +# name. Either can win; the result must run and no staging file may remain. +concurrent_dir="$tmp/concurrent/bin" +fixture_install "$concurrent_dir" --nightly >/dev/null 2>&1 & first=$! +fixture_install "$concurrent_dir" --nightly >/dev/null 2>&1 & second=$! +first_rc=0 +second_rc=0 +wait "$first" || first_rc=$? +wait "$second" || second_rc=$? +set -- "$concurrent_dir"/.soulseek-rs.* +res=1 +if [ "$first_rc" -eq 0 ] && [ "$second_rc" -eq 0 ] && + [ "$("$concurrent_dir/soulseek-rs" --version)" = "soulseek-rs 99.0.0-test" ] && + [ ! -e "$1" ]; then res=0; fi +report "$res" "concurrent installers leave one complete binary" + +case "${INSTALL_TEST_NETWORK:-}" in + "") ;; + stable | all) + network_path="/usr/bin:/bin" + live_stable="$tmp/live-stable" + out=$(SOULSEEK_RS_INSTALL_DIR="$live_stable" PATH="$network_path" \ + /bin/sh "$script" 2>&1) && rc=0 || rc=$? + res=1 + if [ "$rc" -eq 0 ] && "$live_stable/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi + report "$res" "live stable release installs and runs" + + if [ "${INSTALL_TEST_NETWORK:-}" = all ]; then + live_nightly="$tmp/live-nightly" + out=$(SOULSEEK_RS_INSTALL_DIR="$live_nightly" PATH="$network_path" \ + /bin/sh "$script" --nightly 2>&1) && rc=0 || rc=$? + res=1 + if [ "$rc" -eq 0 ] && "$live_nightly/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi + report "$res" "live nightly release installs and runs" + fi + ;; + *) + printf 'FAIL INSTALL_TEST_NETWORK must be empty, stable, or all\n' + failures=$((failures + 1)) + ;; esac -EOF -out=$(STUB_OS=Darwin STUB_ARCH=arm64 SOULSEEK_RS_INSTALL_DIR="$tmp/bin" \ - PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? -res=1 -if [ "$rc" = 0 ] && [ -x "$tmp/bin/soulseek-rs" ] && - contains "$out" "Installed soulseek-rs 99.0.0-test"; then res=0; fi -report $res "verified archive installs and reports the version" -res=1 -if contains "$out" "not on your PATH"; then res=0; fi -report $res "install dir off the PATH gets the PATH note" -# Tampered archive: same stub but a wrong checksum must abort before install. -rm -f "$tmp/bin/soulseek-rs" -sed "s/$sum/0000000000000000000000000000000000000000000000000000000000000000/" \ - "$tmp/stub/curl" >"$tmp/stub/curl.bad" && mv "$tmp/stub/curl.bad" "$tmp/stub/curl" -chmod +x "$tmp/stub/curl" -out=$(STUB_OS=Darwin STUB_ARCH=arm64 SOULSEEK_RS_INSTALL_DIR="$tmp/bin" \ - PATH="$tmp/stub:$base_path" sh "$script" 2>&1) && rc=0 || rc=$? -res=1 -if [ "$rc" != 0 ] && [ ! -e "$tmp/bin/soulseek-rs" ] && - contains "$out" "sha256 mismatch"; then res=0; fi -report $res "sha256 mismatch aborts without installing" - -# Real network installs: once over curl, once over wget (a symlink farm of -# /usr/bin and /bin minus curl and brew forces the wget code path). -out=$(SOULSEEK_RS_INSTALL_DIR="$tmp/real-curl" PATH="$base_path" sh "$script" 2>&1) && rc=0 || rc=$? -res=1 -if [ "$rc" = 0 ] && "$tmp/real-curl/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi -report $res "real release installs via curl and runs" - -if wget=$(command -v wget); then - mkdir -p "$tmp/farm" - for d in /usr/bin /bin; do - for f in "$d"/*; do ln -sf "$f" "$tmp/farm/${f##*/}"; done - done - rm -f "$tmp/farm/curl" "$tmp/farm/brew" - ln -sf "$wget" "$tmp/farm/wget" - out=$(SOULSEEK_RS_INSTALL_DIR="$tmp/real-wget" PATH="$tmp/farm" sh "$script" 2>&1) && rc=0 || rc=$? - res=1 - if [ "$rc" = 0 ] && "$tmp/real-wget/soulseek-rs" --version >/dev/null 2>&1; then res=0; fi - report $res "real release installs via wget and runs" -else - echo "skip real release installs via wget (no wget on this machine)" -fi - -[ "$failures" = 0 ] || { - echo "$failures test(s) failed" +[ "$failures" -eq 0 ] || { + printf '%s test(s) failed\n' "$failures" exit 1 } -echo "all tests passed" +printf 'all tests passed\n' diff --git a/website/src/components/tui/hero-terminal.tsx b/website/src/components/tui/hero-terminal.tsx index 02221bf..4cc2267 100644 --- a/website/src/components/tui/hero-terminal.tsx +++ b/website/src/components/tui/hero-terminal.tsx @@ -37,8 +37,9 @@ export const HeroTerminal = () => { > - {/* Results across the top with the whole width for long names; - Searches, Transfers and Info share the row underneath. */} + {/* Results across the top, file names in a column of their own + with the folder beside them; Searches, Transfers and Info + share the row underneath. */}
โœ“ Filename + Folder Size User Bitrate @@ -143,6 +144,7 @@ export const ResultsPane = ({
{rows.map((row, i) => { const { glyph, tone } = resultGlyph(row) + const parts = row.name.split('\\') const isSelected = selected === i return ( +
@@ -342,14 +357,7 @@ const Steps = () => ( Write your own client on soulseek-rs-lib: the protocol implementation, separate from the TUI.

- +

Full API reference on docs.rs. The lib API