Skip to content

Add torrent support: Torznab indexers + qBittorrent client - #551

Draft
andreasorbelli wants to merge 2 commits into
allaboutduncan:mainfrom
andreasorbelli:feature/torznab-qbittorrent-support
Draft

Add torrent support: Torznab indexers + qBittorrent client#551
andreasorbelli wants to merge 2 commits into
allaboutduncan:mainfrom
andreasorbelli:feature/torznab-qbittorrent-support

Conversation

@andreasorbelli

Copy link
Copy Markdown
Contributor

Summary

Adds torrents as a fourth download source (alongside GetComics, Usenet, and DC++), using the same open-protocol pattern the existing Newznab/SABnzbd integration already uses — no vendor-specific code, no Prowlarr integration. A Torznab indexer (the open torrent-indexer protocol; this is what an indexer manager like Prowlarr happens to expose per-tracker, but CLU only speaks the open protocol) is searched, scored with the existing GetComics scorer, and submitted to qBittorrent.

Closes #550.

What's new

  • models/indexers/torznab_indexer.pyIndexerType.TORZNAB adapter, mirrors newznab_indexer.py's t=caps/t=search shape, plus Torznab's seeders/peers attributes.
  • models/download_clients/qbittorrent_client.pyClientType.QBITTORRENT adapter (client_group="torrent"), using qBittorrent's cookie-session auth. A submitted torrent's hash is resolved via a unique tags value passed on submission, rather than hand-rolling a magnet/bencode hash parser.
  • models/torrent.py — the orchestration module: search via enabled Torznab indexers, score with the existing score_getcomics_result/accept_result, submit, and a crash-recovery job ledger + poller mirroring models/dcpp.py (qBittorrent, like AirDC++, is a separate process that survives a CLU restart). Reuses the shared WATCH-folder import mover and notify_download_terminal hook already used by Usenet and DC++.
  • A new torrent_jobs table (core/database.py), identical shape to dcpp_jobs.
  • Wiring into models/download_sources.py (KNOWN_SOURCES, get_external_sources()) so the nightly sweep, per-series "Check for Missing Issues", and the manual search modal all pick up the new source automatically.
  • Hand-written /api/torrent/{downloads,search,grab} (+ dismiss) routes mirroring the existing Usenet/DC++ ones, since those aren't registry-derived.
  • Config UI: a Type selector (Newznab/Torznab) on the indexer Add/Edit modal, client_group/source-priority labels, and a torrent entry in the shared search-modal JS (static/js/clu-source-search.js) showing seeders/peers alongside score.
  • Bug fix bundled in: indexer searches now filter get_enabled_indexers() by indexer_type, so a Torznab-configured indexer is never fed to the Newznab/Usenet search and vice versa (this only became a real risk once a second indexer type exists).

No new dependencies — qBittorrent's Web API is consumed with the requests library already used throughout the codebase.

Testing

Mirrors the existing Newznab/Usenet/AirDC++/DC++ test files:

  • tests/mocked/test_torznab_indexer.py, tests/mocked/test_qbittorrent_client.py, tests/mocked/test_torrent.py
  • DB integration coverage in tests/integration/test_database_clients.py and test_database_schema.py (torrent_jobs ledger CRUD, client_group="torrent" scoping, indexer_type round-trip)
  • Route coverage in tests/routes/test_download_clients_routes.py
  • Full suite run locally: 4816 passed (the pre-existing, unrelated tests/unit/test_monitor.py macOS tmp-path permission flake reproduces identically on a clean main checkout and is untouched by this change)

Verification steps

  1. Config → Indexers → Add Indexer → Type: Torznab, pointing at a Torznab-compatible indexer.
  2. Config → Download Clients → add qBittorrent, Test, Activate.
  3. From a series page, run a manual search — a "Torrent (Indexers)" section should appear with seeders/score, and Grab should queue it in qBittorrent.
  4. Once the torrent completes, confirm the file lands in WATCH and is imported, and a completion notification fires if Apprise is configured.
  5. Restart CLU mid-download and confirm the torrent is re-adopted from the torrent_jobs ledger and still imports on completion.

🤖 Generated with Claude Code

Adds torrents as a fourth download source, alongside GetComics, Usenet
and DC++, using the same open protocol pattern the existing Newznab/
SABnzbd integration already uses:

- models/indexers/torznab_indexer.py: a Torznab indexer adapter
  (IndexerType.TORZNAB), mirroring the Newznab adapter's t=caps/
  t=search shape, plus parsing seeders/peers.
- models/download_clients/qbittorrent_client.py: a qBittorrent client
  adapter (ClientType.QBITTORRENT, client_group="torrent"), using
  qBittorrent's cookie-session auth. A submitted torrent's hash is
  resolved via a unique `tags` value passed on submission, avoiding a
  hand-rolled magnet/bencode hash parser.
- models/torrent.py: the orchestration module wiring search (via
  enabled Torznab indexers), scoring (reusing the existing GetComics
  scorer), submission, a crash-recovery job ledger + poller (mirroring
  models/dcpp.py, since qBittorrent survives a CLU restart like
  AirDC++), and the shared WATCH-folder import/notification hooks
  already used by Usenet and DC++.
- A new torrent_jobs table (core/database.py), mirroring dcpp_jobs.
- Wiring into models/download_sources.py so the nightly sweep,
  per-series "Check for Missing Issues", and the manual search modal
  all pick up the new source automatically; hand-written
  /api/torrent/{downloads,search,grab} routes mirroring the existing
  Usenet/DC++ ones; config UI and manual-search-modal entries for the
  new source.
- Bug fix bundled in: indexer searches now filter get_enabled_indexers()
  by indexer_type, so a Torznab-configured indexer is never fed to the
  Newznab/Usenet search and vice versa.

No new dependencies — qBittorrent's Web API is consumed with the
`requests` library already used throughout the codebase.

Full test coverage mirrors the existing Newznab/Usenet/AirDC++/DC++
test files: mocked adapter tests, DB integration tests, and route
tests.

Closes allaboutduncan#550

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSgyJ3Tqnf4TbVvk2fTaX
@andreasorbelli
andreasorbelli marked this pull request as draft September 6, 2026 21:15
@andreasorbelli

Copy link
Copy Markdown
Contributor Author

Marking this as a draft for now — I want to spend some time actually testing it end-to-end (real Torznab indexer + qBittorrent instance, a full search → grab → complete → import cycle, and a restart-mid-download recovery check) before I'm confident in it.

If anyone wants to try it out in the meantime and give feedback, you're most welcome — I'd genuinely appreciate it.

_login() required exactly HTTP 200 with body "Ok." for a successful
login, per the WebAPI 4.1 doc this file links to. Tested against a
real qBittorrent 5.2.3 (WebAPI 2.15.1) instance on the NAS: it answers
204 with an empty body on success and 401 on bad credentials instead,
so every login -- valid credentials included -- was rejected as an
HTTP error before the body/cookie was ever checked, breaking the
entire grab/status pipeline for current qBittorrent versions.

Accept both response shapes: 200 (+"Ok."/"Fails." body check) for
older WebAPI, or 204/401 for 2.11+.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kd4eey159qpMoFvQVovSAw
@allaboutduncan

Copy link
Copy Markdown
Owner

I want to review this from a use case perspective and focus on file handling.

Currently all files download to WATCH, get processed and moved to TARGET.

This method could get you banned from some trackers because it would be considered leaching as you wouldn't be sharing the files you downloaded.

I'll put some additional thought into this next week as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add torrent support: Torznab indexers + qBittorrent client

2 participants