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.
110 lines
4 KiB
Python
110 lines
4 KiB
Python
"""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.0–1.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: ...
|