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:
parent
85b6ddb997
commit
b5d22bede5
5 changed files with 76 additions and 6 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue