feat(usenet): add adapter layer for SABnzbd and NZBGet
Third commit in the torrent + usenet rollout. SoulSync now also
speaks the two big usenet downloaders through a sibling adapter
contract that mirrors the torrent adapter set. All three layers are
now stood up — Prowlarr finds releases, the torrent adapter and the
usenet adapter each know how to ship work to the underlying client.
A later commit wires Prowlarr search results through the adapters
and through the archive-extract-match pipeline.
- core/usenet_clients/base.py: UsenetClientAdapter Protocol +
UsenetStatus dataclass. Uniform state set covers usenet-specific
phases (queued / downloading / extracting / verifying / repairing /
completed / failed / paused).
- core/usenet_clients/__init__.py: adapter_for_type factory +
get_active_adapter that reads usenet_client.type each call.
- core/usenet_clients/sabnzbd.py: REST adapter. ?apikey=... auth,
mode=addurl and mode=addfile (multipart) for add_nzb. Reads both
the active queue and the recent history so completed / failed
jobs surface in get_all. Parses SAB's HH:MM:SS ``timeleft`` into
seconds.
- core/usenet_clients/nzbget.py: JSON-RPC adapter. HTTP Basic auth,
``append`` method for add_nzb (auto-detects URL vs base64 NZB),
``editqueue`` with GroupPause/GroupResume/GroupDelete/GroupFinalDelete
for state changes. Reads NZBGet's 64-bit split size fields
(FileSizeHi + FileSizeLo) preferentially over the legacy
FileSizeMB aggregate.
- core/connection_test.py: 'usenet_client' branch picks the right
adapter, runs check_connection, surfaces per-client error
messages (different credentials needed).
- config/settings.py: usenet_client.{type, url, api_key, username,
password, category} defaults + both api_key and password marked
encrypted-at-rest.
- web_server.py: 'usenet_client' added to the /api/settings POST
allow-list.
- webui/index.html: new Usenet Client panel on the Indexers &
Downloaders tab. Type picker swaps the credential fields between
API-key (SABnzbd) and username+password (NZBGet).
- webui/static/settings.js: load/save wiring, updateUsenetClientUI
for the credential field swap, testUsenetClientConnection.
- webui/static/helper.js: WHATS_NEW + VERSION_MODAL_SECTIONS entry.
This commit is contained in:
parent
de2faf290b
commit
7a3ce50f71
10 changed files with 849 additions and 1 deletions
|
|
@ -89,6 +89,8 @@ class ConfigManager:
|
|||
'lidarr_download.api_key',
|
||||
'prowlarr.api_key',
|
||||
'torrent_client.password',
|
||||
'usenet_client.api_key',
|
||||
'usenet_client.password',
|
||||
# Enrichment services
|
||||
'listenbrainz.token',
|
||||
'acoustid.api_key',
|
||||
|
|
@ -541,6 +543,17 @@ class ConfigManager:
|
|||
"category": "soulsync",
|
||||
"save_path": "",
|
||||
},
|
||||
# Usenet client — receives .nzb URLs / payloads. ``type``
|
||||
# picks the adapter (sabnzbd | nzbget). SABnzbd uses an
|
||||
# API key; NZBGet uses username + password.
|
||||
"usenet_client": {
|
||||
"type": "sabnzbd",
|
||||
"url": "",
|
||||
"api_key": "",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"category": "soulsync",
|
||||
},
|
||||
"soundcloud_download": {
|
||||
# Anonymous-only for now — SoundCloud Go+ OAuth tier could be
|
||||
# added later, with credentials living under a "session" subkey
|
||||
|
|
|
|||
|
|
@ -305,6 +305,27 @@ def run_service_test(service, test_config):
|
|||
return False, "Invalid Genius access token."
|
||||
except Exception as e:
|
||||
return False, f"Genius connection error: {str(e)}"
|
||||
elif service == "usenet_client":
|
||||
client_type = (config_manager.get('usenet_client.type', '') or '').strip().lower()
|
||||
url = config_manager.get('usenet_client.url', '')
|
||||
if not url:
|
||||
return False, "Usenet client URL is required."
|
||||
if not client_type:
|
||||
return False, "Pick a usenet client (SABnzbd or NZBGet)."
|
||||
try:
|
||||
from core.usenet_clients import adapter_for_type as _usenet_adapter_for_type
|
||||
adapter = _usenet_adapter_for_type(client_type)
|
||||
if adapter is None:
|
||||
return False, f"Unknown usenet client type: {client_type}"
|
||||
if not adapter.is_configured():
|
||||
if client_type == "sabnzbd":
|
||||
return False, "SABnzbd needs both URL and API key."
|
||||
return False, "NZBGet needs URL, username, and password."
|
||||
if run_async(adapter.check_connection()):
|
||||
return True, f"Connected to {client_type}"
|
||||
return False, f"{client_type} probe failed — check URL, credentials, and that the client is running."
|
||||
except Exception as e:
|
||||
return False, f"Usenet client connection error: {str(e)}"
|
||||
elif service == "torrent_client":
|
||||
client_type = (config_manager.get('torrent_client.type', '') or '').strip().lower()
|
||||
url = config_manager.get('torrent_client.url', '')
|
||||
|
|
|
|||
49
core/usenet_clients/__init__.py
Normal file
49
core/usenet_clients/__init__.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"""Usenet client adapters.
|
||||
|
||||
Each adapter wraps one Usenet downloader (SABnzbd, NZBGet) behind
|
||||
the ``UsenetClientAdapter`` Protocol so the rest of SoulSync can
|
||||
talk to whichever client the user picked through one uniform
|
||||
surface.
|
||||
|
||||
The active adapter is selected at runtime by the
|
||||
``usenet_client.type`` config key. See ``get_active_adapter()``
|
||||
for the factory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from config.settings import config_manager
|
||||
|
||||
from core.usenet_clients.base import UsenetClientAdapter, UsenetStatus
|
||||
from core.usenet_clients.nzbget import NZBGetAdapter
|
||||
from core.usenet_clients.sabnzbd import SABnzbdAdapter
|
||||
|
||||
__all__ = [
|
||||
"UsenetClientAdapter",
|
||||
"UsenetStatus",
|
||||
"SABnzbdAdapter",
|
||||
"NZBGetAdapter",
|
||||
"get_active_adapter",
|
||||
"adapter_for_type",
|
||||
]
|
||||
|
||||
|
||||
def adapter_for_type(client_type: str) -> Optional[UsenetClientAdapter]:
|
||||
"""Build a fresh adapter instance for the given client type string.
|
||||
``None`` for unknown types."""
|
||||
if client_type == "sabnzbd":
|
||||
return SABnzbdAdapter()
|
||||
if client_type == "nzbget":
|
||||
return NZBGetAdapter()
|
||||
return None
|
||||
|
||||
|
||||
def get_active_adapter() -> Optional[UsenetClientAdapter]:
|
||||
"""Return an adapter for whichever usenet client the user has
|
||||
selected in Settings. Reads ``usenet_client.type`` each call."""
|
||||
client_type = (config_manager.get('usenet_client.type', '') or '').strip().lower()
|
||||
if not client_type:
|
||||
return None
|
||||
return adapter_for_type(client_type)
|
||||
74
core/usenet_clients/base.py
Normal file
74
core/usenet_clients/base.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Usenet client adapter contract.
|
||||
|
||||
``UsenetClientAdapter`` mirrors ``TorrentClientAdapter`` in shape so
|
||||
the download plugin layer can reuse the same dispatch pattern.
|
||||
Differences from the torrent side:
|
||||
|
||||
- No magnet URI equivalent — usenet jobs are always ``.nzb`` files
|
||||
or URLs that resolve to one.
|
||||
- No seed/peer counts — usenet is a download-only protocol.
|
||||
- Status values reflect usenet semantics: ``downloading`` /
|
||||
``extracting`` / ``verifying`` / ``repairing`` / ``completed`` /
|
||||
``failed`` / ``paused``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@dataclass
|
||||
class UsenetStatus:
|
||||
"""Adapter-uniform view of one usenet job.
|
||||
|
||||
Field semantics:
|
||||
- ``state`` is one of: ``queued`` | ``downloading`` | ``extracting``
|
||||
| ``verifying`` | ``repairing`` | ``completed`` | ``failed`` |
|
||||
``paused``. Each adapter maps its native names to this set.
|
||||
- ``progress`` is 0.0–1.0 across the entire job (download + par2 +
|
||||
unpack), so a job stalled at the verify step still shows < 1.0.
|
||||
"""
|
||||
|
||||
id: str # SAB nzo_id / NZBGet NZBID
|
||||
name: str
|
||||
state: str
|
||||
progress: float
|
||||
size: int # total size in bytes
|
||||
downloaded: int # bytes downloaded so far
|
||||
download_speed: int # bytes/sec
|
||||
eta: Optional[int] = None # seconds, None if unknown
|
||||
save_path: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
files: Optional[List[str]] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UsenetClientAdapter(Protocol):
|
||||
"""Structural contract every usenet-client adapter implements."""
|
||||
|
||||
def is_configured(self) -> bool: ...
|
||||
|
||||
async def check_connection(self) -> bool: ...
|
||||
|
||||
async def add_nzb(
|
||||
self,
|
||||
url_or_bytes,
|
||||
category: str = "soulsync",
|
||||
save_path: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Hand the usenet client either a ``.nzb`` HTTP URL (``str``)
|
||||
or the raw payload (``bytes``). Returns the client-side job id
|
||||
on success, ``None`` on failure."""
|
||||
...
|
||||
|
||||
async def get_status(self, job_id: str) -> Optional[UsenetStatus]: ...
|
||||
|
||||
async def get_all(self) -> List[UsenetStatus]: ...
|
||||
|
||||
async def remove(self, job_id: str, delete_files: bool = False) -> bool: ...
|
||||
|
||||
async def pause(self, job_id: str) -> bool: ...
|
||||
|
||||
async def resume(self, job_id: str) -> bool: ...
|
||||
277
core/usenet_clients/nzbget.py
Normal file
277
core/usenet_clients/nzbget.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"""NZBGet adapter.
|
||||
|
||||
Auth model: HTTP Basic auth on the JSON-RPC endpoint ``/jsonrpc``.
|
||||
Every method takes positional ``params``. Identical pattern to
|
||||
Deluge but with different method names.
|
||||
|
||||
Reference: https://nzbget.com/documentation/api/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from itertools import count
|
||||
from typing import Any, List, Optional, Union
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.usenet_clients.base import UsenetStatus
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("usenet.nzbget")
|
||||
|
||||
|
||||
# NZBGet's ``Status`` field on ListGroups → adapter-uniform set.
|
||||
# NZBGet states (group): QUEUED, PAUSED, DOWNLOADING, FETCHING, PP_QUEUED,
|
||||
# LOADING_PARS, VERIFYING_SOURCES, REPAIRING, VERIFYING_REPAIRED, RENAMING,
|
||||
# UNPACKING, MOVING, EXECUTING_SCRIPT, PP_FINISHED.
|
||||
_NZBGET_STATE_MAP = {
|
||||
"QUEUED": "queued",
|
||||
"PAUSED": "paused",
|
||||
"DOWNLOADING": "downloading",
|
||||
"FETCHING": "downloading",
|
||||
"PP_QUEUED": "queued",
|
||||
"LOADING_PARS": "verifying",
|
||||
"VERIFYING_SOURCES": "verifying",
|
||||
"REPAIRING": "repairing",
|
||||
"VERIFYING_REPAIRED": "verifying",
|
||||
"RENAMING": "extracting",
|
||||
"UNPACKING": "extracting",
|
||||
"MOVING": "extracting",
|
||||
"EXECUTING_SCRIPT": "extracting",
|
||||
"PP_FINISHED": "completed",
|
||||
}
|
||||
|
||||
|
||||
def _map_state(nzbget_state: str) -> str:
|
||||
return _NZBGET_STATE_MAP.get(nzbget_state or '', "error")
|
||||
|
||||
|
||||
class NZBGetAdapter:
|
||||
"""NZBGet JSON-RPC adapter."""
|
||||
|
||||
DEFAULT_TIMEOUT = 15
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._id_counter = count(1)
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self) -> None:
|
||||
self._url = (config_manager.get('usenet_client.url', '') or '').rstrip('/')
|
||||
self._username = config_manager.get('usenet_client.username', '') or ''
|
||||
self._password = config_manager.get('usenet_client.password', '') or ''
|
||||
self._category = config_manager.get('usenet_client.category', 'soulsync') or 'soulsync'
|
||||
|
||||
def reload_settings(self) -> None:
|
||||
self._load_config()
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self._url and self._username and self._password)
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
if not self.is_configured():
|
||||
return False
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._check_connection_sync)
|
||||
|
||||
def _check_connection_sync(self) -> bool:
|
||||
return self._rpc_sync('version', []) is not None
|
||||
|
||||
def _rpc_sync(self, method: str, params: list) -> Any:
|
||||
if not self._url:
|
||||
return None
|
||||
try:
|
||||
resp = http_requests.post(
|
||||
f"{self._url}/jsonrpc",
|
||||
json={'method': method, 'params': params, 'id': next(self._id_counter)},
|
||||
auth=(self._username, self._password) if self._username else None,
|
||||
headers={'Content-Type': 'application/json'},
|
||||
timeout=self.DEFAULT_TIMEOUT,
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.warning("NZBGet %s returned HTTP %s", method, resp.status_code)
|
||||
return None
|
||||
data = resp.json()
|
||||
if data.get('error'):
|
||||
logger.warning("NZBGet %s error: %r", method, data.get('error'))
|
||||
return None
|
||||
return data.get('result')
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
logger.error("NZBGet %s call failed: %s", method, e)
|
||||
return None
|
||||
except ValueError as e:
|
||||
logger.error("NZBGet %s response not JSON: %s", method, e)
|
||||
return None
|
||||
|
||||
async def add_nzb(
|
||||
self,
|
||||
url_or_bytes: Union[str, bytes],
|
||||
category: str = "soulsync",
|
||||
save_path: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, self._add_nzb_sync, url_or_bytes, category, save_path
|
||||
)
|
||||
|
||||
def _add_nzb_sync(
|
||||
self,
|
||||
url_or_bytes: Union[str, bytes],
|
||||
category: str,
|
||||
save_path: Optional[str],
|
||||
) -> Optional[str]:
|
||||
cat = category or self._category
|
||||
# NZBGet's ``append`` takes: NZBFilename, Content, Category,
|
||||
# Priority, AddToTop, AddPaused, DupeKey, DupeScore, DupeMode,
|
||||
# PPParameters. We pass the minimum required for an unpause-on-add.
|
||||
# Content is either base64 of the raw .nzb or a URL — NZBGet
|
||||
# auto-detects which based on whether it looks like a URL.
|
||||
if isinstance(url_or_bytes, bytes):
|
||||
content = base64.b64encode(url_or_bytes).decode('ascii')
|
||||
nzb_filename = 'soulsync.nzb'
|
||||
else:
|
||||
content = url_or_bytes
|
||||
nzb_filename = ''
|
||||
params = [
|
||||
nzb_filename, # NZBFilename
|
||||
content, # Content (URL or base64 NZB)
|
||||
cat, # Category
|
||||
0, # Priority
|
||||
False, # AddToTop
|
||||
False, # AddPaused
|
||||
'', # DupeKey
|
||||
0, # DupeScore
|
||||
'SCORE', # DupeMode
|
||||
[], # PPParameters
|
||||
]
|
||||
result = self._rpc_sync('append', params)
|
||||
if isinstance(result, int) and result > 0:
|
||||
return str(result)
|
||||
return None
|
||||
|
||||
async def get_status(self, job_id: str) -> Optional[UsenetStatus]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._get_status_sync, job_id)
|
||||
|
||||
def _get_status_sync(self, job_id: str) -> Optional[UsenetStatus]:
|
||||
for status in self._get_all_sync():
|
||||
if status.id == job_id:
|
||||
return status
|
||||
return None
|
||||
|
||||
async def get_all(self) -> List[UsenetStatus]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._get_all_sync)
|
||||
|
||||
def _get_all_sync(self) -> List[UsenetStatus]:
|
||||
out: List[UsenetStatus] = []
|
||||
groups = self._rpc_sync('listgroups', [0])
|
||||
if isinstance(groups, list):
|
||||
for group in groups:
|
||||
out.append(self._parse_group(group))
|
||||
history = self._rpc_sync('history', [False])
|
||||
if isinstance(history, list):
|
||||
for entry in history:
|
||||
out.append(self._parse_history(entry))
|
||||
return out
|
||||
|
||||
def _parse_group(self, group: dict) -> UsenetStatus:
|
||||
# NZBGet reports sizes split into ``FileSizeLo`` (low 32 bits) +
|
||||
# ``FileSizeHi`` (high 32 bits) for compat with old clients —
|
||||
# ``FileSizeMB`` is the human-friendly aggregate.
|
||||
size_mb = self._mb_value(group, 'FileSize')
|
||||
remaining_mb = self._mb_value(group, 'RemainingSize')
|
||||
size_bytes = int(size_mb * 1024 * 1024) if size_mb else 0
|
||||
downloaded_bytes = int((size_mb - remaining_mb) * 1024 * 1024) if size_mb and remaining_mb is not None else 0
|
||||
progress = 0.0
|
||||
if size_bytes > 0:
|
||||
progress = max(0.0, min(downloaded_bytes / size_bytes, 1.0))
|
||||
# NZBGet's per-group ``DownloadRate`` field is in bytes/sec.
|
||||
speed = int(group.get('DownloadRate') or 0)
|
||||
return UsenetStatus(
|
||||
id=str(group.get('NZBID') or ''),
|
||||
name=group.get('NZBName') or '',
|
||||
state=_map_state(group.get('Status') or ''),
|
||||
progress=progress,
|
||||
size=size_bytes,
|
||||
downloaded=downloaded_bytes,
|
||||
download_speed=speed,
|
||||
save_path=group.get('DestDir'),
|
||||
category=group.get('Category'),
|
||||
)
|
||||
|
||||
def _parse_history(self, entry: dict) -> UsenetStatus:
|
||||
# History entries have ``Status`` like ``SUCCESS/HEALTH``,
|
||||
# ``SUCCESS/UNPACK``, ``FAILURE/PAR``, etc.
|
||||
status_field = entry.get('Status') or ''
|
||||
is_failed = status_field.startswith('FAILURE')
|
||||
size_mb = self._mb_value(entry, 'FileSize')
|
||||
size_bytes = int(size_mb * 1024 * 1024) if size_mb else 0
|
||||
return UsenetStatus(
|
||||
id=str(entry.get('NZBID') or ''),
|
||||
name=entry.get('Name') or entry.get('NZBName') or '',
|
||||
state='failed' if is_failed else 'completed',
|
||||
progress=0.0 if is_failed else 1.0,
|
||||
size=size_bytes,
|
||||
downloaded=size_bytes if not is_failed else 0,
|
||||
download_speed=0,
|
||||
save_path=entry.get('DestDir'),
|
||||
category=entry.get('Category'),
|
||||
error=status_field if is_failed else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _mb_value(entry: dict, prefix: str) -> Optional[float]:
|
||||
"""Read an NZBGet size field. Prefers the high+low 32-bit split
|
||||
when available (most accurate); falls back to the ``MB``
|
||||
aggregate for older NZBGet versions."""
|
||||
lo = entry.get(f'{prefix}Lo')
|
||||
hi = entry.get(f'{prefix}Hi')
|
||||
if isinstance(lo, int) and isinstance(hi, int):
|
||||
total_bytes = (hi << 32) | lo
|
||||
return total_bytes / (1024 * 1024)
|
||||
mb = entry.get(f'{prefix}MB')
|
||||
if isinstance(mb, (int, float)):
|
||||
return float(mb)
|
||||
return None
|
||||
|
||||
async def remove(self, job_id: str, delete_files: bool = False) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._remove_sync, job_id, delete_files)
|
||||
|
||||
def _remove_sync(self, job_id: str, delete_files: bool) -> bool:
|
||||
# editqueue commands take a list of NZBIDs. ``GroupFinalDelete``
|
||||
# both removes and deletes downloaded data; ``GroupDelete`` just
|
||||
# removes the queue entry.
|
||||
try:
|
||||
id_int = int(job_id)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
command = 'GroupFinalDelete' if delete_files else 'GroupDelete'
|
||||
# editqueue(Command, Offset, EditText, IDs)
|
||||
result = self._rpc_sync('editqueue', [command, 0, '', [id_int]])
|
||||
return bool(result)
|
||||
|
||||
async def pause(self, job_id: str) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._pause_sync, job_id)
|
||||
|
||||
def _pause_sync(self, job_id: str) -> bool:
|
||||
try:
|
||||
id_int = int(job_id)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return bool(self._rpc_sync('editqueue', ['GroupPause', 0, '', [id_int]]))
|
||||
|
||||
async def resume(self, job_id: str) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._resume_sync, job_id)
|
||||
|
||||
def _resume_sync(self, job_id: str) -> bool:
|
||||
try:
|
||||
id_int = int(job_id)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return bool(self._rpc_sync('editqueue', ['GroupResume', 0, '', [id_int]]))
|
||||
284
core/usenet_clients/sabnzbd.py
Normal file
284
core/usenet_clients/sabnzbd.py
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
"""SABnzbd adapter.
|
||||
|
||||
Auth model: a single API key passed as ``?apikey=...`` on every
|
||||
request. No login flow. Every endpoint is the same path ``/api`` with
|
||||
a ``mode=`` query param.
|
||||
|
||||
Reference: https://sabnzbd.org/wiki/configuration/4.3/api
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import List, Optional, Union
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from config.settings import config_manager
|
||||
from core.usenet_clients.base import UsenetStatus
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("usenet.sabnzbd")
|
||||
|
||||
|
||||
# SAB queue states + history states → adapter-uniform set.
|
||||
# Queue: Idle, Paused, Downloading, Grabbing, Queued, Checking,
|
||||
# QuickCheck, Verifying, Repairing, Fetching, Extracting, Moving,
|
||||
# Running, Completed, Failed.
|
||||
_SAB_QUEUE_STATE_MAP = {
|
||||
"idle": "queued",
|
||||
"queued": "queued",
|
||||
"grabbing": "queued",
|
||||
"fetching": "downloading",
|
||||
"downloading": "downloading",
|
||||
"paused": "paused",
|
||||
"checking": "verifying",
|
||||
"quickcheck": "verifying",
|
||||
"verifying": "verifying",
|
||||
"repairing": "repairing",
|
||||
"extracting": "extracting",
|
||||
"moving": "extracting",
|
||||
"running": "extracting",
|
||||
"completed": "completed",
|
||||
"failed": "failed",
|
||||
}
|
||||
|
||||
|
||||
def _map_state(sab_state: str) -> str:
|
||||
return _SAB_QUEUE_STATE_MAP.get((sab_state or "").lower(), "error")
|
||||
|
||||
|
||||
class SABnzbdAdapter:
|
||||
"""SABnzbd REST API adapter (v2+)."""
|
||||
|
||||
DEFAULT_TIMEOUT = 15
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self) -> None:
|
||||
self._url = (config_manager.get('usenet_client.url', '') or '').rstrip('/')
|
||||
self._api_key = config_manager.get('usenet_client.api_key', '') or ''
|
||||
self._category = config_manager.get('usenet_client.category', 'soulsync') or 'soulsync'
|
||||
|
||||
def reload_settings(self) -> None:
|
||||
self._load_config()
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(self._url and self._api_key)
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
if not self.is_configured():
|
||||
return False
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._check_connection_sync)
|
||||
|
||||
def _check_connection_sync(self) -> bool:
|
||||
# ``mode=version`` is the cheapest authenticated probe SAB exposes.
|
||||
data = self._call_sync('version')
|
||||
return bool(data and data.get('version'))
|
||||
|
||||
def _call_sync(self, mode: str, **extra) -> Optional[dict]:
|
||||
if not self.is_configured():
|
||||
return None
|
||||
params = {
|
||||
'mode': mode,
|
||||
'output': 'json',
|
||||
'apikey': self._api_key,
|
||||
}
|
||||
params.update(extra)
|
||||
try:
|
||||
resp = http_requests.get(f"{self._url}/api", params=params, timeout=self.DEFAULT_TIMEOUT)
|
||||
if not resp.ok:
|
||||
logger.warning("SABnzbd mode=%s returned HTTP %s", mode, resp.status_code)
|
||||
return None
|
||||
return resp.json()
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
logger.error("SABnzbd mode=%s request failed: %s", mode, e)
|
||||
return None
|
||||
except ValueError as e:
|
||||
logger.error("SABnzbd mode=%s response was not JSON: %s", mode, e)
|
||||
return None
|
||||
|
||||
def _post_sync(self, mode: str, files=None, **extra) -> Optional[dict]:
|
||||
if not self.is_configured():
|
||||
return None
|
||||
params = {
|
||||
'mode': mode,
|
||||
'output': 'json',
|
||||
'apikey': self._api_key,
|
||||
}
|
||||
params.update(extra)
|
||||
try:
|
||||
resp = http_requests.post(f"{self._url}/api", params=params, files=files,
|
||||
timeout=self.DEFAULT_TIMEOUT)
|
||||
if not resp.ok:
|
||||
logger.warning("SABnzbd POST mode=%s returned HTTP %s", mode, resp.status_code)
|
||||
return None
|
||||
return resp.json()
|
||||
except http_requests.exceptions.RequestException as e:
|
||||
logger.error("SABnzbd POST mode=%s failed: %s", mode, e)
|
||||
return None
|
||||
except ValueError as e:
|
||||
logger.error("SABnzbd POST mode=%s response was not JSON: %s", mode, e)
|
||||
return None
|
||||
|
||||
async def add_nzb(
|
||||
self,
|
||||
url_or_bytes: Union[str, bytes],
|
||||
category: str = "soulsync",
|
||||
save_path: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(
|
||||
None, self._add_nzb_sync, url_or_bytes, category, save_path
|
||||
)
|
||||
|
||||
def _add_nzb_sync(
|
||||
self,
|
||||
url_or_bytes: Union[str, bytes],
|
||||
category: str,
|
||||
save_path: Optional[str],
|
||||
) -> Optional[str]:
|
||||
cat = category or self._category
|
||||
if isinstance(url_or_bytes, bytes):
|
||||
files = {'name': ('soulsync.nzb', url_or_bytes, 'application/x-nzb')}
|
||||
data = self._post_sync('addfile', files=files, cat=cat)
|
||||
else:
|
||||
data = self._call_sync('addurl', name=url_or_bytes, cat=cat)
|
||||
if not data or not data.get('status'):
|
||||
return None
|
||||
ids = data.get('nzo_ids') or []
|
||||
return ids[0] if ids else None
|
||||
|
||||
async def get_status(self, job_id: str) -> Optional[UsenetStatus]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._get_status_sync, job_id)
|
||||
|
||||
def _get_status_sync(self, job_id: str) -> Optional[UsenetStatus]:
|
||||
# Check active queue first; if not found, fall back to history.
|
||||
for status in self._get_all_sync():
|
||||
if status.id == job_id:
|
||||
return status
|
||||
return None
|
||||
|
||||
async def get_all(self) -> List[UsenetStatus]:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._get_all_sync)
|
||||
|
||||
def _get_all_sync(self) -> List[UsenetStatus]:
|
||||
out: List[UsenetStatus] = []
|
||||
# Active queue
|
||||
queue = self._call_sync('queue')
|
||||
if queue and isinstance(queue.get('queue'), dict):
|
||||
for slot in queue['queue'].get('slots', []) or []:
|
||||
out.append(self._parse_queue_slot(slot))
|
||||
# History — completed / failed jobs SAB still tracks
|
||||
history = self._call_sync('history', limit=50)
|
||||
if history and isinstance(history.get('history'), dict):
|
||||
for slot in history['history'].get('slots', []) or []:
|
||||
out.append(self._parse_history_slot(slot))
|
||||
return out
|
||||
|
||||
def _parse_queue_slot(self, slot: dict) -> UsenetStatus:
|
||||
try:
|
||||
percentage = float(slot.get('percentage') or 0.0)
|
||||
except (TypeError, ValueError):
|
||||
percentage = 0.0
|
||||
progress = percentage / 100.0
|
||||
# mb / mbleft are strings of MB values in SAB's queue API.
|
||||
size_mb = self._safe_float(slot.get('mb'))
|
||||
left_mb = self._safe_float(slot.get('mbleft'))
|
||||
size_bytes = int(size_mb * 1024 * 1024) if size_mb else 0
|
||||
downloaded_bytes = int((size_mb - left_mb) * 1024 * 1024) if size_mb and left_mb is not None else 0
|
||||
# ``timeleft`` is HH:MM:SS — convert to seconds.
|
||||
eta = self._parse_timeleft(slot.get('timeleft'))
|
||||
return UsenetStatus(
|
||||
id=str(slot.get('nzo_id') or ''),
|
||||
name=slot.get('filename') or slot.get('name') or '',
|
||||
state=_map_state(slot.get('status') or ''),
|
||||
progress=max(0.0, min(progress, 1.0)),
|
||||
size=size_bytes,
|
||||
downloaded=max(0, downloaded_bytes),
|
||||
download_speed=0, # queue endpoint doesn't include per-slot speed
|
||||
eta=eta,
|
||||
category=slot.get('cat'),
|
||||
)
|
||||
|
||||
def _parse_history_slot(self, slot: dict) -> UsenetStatus:
|
||||
# History entries are post-download — progress is 1.0 unless failed.
|
||||
sab_state = (slot.get('status') or '').lower()
|
||||
is_failed = sab_state == 'failed'
|
||||
return UsenetStatus(
|
||||
id=str(slot.get('nzo_id') or ''),
|
||||
name=slot.get('name') or '',
|
||||
state='failed' if is_failed else 'completed',
|
||||
progress=0.0 if is_failed else 1.0,
|
||||
size=int(slot.get('bytes') or 0),
|
||||
downloaded=int(slot.get('bytes') or 0) if not is_failed else 0,
|
||||
download_speed=0,
|
||||
save_path=slot.get('storage') or slot.get('path'),
|
||||
category=slot.get('category'),
|
||||
error=slot.get('fail_message') if is_failed else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _safe_float(value) -> Optional[float]:
|
||||
if value is None or value == '':
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_timeleft(value) -> Optional[int]:
|
||||
if not value or not isinstance(value, str):
|
||||
return None
|
||||
parts = value.split(':')
|
||||
try:
|
||||
if len(parts) == 3:
|
||||
h, m, s = parts
|
||||
return int(h) * 3600 + int(m) * 60 + int(s)
|
||||
if len(parts) == 2:
|
||||
m, s = parts
|
||||
return int(m) * 60 + int(s)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def remove(self, job_id: str, delete_files: bool = False) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._remove_sync, job_id, delete_files)
|
||||
|
||||
def _remove_sync(self, job_id: str, delete_files: bool) -> bool:
|
||||
# SAB deletes from queue or history depending on where the job is.
|
||||
# We try queue first; if SAB reports no-op, fall through to history.
|
||||
params = {'name': 'delete', 'value': job_id}
|
||||
if delete_files:
|
||||
params['del_files'] = 1
|
||||
data = self._call_sync('queue', **params)
|
||||
if data and data.get('status'):
|
||||
return True
|
||||
# History delete
|
||||
history_params = {'name': 'delete', 'value': job_id}
|
||||
if delete_files:
|
||||
history_params['del_files'] = 1
|
||||
data = self._call_sync('history', **history_params)
|
||||
return bool(data and data.get('status'))
|
||||
|
||||
async def pause(self, job_id: str) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._pause_sync, job_id)
|
||||
|
||||
def _pause_sync(self, job_id: str) -> bool:
|
||||
data = self._call_sync('queue', name='pause', value=job_id)
|
||||
return bool(data and data.get('status'))
|
||||
|
||||
async def resume(self, job_id: str) -> bool:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._resume_sync, job_id)
|
||||
|
||||
def _resume_sync(self, job_id: str) -> bool:
|
||||
data = self._call_sync('queue', name='resume', value=job_id)
|
||||
return bool(data and data.get('status'))
|
||||
|
|
@ -2753,7 +2753,7 @@ def handle_settings():
|
|||
if 'active_media_server' in new_settings:
|
||||
config_manager.set_active_media_server(new_settings['active_media_server'])
|
||||
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'prowlarr', 'torrent_client', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing']:
|
||||
for service in ['spotify', 'plex', 'jellyfin', 'navidrome', 'soulseek', 'download_source', 'settings', 'database', 'metadata_enhancement', 'file_organization', 'playlist_sync', 'tidal', 'tidal_download', 'qobuz', 'hifi_download', 'deezer_download', 'amazon_download', 'lidarr_download', 'prowlarr', 'torrent_client', 'usenet_client', 'listenbrainz', 'acoustid', 'lastfm', 'genius', 'import', 'lossy_copy', 'listening_stats', 'ui_appearance', 'youtube', 'content_filter', 'itunes', 'm3u_export', 'musicbrainz', 'deezer', 'audiodb', 'metadata', 'hydrabase', 'security', 'discogs', 'library', 'discover', 'wishlist', 'genre_whitelist', 'post_processing']:
|
||||
if service in new_settings:
|
||||
for key, value in new_settings[service].items():
|
||||
config_manager.set(f'{service}.{key}', value)
|
||||
|
|
|
|||
|
|
@ -5052,6 +5052,59 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ USENET CLIENT ═══ -->
|
||||
<div class="settings-group" data-stg="indexers">
|
||||
<h3>📰 Usenet Client</h3>
|
||||
<div class="setting-help-text" style="margin-bottom: 10px;">
|
||||
Where SoulSync sends NZBs once Prowlarr finds them. Pick one usenet downloader. SABnzbd uses an API key for auth; NZBGet uses a username + password.
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Client Type:</label>
|
||||
<select id="usenet-client-type" class="form-select" onchange="updateUsenetClientUI()">
|
||||
<option value="sabnzbd">SABnzbd</option>
|
||||
<option value="nzbget">NZBGet</option>
|
||||
</select>
|
||||
<div class="setting-help-text">
|
||||
SABnzbd: default WebUI port 8080. NZBGet: default WebUI port 6789.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>WebUI URL:</label>
|
||||
<input type="text" id="usenet-client-url" placeholder="http://localhost:8080">
|
||||
</div>
|
||||
<div class="form-group" id="usenet-apikey-group">
|
||||
<label>API Key:</label>
|
||||
<input type="password" id="usenet-client-api-key" placeholder="SABnzbd API key">
|
||||
<div class="setting-help-text">
|
||||
SABnzbd → Config → General → API Key. Used by SABnzbd only.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" id="usenet-username-group" style="display: none;">
|
||||
<label>Username:</label>
|
||||
<input type="text" id="usenet-client-username" placeholder="NZBGet WebUI username">
|
||||
</div>
|
||||
<div class="form-group" id="usenet-password-group" style="display: none;">
|
||||
<label>Password:</label>
|
||||
<input type="password" id="usenet-client-password" placeholder="NZBGet WebUI password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Category / Label:</label>
|
||||
<input type="text" id="usenet-client-category" placeholder="soulsync" value="soulsync">
|
||||
<div class="setting-help-text">
|
||||
SoulSync tags every NZB with this category so it ends up in a predictable post-processing folder.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Usenet Client Status:</label>
|
||||
<div class="form-actions" style="margin-top: 4px;">
|
||||
<button class="test-button" id="usenet-client-test-btn" onclick="testUsenetClientConnection()">
|
||||
Test Connection
|
||||
</button>
|
||||
<span id="usenet-client-connection-status" class="setting-help-text" style="margin-left: 8px;"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ PATHS & ORGANIZATION ═══ -->
|
||||
<div class="settings-section-header collapsed" data-stg="library" onclick="this.classList.toggle('collapsed'); const b=this.nextElementSibling; b.classList.toggle('collapsed'); b.style.display=b.classList.contains('collapsed')?'none':''">
|
||||
<span class="settings-section-arrow">▼</span>
|
||||
|
|
|
|||
|
|
@ -3415,6 +3415,7 @@ function closeHelperSearch() {
|
|||
const WHATS_NEW = {
|
||||
'2.6.0': [
|
||||
{ unreleased: true },
|
||||
{ title: 'Usenet client adapters (SABnzbd, NZBGet)', desc: 'third commit in the torrent + usenet rollout. SoulSync now talks to SABnzbd and NZBGet through a sibling adapter contract that mirrors the torrent adapter set — pick one downloader in Settings → Indexers & Downloaders, fill in its API key (SABnzbd) or username + password (NZBGet), and Test Connection confirms the link. all three layers are now stood up: Prowlarr finds releases, the torrent adapter and the usenet adapter each know how to ship work to the underlying client. next commit wires Prowlarr search → adapter dispatch → archive extraction so the new sources actually download. job state mapping covers SABnzbd queue + history and NZBGet groups + history, including the verify/repair/unpack phases that are unique to usenet.' },
|
||||
{ title: 'Torrent client adapters (qBittorrent, Transmission, Deluge)', desc: 'second commit in the torrent + usenet rollout. SoulSync can now talk to any of the three big torrent clients through a single adapter contract — pick which one you use in Settings → Indexers & Downloaders, paste your WebUI URL and credentials, and Test Connection confirms the link. each adapter handles its own auth quirk (qBit cookies, Transmission session-id, Deluge JSON-RPC) and maps native state strings onto a uniform set so the rest of the app stays client-agnostic. no download wiring yet — that gets layered on once the usenet client adapters land in the next commit.' },
|
||||
{ title: 'Prowlarr integration', desc: 'new Indexers & Downloaders tab in Settings. point SoulSync at your Prowlarr instance with a URL and API key, and you can browse the indexers Prowlarr exposes from inside the app. this is the search half of the upcoming torrent and usenet download sources — wires up the indexer list now so later commits can plug the download flow on top. Lidarr already pulls from its own indexers; Prowlarr unlocks the same search surface to the rest of the download pipeline.' },
|
||||
],
|
||||
|
|
@ -3476,6 +3477,19 @@ const WHATS_NEW = {
|
|||
// Section shape: { title, description, features: [bullet strings],
|
||||
// usage_note?: 'optional hint shown at the bottom' }
|
||||
const VERSION_MODAL_SECTIONS = [
|
||||
{
|
||||
title: "Usenet Client Adapters (SABnzbd, NZBGet)",
|
||||
description: "third phase of the torrent + usenet rollout. SoulSync now also talks to the two big usenet downloaders through a sibling adapter contract. Prowlarr + torrent + usenet are all stood up — next commit wires them together into actual download sources.",
|
||||
features: [
|
||||
"• supports SABnzbd (API-key auth) and NZBGet (JSON-RPC basic auth)",
|
||||
"• new Usenet Client section on the Indexers & Downloaders tab; client picker swaps the credential fields automatically (API key vs username + password)",
|
||||
"• state mapping covers the verify / repair / unpack phases unique to usenet",
|
||||
"• category override so SoulSync's NZBs land in a predictable post-processing folder",
|
||||
"• Test Connection probes the live API",
|
||||
"• next commit wires Prowlarr → adapter → archive extraction → match so the new sources fully download",
|
||||
],
|
||||
usage_note: "Settings → Indexers & Downloaders → Usenet Client",
|
||||
},
|
||||
{
|
||||
title: "Torrent Client Adapters (qBit, Transmission, Deluge)",
|
||||
description: "second phase of the torrent + usenet rollout. SoulSync now speaks the three big torrent client APIs through one uniform adapter — pick which client you use and SoulSync handles the auth and protocol quirks for you.",
|
||||
|
|
|
|||
|
|
@ -964,6 +964,19 @@ async function loadSettingsData() {
|
|||
if (_tcPass) _tcPass.value = settings.torrent_client?.password || '';
|
||||
if (_tcCat) _tcCat.value = settings.torrent_client?.category || 'soulsync';
|
||||
if (_tcPath) _tcPath.value = settings.torrent_client?.save_path || '';
|
||||
const _ucType = document.getElementById('usenet-client-type');
|
||||
const _ucUrl = document.getElementById('usenet-client-url');
|
||||
const _ucKey = document.getElementById('usenet-client-api-key');
|
||||
const _ucUser = document.getElementById('usenet-client-username');
|
||||
const _ucPass = document.getElementById('usenet-client-password');
|
||||
const _ucCat = document.getElementById('usenet-client-category');
|
||||
if (_ucType) _ucType.value = settings.usenet_client?.type || 'sabnzbd';
|
||||
if (_ucUrl) _ucUrl.value = settings.usenet_client?.url || '';
|
||||
if (_ucKey) _ucKey.value = settings.usenet_client?.api_key || '';
|
||||
if (_ucUser) _ucUser.value = settings.usenet_client?.username || '';
|
||||
if (_ucPass) _ucPass.value = settings.usenet_client?.password || '';
|
||||
if (_ucCat) _ucCat.value = settings.usenet_client?.category || 'soulsync';
|
||||
if (typeof updateUsenetClientUI === 'function') updateUsenetClientUI();
|
||||
// Sync ARL to connections tab field + bidirectional listeners
|
||||
const _connArl = document.getElementById('deezer-connection-arl');
|
||||
const _dlArl = document.getElementById('deezer-download-arl');
|
||||
|
|
@ -2722,6 +2735,14 @@ async function saveSettings(quiet = false) {
|
|||
category: document.getElementById('torrent-client-category')?.value || 'soulsync',
|
||||
save_path: document.getElementById('torrent-client-save-path')?.value || '',
|
||||
},
|
||||
usenet_client: {
|
||||
type: document.getElementById('usenet-client-type')?.value || 'sabnzbd',
|
||||
url: document.getElementById('usenet-client-url')?.value || '',
|
||||
api_key: document.getElementById('usenet-client-api-key')?.value || '',
|
||||
username: document.getElementById('usenet-client-username')?.value || '',
|
||||
password: document.getElementById('usenet-client-password')?.value || '',
|
||||
category: document.getElementById('usenet-client-category')?.value || 'soulsync',
|
||||
},
|
||||
soundcloud_download: {
|
||||
// No knobs yet — anonymous-only. Keeping the key present so
|
||||
// future tier-2 OAuth wiring (Go+ session token) doesn't have
|
||||
|
|
@ -3588,6 +3609,48 @@ async function testProwlarrConnection() {
|
|||
}
|
||||
}
|
||||
|
||||
function updateUsenetClientUI() {
|
||||
const type = document.getElementById('usenet-client-type')?.value || 'sabnzbd';
|
||||
const apikeyGroup = document.getElementById('usenet-apikey-group');
|
||||
const userGroup = document.getElementById('usenet-username-group');
|
||||
const passGroup = document.getElementById('usenet-password-group');
|
||||
if (type === 'sabnzbd') {
|
||||
if (apikeyGroup) apikeyGroup.style.display = '';
|
||||
if (userGroup) userGroup.style.display = 'none';
|
||||
if (passGroup) passGroup.style.display = 'none';
|
||||
} else {
|
||||
if (apikeyGroup) apikeyGroup.style.display = 'none';
|
||||
if (userGroup) userGroup.style.display = '';
|
||||
if (passGroup) passGroup.style.display = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function testUsenetClientConnection() {
|
||||
const statusEl = document.getElementById('usenet-client-connection-status');
|
||||
if (!statusEl) return;
|
||||
statusEl.textContent = 'Checking...';
|
||||
statusEl.style.color = '#aaa';
|
||||
try {
|
||||
await saveSettings();
|
||||
const resp = await fetch('/api/test-connection', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ service: 'usenet_client' })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
statusEl.textContent = data.message || 'Connected';
|
||||
statusEl.style.color = '#4caf50';
|
||||
} else {
|
||||
statusEl.textContent = data.error || 'Connection failed';
|
||||
statusEl.style.color = '#f44336';
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Connection error';
|
||||
statusEl.style.color = '#f44336';
|
||||
}
|
||||
}
|
||||
|
||||
async function testTorrentClientConnection() {
|
||||
const statusEl = document.getElementById('torrent-client-connection-status');
|
||||
if (!statusEl) return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue