DEV Community

Ishan Naik
Ishan Naik

Posted on Originally published at github.com

Building a Terminal UI BitTorrent Client in Rust with Ratatui and Local Streaming

A video player reads a file in playback order. A BitTorrent peer can deliver whichever pieces it has. When I built Harbour, I had to connect those two access patterns without making the terminal wait for the swarm.

Harbour lets you search, download, and open a video in VLC or mpv from one terminal window. You can start playback before the download finishes. I use Rust 2024, Ratatui for rendering, Tokio for asynchronous work, and librqbit for the BitTorrent engine.

Harbour TUI Demo

The demo uses the project's Creative Commons catalog, including Blender Foundation films. Use files you own or have permission to distribute. A protocol-neutral client does not grant distribution rights.

Keep rendering away from network work

I keep the engine adapter in src/engine/rqbit.rs. The queue and UI work with Harbour's types rather than librqbit handles. That boundary gives me one place to translate engine statistics, metadata, and errors.

For example, librqbit reports speeds in MiB/s. I convert those values in the adapter rather than asking each widget to remember the engine's units. I also represent an unavailable peer count with Option, because a paused torrent has no live peer statistics. Showing zero would confuse “no measurement” with “connected to no peers.”

I use Tokio tasks for asynchronous operations and event channels to bring results back to application state. Ratatui renders that state. I do not fetch metadata or wait for a peer inside a widget's draw function.

flowchart LR
    User[Keyboard and mouse input] --> App[Harbour application state]
    App --> Queue[Queue manager]
    Queue --> Adapter[librqbit adapter]
    Adapter --> Swarm[DHT, trackers, and peers]
    Adapter --> Events[Tokio event channels]
    Events --> App
    App --> UI[Ratatui views]
    App --> Source[HTTP Source adapter]
    Source --> Indexer[Local indexer API]
    Adapter --> HTTP[Loopback HTTP stream]
    HTTP --> Player[VLC or mpv]
    Queue --> Ledger[downloads.json]

I target a 30fps terminal render cadence. That number describes the UI schedule, not torrent throughput or video frame rate. A swarm can stop delivering data while you keep navigating the queue.

For this layout, I need separate clocks: network work follows socket readiness, playback follows the player's requests, and terminal rendering follows the UI cadence. Coupling those clocks would make a slow peer visible as input lag.

Watch mode: a file reader backed by incomplete data

BitTorrent downloads do not have to arrive in file order. A peer might supply a piece near the end before anyone supplies the next piece needed for playback. The engine must verify received pieces and make requested bytes available in the order the reader needs them.

For watch mode, I delegate this work to librqbit's HTTP API. In Harbour's adapter, stream_server() creates a Tokio listener with:

let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.ok()?;
Enter fullscreen mode Exit fullscreen mode

The :0 asks the operating system for an available port. I bind to 127.0.0.1 rather than 0.0.0.0, so the playback endpoint does not listen on the machine's LAN interfaces. Loopback limits network exposure; it does not authenticate other processes running on the same machine.

I start the server on the first watch request and reuse it for later requests. Harbour constructs a URL with this shape:

http://127.0.0.1:<assigned-port>/torrents/<info-hash>/stream/<file-id>
Enter fullscreen mode Exit fullscreen mode

I pass that URL to the external player. VLC or mpv then requests bytes over HTTP while Tokio workers continue contacting peers through DHT and trackers. The player uses its own demuxer, codecs, and playback buffers. I do not decode video inside the TUI.

sequenceDiagram
    autonumber
    participant U as User
    participant H as Harbour
    participant B as librqbit piece scheduler
    participant P as Swarm peers
    participant S as Localhost HTTP server
    participant V as VLC or mpv
    U->>H: Watch selected video
    H->>B: Resolve metadata and select file
    H->>S: Start or reuse 127.0.0.1 listener
    H->>V: Launch player with stream URL
    V->>S: HTTP request for file bytes
    S->>B: Read requested byte range
    B->>P: Prioritize pieces needed by reader
    P-->>B: Piece blocks
    B->>B: Verify and expose ordered bytes
    B-->>S: Contiguous bytes for requested range
    S-->>V: Stream response body
    V->>V: Buffer, demux, and decode
    Note over B,V: Missing required pieces can stall playback
    V->>S: Range request after seek
    S->>B: Read new offset and prioritize pieces

The “playback pipe” here means the HTTP response body. Harbour launches the player with a URL, rather than pushing video into its standard input.

Sequential reads still need random access

During normal playback, the player consumes bytes in sequence. On a seek, it can ask for a different range. Some media containers also require information outside the first bytes of the file.

A production endpoint therefore needs byte-range handling, including 206 Partial Content, Content-Range, and correct offsets. A stream that serves only the first byte onward can support a simple forward reader but cannot satisfy the player's full file-access contract.

I avoid building a second piece scheduler or range server in Harbour. The adapter uses librqbit::http_api::HttpApi, which keeps the HTTP reader connected to the engine's piece availability and prioritization.

I also wait for torrent metadata before returning the default video URL. The current adapter allows eight seconds and polls every 200ms. If metadata never arrives, or the torrent contains no recognized video file, Harbour can warn you before launching a player with an unusable URL.

For the default selection, I choose the largest recognized video file. A season pack may contain a small sample.mkv; alphabetical selection can send the player to that sample. File selection needs torrent metadata, not a guess based on the search result's display name.

The channel boundary behind an HTTP body

You can model the asynchronous handoff with a bounded Tokio channel. The producer reads ordered, verified bytes; the HTTP consumer drains the channel as the connection permits.

The following teaching example uses Axum's Body::from_stream, which supplies a body for the underlying Hyper stack. It illustrates the channel boundary, not Harbour's implementation. Harbour calls librqbit's HTTP API instead of adding this helper.

use std::io;

use axum::body::{Body, Bytes};
use futures_util::StreamExt;
use tokio::{io::AsyncRead, sync::mpsc};
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::io::ReaderStream;

// Pass a torrent-backed reader that yields verified bytes in file order.
// Range parsing and response headers belong to the HTTP handler.
fn body_from_verified_reader<R>(reader: R) -> Body
where
    R: AsyncRead + Unpin + Send + 'static,
{
    let (tx, rx) = mpsc::channel::<Result<Bytes, io::Error>>(8);

    tokio::spawn(async move {
        let mut chunks = ReaderStream::with_capacity(reader, 64 * 1024);

        while let Some(chunk) = chunks.next().await {
            let failed = chunk.is_err();

            // A full channel suspends this producer. A disconnected
            // player drops the receiver and makes send return an error.
            if tx.send(chunk).await.is_err() || failed {
                break;
            }
        }
    });

    Body::from_stream(ReceiverStream::new(rx))
}
Enter fullscreen mode Exit fullscreen mode

For a standalone example, include axum, futures-util, tokio, tokio-stream, and tokio-util; enable Tokio's runtime, I/O, and synchronization support and tokio-util's io feature. Those are example dependencies, not Harbour's direct dependency list.

I use eight channel slots to bound the number of chunks waiting at this handoff. With the reader's 64KiB capacity, that suggests roughly 512KiB of queued payload under typical chunking. It does not cap memory in the torrent engine, operating system, or player, and the slot count alone does not enforce a byte limit for arbitrary producers.

When the channel fills, send().await pauses this reader task rather than blocking an operating-system thread. When the player disconnects, a later send fails and the task exits. A producer waiting for a missing piece may need explicit cancellation to stop before that read completes; channel closure by itself cannot interrupt an unrelated pending read.

The reader must also propagate a missing-piece read failure as an error. Returning end-of-stream would tell the player that the file ended, which hides the transfer failure.

Keep search providers outside the client

I use the Stremio/Jackett addon paradigm for search: Harbour contains zero built-in scrapers and talks to a decoupled HTTP indexer. This describes the architectural split, not automatic compatibility with either project's wire protocol.

The default indexer URL is http://127.0.0.1:8765. The Harbour indexer contract defines three endpoints:

Endpoint Client operation
GET /search?q=...&exclude=... Retrieve results and omit user-disabled source IDs
GET /magnet?hash=...&source=... Resolve a magnet if the result omitted one
GET /health Check indexer connectivity

The indexer returns fields such as name, info_hash, size_bytes, seeders, and source. Harbour can merge and deduplicate results by info hash without knowing how the provider obtained them.

If you maintain a provider, you can change its parsing, credentials, or catalog independently of the Rust client. You also own another service: you must start it, configure its URL, and diagnose connection failures. Direct magnet and .torrent inputs do not need a search indexer.

I keep that distinction visible in the UI. An unavailable search provider should produce a search error, not imply that the BitTorrent engine has stopped working.

Recover a queue without erasing the evidence

A crash can leave two different problems: an interrupted state write and uncertainty about which transfers should restart. I handle those through separate mechanisms in src/persist.rs.

For downloads.json, I serialize the ledger, write a sibling temporary file, sync that file, and rename it over the destination. The current temporary path is downloads.json.tmp.

~/.harbour/
  downloads.json       saved queue ledger
  downloads.json.tmp   sibling file during a write
  boot.marker          interrupted-run detection
  cache/torrents/      cached .torrent metadata
Enter fullscreen mode Exit fullscreen mode

I place the temporary file in the same directory so the replacement stays on one filesystem. I call sync_all() before the rename to reduce the chance that the replacement becomes visible before the file's contents reach storage.

Atomic replacement and power-loss durability have different guarantees. The implementation syncs the temporary file; it does not sync the parent directory after the rename. I would not claim that this sequence proves durability under every filesystem and power-loss scenario.

For recovery, I use boot.marker to detect an interrupted prior run. Harbour restores entries paused in Safe Mode, leaving you to resume them. On clean exit, I save the ledger before clearing the marker. If that save fails, I retain the marker rather than claiming a clean checkpoint.

I also preserve an unreadable ledger as downloads.json.corrupt rather than overwriting its contents with an empty queue. If the top-level JSON array parses but individual rows fail, I restore valid rows and report the skipped count. That distinction lets you recover usable entries while keeping the failure visible.

Piece verification belongs to the BitTorrent engine; ledger recovery belongs to Harbour. A valid JSON queue does not prove that the corresponding file bytes still exist or match their piece hashes.

Make terminal controls readable and reachable

I use Ratatui for the views and crossterm for terminal input and lifecycle. You can navigate with keys, click a search result, toggle sources, and choose VLC or mpv with the mouse. I keep both paths because selecting a player from a short list should not require learning another modal key sequence.

The theme loader supports the 67-token omp schema: semantic colors, named variables, and symbols. Harbour's views consume a subset, including accent, border, success, error, warning, selectedBg, and statusLineBg. Schema compatibility does not mean each Harbour screen uses all 67 tokens.

You can put a custom theme in ~/.harbour/themes/<name>.json. Semantic tokens let me style selection and failure states without putting RGB literals into individual widgets.

For color detection, I recognize COLORTERM=truecolor, COLORTERM=24bit, and a nonempty WT_SESSION. Otherwise, I use the 256-color path and quantize RGB values to ANSI palette indices. You can still read progress and errors in a terminal that lacks Truecolor support. Labels and selection remain necessary because color alone cannot convey state to every reader.

Try the client with a permitted file

Download the binary for your platform from GitHub Releases, install VLC or mpv, and start Harbour. To build from source:

git clone https://github.com/Ishannaik/harbour.git
cd harbour
cargo run
Enter fullscreen mode Exit fullscreen mode

A Rust toolchain with 2024 edition support is required. For the Creative Commons demo workflow, search for sintel, select a result, and press Enter or w to watch. Choose your player when Harbour prompts you. Press Tab for the downloads view and ? for help. Configure your own local indexer when you want to search your own sources.

I keep the terminal client responsible for interaction, queue state, and recovery. I let librqbit manage peer traffic and verified file access, and I let VLC or mpv handle media playback. Those boundaries give me specific places to investigate a stalled watch session: metadata resolution, required piece availability, the loopback response, or the player. They also let me change a view without touching the code that decides which bytes are safe to serve.

Source: Ishannaik/harbour, by Ishan Naik.

Top comments (0)