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
|
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
|
@dataclass
|
||||||
class TorrentStatus:
|
class TorrentStatus:
|
||||||
"""Adapter-uniform view of one torrent's live state.
|
"""Adapter-uniform view of one torrent's live state.
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from typing import Any, List, Optional
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from config.settings import config_manager
|
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
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger("torrent.deluge")
|
logger = get_logger("torrent.deluge")
|
||||||
|
|
@ -67,7 +67,7 @@ class DelugeAdapter:
|
||||||
self._load_config()
|
self._load_config()
|
||||||
|
|
||||||
def _load_config(self) -> None:
|
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.
|
# Deluge's WebUI auth uses a single password, not username+password.
|
||||||
# We accept whichever field the user filled in — keeps the UI uniform.
|
# We accept whichever field the user filled in — keeps the UI uniform.
|
||||||
self._password = (
|
self._password = (
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ from typing import List, Optional
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from config.settings import config_manager
|
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
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger("torrent.qbittorrent")
|
logger = get_logger("torrent.qbittorrent")
|
||||||
|
|
@ -64,7 +64,7 @@ class QBittorrentAdapter:
|
||||||
self._load_config()
|
self._load_config()
|
||||||
|
|
||||||
def _load_config(self) -> None:
|
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._username = config_manager.get('torrent_client.username', '') or ''
|
||||||
self._password = config_manager.get('torrent_client.password', '') or ''
|
self._password = config_manager.get('torrent_client.password', '') or ''
|
||||||
self._category = config_manager.get('torrent_client.category', 'soulsync') or 'soulsync'
|
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
|
import requests as http_requests
|
||||||
|
|
||||||
from config.settings import config_manager
|
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
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
logger = get_logger("torrent.transmission")
|
logger = get_logger("torrent.transmission")
|
||||||
|
|
@ -57,7 +57,7 @@ class TransmissionAdapter:
|
||||||
self._load_config()
|
self._load_config()
|
||||||
|
|
||||||
def _load_config(self) -> None:
|
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
|
# Transmission's RPC endpoint is always /transmission/rpc — if the
|
||||||
# user pasted a bare host URL, append it. If they pasted the full
|
# user pasted a bare host URL, append it. If they pasted the full
|
||||||
# /transmission/rpc URL, leave it.
|
# /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'
|
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:
|
def test_transmission_session_id_renegotiation() -> None:
|
||||||
"""Transmission rejects the first call with 409 and a fresh
|
"""Transmission rejects the first call with 409 and a fresh
|
||||||
``X-Transmission-Session-Id`` header; the adapter must store it
|
``X-Transmission-Session-Id`` header; the adapter must store it
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue