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.
pull/665/head
Broque Thomas 5 days ago
parent de2faf290b
commit 7a3ce50f71

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

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

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

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

@ -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">&#9660;</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…
Cancel
Save