qbittorrent.cr v0.2.0
qbittorrent
A typed Crystal client for the qBittorrent WebUI API v2, targeting qBittorrent 5.x (WebAPI 2.11+, live-verified against 5.2.3 / WebAPI 2.15.1). There is no OpenAPI schema for this API — every endpoint method and JSON model in this shard is hand-modeled from the official wiki (distilled into ext/api-map.md) and cross-checked against a live container.
Highlights:
- Session-based auth. qBittorrent's WebUI uses a cookie session, not an API key:
Client#loginposts credentials toauth/login, captures the dynamicQBT_SID_<port>session cookie, and attaches it (with a matchingReferer/Originheader) to every later call. A403from an authenticated call triggers one transparent re-login + retry. - One class per endpoint group (
app,log,sync,transfer,torrents,search,rss), each built on top of the sharedClient. - Typed models and enums for JSON responses, with tolerant decoding for the partial payloads
sync/maindatasends (see Usage: sync).
Installation
-
Add the dependency to your
shard.yml:dependencies: qbittorrent: github: mjblack/qbittorrent.cr -
Run
shards install
Quick start
require "qbittorrent"
client = QBittorrent::Client.new("http://localhost:8080", "admin", "adminadmin")
client.login
version = QBittorrent::Api::App.new(client).version
# => "v5.2.3"
transfer_info = QBittorrent::Api::Transfer.new(client).info
transfer_info.dht_nodes
# => 67
downloading = QBittorrent::Api::Torrents.new(client).info(filter: QBittorrent::Model::TorrentFilter::Downloading)
# => Array(QBittorrent::Model::TorrentInfo)
client.logout
Every endpoint group is its own class taking a Client: QBittorrent::Api::<Group>.new(client). Client#login is called lazily on the first request if you skip it, so the explicit call above is optional but recommended — it fails fast on bad credentials instead of on the first real call.
Note: convenience accessors on
Clientitself (e.g.client.torrentsinstead ofQBittorrent::Api::Torrents.new(client)) are planned but not implemented yet. For now, construct each group explicitly as shown below.
Usage by group
Every example below assumes:
client = QBittorrent::Client.new("http://localhost:8080", "admin", "adminadmin")
client.login
app
Application version/build info, global preferences, cookies, and network interfaces (QBittorrent::Api::App):
app = QBittorrent::Api::App.new(client)
app.version # => "v5.2.3"
app.webapi_version # => "2.15.1"
app.preferences.dht # => true
app.set_preferences({"dht" => JSON::Any.new(false)})
set_preferences sends only the fields you pass — it's a partial update, not a full replace.
log
The main log and peer log, both with incremental fetching via last_known_id (QBittorrent::Api::Log):
log = QBittorrent::Api::Log.new(client)
log.main(warning: true, critical: true) # only warnings/criticals
log.peers(last_known_id: 42_i64) # peer log entries since id 42
sync
The delta-based feeds that back the WebUI (QBittorrent::Api::Sync). Both methods take a response id (rid) from the previous call; omit it to get a full snapshot, then pass back the returned rid to fetch only what changed:
sync = QBittorrent::Api::Sync.new(client)
data = sync.maindata # full snapshot (rid defaults to 0)
delta = sync.maindata(rid: data.rid) # only what changed since `data`
sync.torrent_peers("<info-hash>")
Because deltas only carry changed keys, Model::MainData and the nested Model::TorrentInfo values it contains have every field nilable — don't assume a field is present on any given response.
transfer
Global transfer stats, the alternative speed-limits toggle, global rate limits, and peer banning (QBittorrent::Api::Transfer):
transfer = QBittorrent::Api::Transfer.new(client)
transfer.info.dht_nodes # => 67
transfer.download_limit # => 0 (unlimited)
transfer.set_download_limit(1_048_576_i64)
torrents
The largest group, spread across three files (torrents.cr for read/info endpoints, torrents_lifecycle.cr for add/start/stop/delete/queue, torrents_config.cr for per-torrent configuration) but all reopening the same QBittorrent::Api::Torrents class:
torrents = QBittorrent::Api::Torrents.new(client)
torrents.info(filter: QBittorrent::Model::TorrentFilter::Downloading)
torrents.properties("<info-hash>").save_path
torrents.categories["movies"].save_path
Adding torrents — from a magnet/HTTP URL, or from raw .torrent file content (which is sent as multipart/form-data; content may be a String or Bytes):
torrents.add(
urls: "magnet:?xt=urn:btih:...",
category: "movies",
tags: %w[hd 4k],
)
torrent_bytes = File.read("ubuntu.torrent")
torrents.add(torrent: torrent_bytes, torrent_filename: "ubuntu.torrent", savepath: "/downloads")
Lifecycle and per-torrent configuration:
torrents.start("all")
torrents.stop(["hash1", "hash2"])
torrents.delete(["hash1"], delete_files: false)
torrents.set_download_limit("all", 1_048_576_i64)
torrents.set_category(%w[abc def], "movies")
torrents.add_tags("abc", %w[hd 4k])
Anywhere a hashes argument is accepted you can pass the string "all", a single hash, or an Enumerable(String) of hashes.
search
Start/poll/stop search jobs and manage search plugins (QBittorrent::Api::Search):
search = QBittorrent::Api::Search.new(client)
id = search.start("ubuntu") # => 1
search.status(id).first.running? # => true
search.results(id).results.first.file_name
search.stop(id)
search.delete(id)
rss
RSS feeds/folders, their items, and the auto-downloading rules engine (QBittorrent::Api::Rss):
rss = QBittorrent::Api::Rss.new(client)
rss.add_feed("https://example.com/feed.xml", "News")
rss.items(with_data: true) # => Hash(String, JSON::Any) tree
rss.rules # => Hash(String, Model::RssRule)
rss/items returns a heterogeneous, recursively-nested tree (folders contain folders and feeds), so #items returns it untyped as Hash(String, JSON::Any). To work with a located feed node as a typed model:
tree = rss.items(with_data: true)
feed = QBittorrent::Model::RssFeed.from_json(tree["My Feed"].to_json)
feed.articles.try &.each { |a| puts a.title }
Development
$ shards install
$ crystal spec
$ crystal tool format
$ bin/ameba
crystal spec (no environment variables) runs the offline unit specs only — JSON parsing against recorded response samples — and stays green without Docker or network access.
Docker integration tests
Live integration coverage runs against a real qbittorrentofficial/qbittorrent-nox container, managed with scripts/qbit-testenv.sh:
$ scripts/qbit-testenv.sh up
$ scripts/qbit-testenv.sh wait
$ QBITTORRENT_INTEGRATION=1 \
QBITTORRENT_PASSWORD="$(scripts/qbit-testenv.sh creds password)" \
crystal spec spec/integration
$ scripts/qbit-testenv.sh down
The container's config volume is ephemeral, so on every fresh up qBittorrent 5.x generates a temporary WebUI password for the built-in admin user and prints it to the container log instead of using a pre-seeded credential. scripts/qbit-testenv.sh creds (or creds password / creds user) parses that line out of docker compose logs for you. Integration specs are gated on QBITTORRENT_INTEGRATION=1 and report as pending (not failed) when it's unset, so a plain crystal spec never needs Docker. scripts/qbit-testenv.sh down removes the container and its ephemeral config volume so the next up starts clean.
Development notes
- The API surface has no schema to generate from;
ext/api-map.mdis kept as the working reference distilled from the wiki, cross-checked against a live 5.2.3 / WebAPI 2.15.1 container where the two disagree. - The qBittorrent 5.x session cookie is named
QBT_SID_<port>(e.g.QBT_SID_8080), not theSIDname older docs/tools use —Clientreads the actual cookie name offSet-Cookierather than assuming it.
Contributing
- Fork it (https://github.com/mjblack/qbittorrent.cr/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Matthew J. Black - creator and maintainer
qbittorrent.cr
- 0
- 0
- 0
- 1
- 1
- 2 days ago
- July 19, 2026
MIT License
Mon, 20 Jul 2026 17:43:53 GMT