Fix #790: torrent client URL without http:// scheme fails to connect

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.
This commit is contained in:
BoulderBadgeDad 2026-06-04 11:57:53 -07:00
parent 85b6ddb997
commit b5d22bede5
5 changed files with 76 additions and 6 deletions

View file

@ -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.

View file

@ -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 = (

View file

@ -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'

View file

@ -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.

View file

@ -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