From b5d22bede511c23cb92969531803118f95d460e9 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Thu, 4 Jun 2026 11:57:53 -0700 Subject: [PATCH] Fix #790: torrent client URL without http:// scheme fails to connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare host like '192.168.1.5:8080' or 'qbittorrent.lan:8080' (no scheme) is what users naturally type, but requests then raises 'No connection adapters were found for ...' — it can't pick an http/https adapter, and a bare host:port even gets misparsed as scheme=host. This surfaced as the generic 'qbittorrent probe failed' with a 'login error: No connection adapters were found' in the logs. Add normalize_client_url() in torrent_clients/base: default a missing scheme to http:// (+ trim trailing slash), and route all three adapters' _load_config through it. Transmission normalizes the base before appending /transmission/rpc. Tests: normalizer unit cases + per-adapter regression (bare host -> http://). Note: usenet adapters (sabnzbd/nzbget) share the same pattern and need the same treatment in a follow-up. --- core/torrent_clients/base.py | 18 ++++++++++ core/torrent_clients/deluge.py | 4 +-- core/torrent_clients/qbittorrent.py | 4 +-- core/torrent_clients/transmission.py | 4 +-- tests/test_torrent_client_adapters.py | 52 +++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 6 deletions(-) diff --git a/core/torrent_clients/base.py b/core/torrent_clients/base.py index 4efe936e..f95cdbbc 100644 --- a/core/torrent_clients/base.py +++ b/core/torrent_clients/base.py @@ -20,6 +20,24 @@ from dataclasses import dataclass from typing import List, Optional, Protocol, runtime_checkable +def normalize_client_url(raw: str) -> str: + """Clean a user-entered WebUI URL into something ``requests`` accepts. + + Users routinely type a bare host like ``192.168.1.5:8080`` or + ``qbittorrent.lan:8080`` with no scheme. ``requests`` then raises + "No connection adapters were found for '...'" because it can't pick an + http/https adapter (a bare ``host:port`` even gets misparsed as + ``scheme=host``). Default a missing scheme to ``http://`` and trim a + trailing slash. Empty input passes through unchanged. + """ + url = (raw or '').strip() + if not url: + return '' + if '://' not in url: + url = 'http://' + url + return url.rstrip('/') + + @dataclass class TorrentStatus: """Adapter-uniform view of one torrent's live state. diff --git a/core/torrent_clients/deluge.py b/core/torrent_clients/deluge.py index 7c7e6ae9..a1c23e69 100644 --- a/core/torrent_clients/deluge.py +++ b/core/torrent_clients/deluge.py @@ -20,7 +20,7 @@ from typing import Any, List, Optional import requests as http_requests from config.settings import config_manager -from core.torrent_clients.base import TorrentStatus +from core.torrent_clients.base import TorrentStatus, normalize_client_url from utils.logging_config import get_logger logger = get_logger("torrent.deluge") @@ -67,7 +67,7 @@ class DelugeAdapter: self._load_config() def _load_config(self) -> None: - self._url = (config_manager.get('torrent_client.url', '') or '').rstrip('/') + self._url = normalize_client_url(config_manager.get('torrent_client.url', '')) # Deluge's WebUI auth uses a single password, not username+password. # We accept whichever field the user filled in — keeps the UI uniform. self._password = ( diff --git a/core/torrent_clients/qbittorrent.py b/core/torrent_clients/qbittorrent.py index 147c359b..537fdd97 100644 --- a/core/torrent_clients/qbittorrent.py +++ b/core/torrent_clients/qbittorrent.py @@ -18,7 +18,7 @@ from typing import List, Optional import requests as http_requests from config.settings import config_manager -from core.torrent_clients.base import TorrentStatus +from core.torrent_clients.base import TorrentStatus, normalize_client_url from utils.logging_config import get_logger logger = get_logger("torrent.qbittorrent") @@ -64,7 +64,7 @@ class QBittorrentAdapter: self._load_config() def _load_config(self) -> None: - self._url = (config_manager.get('torrent_client.url', '') or '').rstrip('/') + self._url = normalize_client_url(config_manager.get('torrent_client.url', '')) self._username = config_manager.get('torrent_client.username', '') or '' self._password = config_manager.get('torrent_client.password', '') or '' self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync' diff --git a/core/torrent_clients/transmission.py b/core/torrent_clients/transmission.py index 266bda79..050d7968 100644 --- a/core/torrent_clients/transmission.py +++ b/core/torrent_clients/transmission.py @@ -18,7 +18,7 @@ from typing import List, Optional import requests as http_requests from config.settings import config_manager -from core.torrent_clients.base import TorrentStatus +from core.torrent_clients.base import TorrentStatus, normalize_client_url from utils.logging_config import get_logger logger = get_logger("torrent.transmission") @@ -57,7 +57,7 @@ class TransmissionAdapter: self._load_config() def _load_config(self) -> None: - url = (config_manager.get('torrent_client.url', '') or '').rstrip('/') + url = normalize_client_url(config_manager.get('torrent_client.url', '')) # Transmission's RPC endpoint is always /transmission/rpc — if the # user pasted a bare host URL, append it. If they pasted the full # /transmission/rpc URL, leave it. diff --git a/tests/test_torrent_client_adapters.py b/tests/test_torrent_client_adapters.py index 19f8d423..ba344a51 100644 --- a/tests/test_torrent_client_adapters.py +++ b/tests/test_torrent_client_adapters.py @@ -263,6 +263,58 @@ def test_transmission_normalises_bare_host_to_rpc_path() -> None: assert adapter._url == 'http://host:9091/transmission/rpc' +# --------------------------------------------------------------------------- +# URL scheme normalization (#790) +# --------------------------------------------------------------------------- + + +def test_normalize_client_url_prepends_http_when_scheme_missing() -> None: + from core.torrent_clients.base import normalize_client_url + # The exact shapes users type: bare IP:port, bare DNS name:port, bare host. + assert normalize_client_url('192.168.1.5:8080') == 'http://192.168.1.5:8080' + assert normalize_client_url('qbittorrent.lan:8080') == 'http://qbittorrent.lan:8080' + assert normalize_client_url('myhost') == 'http://myhost' + + +def test_normalize_client_url_preserves_existing_scheme_and_trims() -> None: + from core.torrent_clients.base import normalize_client_url + assert normalize_client_url('http://host:8080') == 'http://host:8080' + assert normalize_client_url('https://host') == 'https://host' + assert normalize_client_url(' http://host:8080/ ') == 'http://host:8080' + assert normalize_client_url('') == '' + assert normalize_client_url(None) == '' + + +def test_qbit_load_config_defaults_scheme_for_bare_host() -> None: + """Regression #790: a bare ``host:port`` config (no scheme) must become an + http:// URL. Otherwise requests can't pick an adapter and raises + 'No connection adapters were found for ...', which surfaced to the user as + a generic 'qbittorrent probe failed'.""" + adapter = QBittorrentAdapter.__new__(QBittorrentAdapter) + import threading + adapter._session = None + adapter._session_lock = threading.Lock() + with patch('core.torrent_clients.qbittorrent.config_manager') as cm: + cm.get.side_effect = lambda key, default='': { + 'torrent_client.url': '192.168.1.5:8080', + }.get(key, default) + adapter._load_config() + assert adapter._url == 'http://192.168.1.5:8080' + + +def test_deluge_load_config_defaults_scheme_for_bare_host() -> None: + adapter = DelugeAdapter.__new__(DelugeAdapter) + import threading + adapter._session = None + adapter._session_lock = threading.Lock() + with patch('core.torrent_clients.deluge.config_manager') as cm: + cm.get.side_effect = lambda key, default='': { + 'torrent_client.url': 'deluge.lan:8112', + }.get(key, default) + adapter._load_config() + assert adapter._url == 'http://deluge.lan:8112' + + def test_transmission_session_id_renegotiation() -> None: """Transmission rejects the first call with 409 and a fresh ``X-Transmission-Session-Id`` header; the adapter must store it