soulsync/core/torrent_clients/base.py
Broque Thomas de2faf290b feat(torrent): add adapter layer for qBittorrent, Transmission, Deluge
Second commit in the torrent + usenet rollout. SoulSync now speaks
three different BitTorrent client APIs through one uniform adapter
contract — picks the active client by config and dispatches the same
verbs to whichever backend the user uses. Each adapter handles its
own auth quirk (qBit cookie + CSRF Referer, Transmission session-id
renegotiation, Deluge JSON-RPC session) and maps native state
strings onto a shared 7-value set so the rest of the app stays
client-agnostic.

- core/torrent_clients/base.py: TorrentClientAdapter Protocol +
  TorrentStatus dataclass. Eight verbs: is_configured, check_connection,
  add_torrent (URL/magnet), add_torrent_file (raw bytes), get_status,
  get_all, remove, pause, resume.
- core/torrent_clients/__init__.py: adapter_for_type factory +
  get_active_adapter that reads torrent_client.type each call so
  settings changes take effect without restart.
- core/torrent_clients/qbittorrent.py: WebUI v2 adapter. Cookie auth
  via /api/v2/auth/login, transparent 403 re-login, Referer header
  to satisfy qBit's CSRF guard. add_torrent returns the just-added
  hash via /torrents/info sort=added_on (qBit's add endpoint doesn't
  echo the hash).
- core/torrent_clients/transmission.py: RPC adapter. Auto-resolves
  bare host URLs to /transmission/rpc, handles the 409 + new
  X-Transmission-Session-Id renegotiation transparently, accepts
  HTTP basic auth. add_torrent_file base64-encodes payload per spec.
- core/torrent_clients/deluge.py: Deluge 2.x JSON-RPC adapter.
  Password-only auth, distinguishes magnet vs HTTP URL at the RPC
  method layer, applies category via Label plugin (best-effort —
  label plugin is optional).
- core/connection_test.py: 'torrent_client' branch picks the right
  adapter, runs check_connection, surfaces a per-client error
  message.
- config/settings.py: torrent_client.{type, url, username, password,
  category, save_path} defaults + torrent_client.password in the
  encrypted-at-rest secrets list.
- web_server.py: 'torrent_client' added to the /api/settings POST
  allow-list so saved config persists.
- webui/index.html: new Torrent Client panel on the Indexers &
  Downloaders tab — client-type dropdown, URL, username, password,
  category, optional save path, Test Connection.
- webui/static/settings.js: load/save wiring + testTorrentClientConnection.
- webui/static/helper.js: WHATS_NEW + VERSION_MODAL_SECTIONS entry.
2026-05-20 15:10:30 -07:00

110 lines
4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Torrent client adapter contract.
``TorrentClientAdapter`` is a structural Protocol — any class with
these method signatures is treated as a valid adapter. The download
plugin layer (built in a later commit) dispatches generically against
this surface so it doesn't have to know whether the user picked
qBittorrent, Transmission, or Deluge.
The contract intentionally hides protocol-specific details:
- qBittorrent uses cookie auth + multipart form uploads.
- Transmission uses an X-Transmission-Session-Id header + JSON RPC.
- Deluge 2.x uses /json with a session cookie.
All three converge on the same eight verbs below.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Protocol, runtime_checkable
@dataclass
class TorrentStatus:
"""Adapter-uniform view of one torrent's live state.
Field semantics:
- ``state`` is one of: ``queued`` | ``downloading`` | ``seeding`` |
``paused`` | ``stalled`` | ``error`` | ``completed``. Each
adapter maps its native state names to this set.
- ``progress`` is 0.01.0.
- ``save_path`` is where files land on the torrent client's host.
For remote clients this is a path on the *remote* machine.
- ``files`` is the list of relative paths inside the torrent. Empty
until the client has finished fetching the metadata.
"""
id: str # torrent hash (qBit, Deluge) or numeric id (Transmission)
name: str
state: str
progress: float
size: int # total size in bytes
downloaded: int # bytes downloaded so far
download_speed: int # bytes/sec
upload_speed: int # bytes/sec
seeders: int = 0
peers: int = 0
eta: Optional[int] = None # seconds, None if unknown
save_path: Optional[str] = None
files: Optional[List[str]] = None
error: Optional[str] = None
@runtime_checkable
class TorrentClientAdapter(Protocol):
"""Structural contract every torrent-client adapter implements."""
def is_configured(self) -> bool:
"""True when the adapter has a URL and any credentials it
needs. Reads from config_manager — never raises on missing
config, just returns False so the orchestrator can dim the
torrent download source in the UI."""
...
async def check_connection(self) -> bool:
"""Probe the client over the network. Logs in if required."""
...
async def add_torrent(
self,
url_or_magnet: str,
category: str = "soulsync",
save_path: Optional[str] = None,
) -> Optional[str]:
"""Hand the torrent client a HTTP/HTTPS URL pointing to a
``.torrent`` file or a ``magnet:`` URI. Returns the torrent's
client-side identifier (info-hash for qBit / Deluge, numeric
id for Transmission) or ``None`` on failure."""
...
async def add_torrent_file(
self,
file_bytes: bytes,
category: str = "soulsync",
save_path: Optional[str] = None,
) -> Optional[str]:
"""Upload a raw ``.torrent`` payload. Same return as
``add_torrent``. Used when the indexer doesn't expose a
direct download URL and SoulSync had to fetch the file
itself first."""
...
async def get_status(self, torrent_id: str) -> Optional[TorrentStatus]:
"""Return live status for one torrent, or ``None`` if the
client doesn't know about it."""
...
async def get_all(self) -> List[TorrentStatus]:
"""Return live status for every torrent the client currently
tracks. Used by the global download list."""
...
async def remove(self, torrent_id: str, delete_files: bool = False) -> bool:
"""Remove the torrent from the client. ``delete_files=True``
also deletes the downloaded data on disk."""
...
async def pause(self, torrent_id: str) -> bool: ...
async def resume(self, torrent_id: str) -> bool: ...