diff --git a/tests/test_prowlarr_client.py b/tests/test_prowlarr_client.py
new file mode 100644
index 00000000..eb3fe6c0
--- /dev/null
+++ b/tests/test_prowlarr_client.py
@@ -0,0 +1,223 @@
+"""Tests for ``core/prowlarr_client.py``.
+
+Pins the parse + dispatch behavior so a future Prowlarr API tweak
+that drops a field doesn't silently lose data, and the search
+endpoint keeps building the repeated-key query Prowlarr expects.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+from core.prowlarr_client import (
+ DEFAULT_MUSIC_CATEGORIES,
+ ProwlarrClient,
+ ProwlarrIndexer,
+ ProwlarrSearchResult,
+)
+
+
+def _run(coro):
+ return asyncio.new_event_loop().run_until_complete(coro)
+
+
+def _client_with_config(url="http://prowlarr:9696", api_key="secret"):
+ """Build a client whose ``_load_config`` already ran with the
+ given URL + key, sidestepping the real config_manager."""
+ client = ProwlarrClient.__new__(ProwlarrClient)
+ client._url = url.rstrip('/')
+ client._api_key = api_key
+ return client
+
+
+# ---------------------------------------------------------------------------
+# Pure parsers
+# ---------------------------------------------------------------------------
+
+
+def test_parse_indexer_extracts_core_fields() -> None:
+ client = _client_with_config()
+ entry = {
+ 'id': 7,
+ 'name': 'Public Tracker',
+ 'protocol': 'torrent',
+ 'enable': True,
+ 'privacy': 'public',
+ 'capabilities': {
+ 'categories': [
+ {'id': 3000, 'name': 'Audio'},
+ {'id': 3040, 'name': 'Audio/Lossless'},
+ ],
+ },
+ }
+ indexer = client._parse_indexer(entry)
+ assert indexer == ProwlarrIndexer(
+ id=7,
+ name='Public Tracker',
+ protocol='torrent',
+ enable=True,
+ privacy='public',
+ categories=[3000, 3040],
+ capabilities=entry['capabilities'],
+ )
+
+
+def test_parse_indexer_tolerates_missing_capabilities() -> None:
+ """Some indexers (the ones in error state) come back with no
+ ``capabilities`` block — must not crash."""
+ client = _client_with_config()
+ indexer = client._parse_indexer({'id': 1, 'name': 'X', 'protocol': 'usenet'})
+ assert indexer.id == 1
+ assert indexer.protocol == 'usenet'
+ assert indexer.categories == []
+
+
+def test_parse_result_extracts_torrent_fields() -> None:
+ client = _client_with_config()
+ entry = {
+ 'guid': 'guid-1',
+ 'title': 'Some Album FLAC',
+ 'indexerId': 3,
+ 'indexer': 'Tracker',
+ 'protocol': 'torrent',
+ 'downloadUrl': 'https://example.com/x.torrent',
+ 'magnetUrl': 'magnet:?xt=urn:btih:abc',
+ 'infoUrl': 'https://example.com/details/1',
+ 'size': 524288000,
+ 'seeders': 12,
+ 'leechers': 3,
+ 'grabs': 100,
+ 'publishDate': '2026-05-10T00:00:00Z',
+ 'categories': [{'id': 3040, 'name': 'Audio/Lossless'}],
+ }
+ result = client._parse_result(entry)
+ assert result.title == 'Some Album FLAC'
+ assert result.indexer_id == 3
+ assert result.download_url == 'https://example.com/x.torrent'
+ assert result.magnet_uri == 'magnet:?xt=urn:btih:abc'
+ assert result.size == 524288000
+ assert result.seeders == 12
+ assert result.categories == [3040]
+
+
+def test_parse_result_accepts_int_categories() -> None:
+ """Some indexers return categories as bare ints instead of
+ ``{id, name}`` dicts. Both forms must work."""
+ client = _client_with_config()
+ result = client._parse_result({'title': 'X', 'categories': [3000, 3010]})
+ assert result.categories == [3000, 3010]
+
+
+def test_parse_result_skips_garbage_category_entries() -> None:
+ client = _client_with_config()
+ result = client._parse_result({'title': 'X', 'categories': [{'name': 'no-id'}, 'string', None]})
+ assert result.categories == []
+
+
+# ---------------------------------------------------------------------------
+# Configured-state predicates
+# ---------------------------------------------------------------------------
+
+
+def test_is_configured_requires_both_fields() -> None:
+ assert _client_with_config('http://x', '').is_configured() is False
+ assert _client_with_config('', 'key').is_configured() is False
+ assert _client_with_config('http://x', 'key').is_configured() is True
+
+
+def test_check_connection_returns_false_when_not_configured() -> None:
+ client = _client_with_config('', '')
+ assert _run(client.check_connection()) is False
+
+
+# ---------------------------------------------------------------------------
+# HTTP plumbing
+# ---------------------------------------------------------------------------
+
+
+def _mock_response(status_code: int, json_body):
+ resp = MagicMock()
+ resp.ok = 200 <= status_code < 400
+ resp.status_code = status_code
+ resp.json.return_value = json_body
+ return resp
+
+
+def test_search_passes_repeated_categories_and_indexer_ids() -> None:
+ """Prowlarr's search endpoint expects repeated query keys —
+ ``categories=3000&categories=3010&indexerIds=1``. ``requests``
+ serializes a list of tuples into that exact form, so we assert
+ the params are passed as a list-of-tuples (not a dict)."""
+ client = _client_with_config()
+ captured_params = {}
+
+ def fake_get(url, headers=None, params=None, timeout=None):
+ captured_params['url'] = url
+ captured_params['params'] = params
+ return _mock_response(200, [])
+
+ with patch('core.prowlarr_client.http_requests.get', side_effect=fake_get):
+ _run(client.search('the query', categories=[3000, 3010], indexer_ids=[1, 5]))
+
+ assert captured_params['url'] == 'http://prowlarr:9696/api/v1/search'
+ params = captured_params['params']
+ # Convert to a frozenset of pairs for order-independent comparison
+ pair_set = set(params)
+ assert ('query', 'the query') in pair_set
+ assert ('type', 'search') in pair_set
+ assert ('categories', 3000) in pair_set
+ assert ('categories', 3010) in pair_set
+ assert ('indexerIds', 1) in pair_set
+ assert ('indexerIds', 5) in pair_set
+
+
+def test_search_returns_empty_on_blank_query() -> None:
+ client = _client_with_config()
+ # No HTTP mock — call must short-circuit without touching the network.
+ results = _run(client.search(''))
+ assert results == []
+ results = _run(client.search(' '))
+ assert results == []
+
+
+def test_search_parses_response_list() -> None:
+ client = _client_with_config()
+ with patch('core.prowlarr_client.http_requests.get',
+ return_value=_mock_response(200, [
+ {'guid': 'a', 'title': 'Album A', 'protocol': 'torrent'},
+ {'guid': 'b', 'title': 'Album B', 'protocol': 'usenet'},
+ ])):
+ results = _run(client.search('q'))
+ assert [r.title for r in results] == ['Album A', 'Album B']
+ assert [r.protocol for r in results] == ['torrent', 'usenet']
+
+
+def test_check_connection_hits_system_status() -> None:
+ client = _client_with_config()
+ with patch('core.prowlarr_client.http_requests.get',
+ return_value=_mock_response(200, {'version': '1.13.0'})) as mock_get:
+ ok = _run(client.check_connection())
+ assert ok is True
+ called_url = mock_get.call_args.args[0]
+ assert called_url == 'http://prowlarr:9696/api/v1/system/status'
+ assert mock_get.call_args.kwargs['headers']['X-Api-Key'] == 'secret'
+
+
+def test_check_connection_returns_false_on_http_error() -> None:
+ client = _client_with_config()
+ with patch('core.prowlarr_client.http_requests.get',
+ return_value=_mock_response(401, {'error': 'unauthorized'})):
+ ok = _run(client.check_connection())
+ assert ok is False
+
+
+def test_default_music_categories_match_newznab_tree() -> None:
+ """The Newznab Music category IDs are a stable convention across
+ Prowlarr / Jackett / every indexer. Pin the defaults so a typo
+ here doesn't silently broaden / narrow what SoulSync queries."""
+ assert 3000 in DEFAULT_MUSIC_CATEGORIES # Audio (parent)
+ assert 3010 in DEFAULT_MUSIC_CATEGORIES # MP3
+ assert 3040 in DEFAULT_MUSIC_CATEGORIES # Lossless
diff --git a/tests/test_torrent_client_adapters.py b/tests/test_torrent_client_adapters.py
new file mode 100644
index 00000000..0f813b69
--- /dev/null
+++ b/tests/test_torrent_client_adapters.py
@@ -0,0 +1,339 @@
+"""Tests for the three torrent client adapters.
+
+Pins state-mapping behavior (each client has a different native state
+vocabulary that must collapse onto the adapter-uniform set) and basic
+HTTP / RPC plumbing so a future protocol-spec drift fails CI instead
+of silently breaking downloads.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from core.torrent_clients import adapter_for_type, get_active_adapter
+from core.torrent_clients.base import TorrentClientAdapter, TorrentStatus
+from core.torrent_clients.deluge import DelugeAdapter, _map_state as deluge_map
+from core.torrent_clients.qbittorrent import QBittorrentAdapter, _map_state as qbit_map
+from core.torrent_clients.transmission import TransmissionAdapter, _map_state as trans_map
+
+
+def _run(coro):
+ return asyncio.new_event_loop().run_until_complete(coro)
+
+
+def _mock_response(status_code: int, json_body=None, text=None, headers=None):
+ resp = MagicMock()
+ resp.ok = 200 <= status_code < 400
+ resp.status_code = status_code
+ resp.headers = headers or {}
+ if json_body is not None:
+ resp.json.return_value = json_body
+ resp.text = text or ''
+ return resp
+
+
+# ---------------------------------------------------------------------------
+# Factory
+# ---------------------------------------------------------------------------
+
+
+def test_adapter_for_type_returns_concrete_classes() -> None:
+ assert isinstance(adapter_for_type('qbittorrent'), QBittorrentAdapter)
+ assert isinstance(adapter_for_type('transmission'), TransmissionAdapter)
+ assert isinstance(adapter_for_type('deluge'), DelugeAdapter)
+
+
+def test_adapter_for_type_returns_none_for_unknown() -> None:
+ assert adapter_for_type('utorrent') is None
+ assert adapter_for_type('') is None
+
+
+def test_adapters_conform_to_protocol() -> None:
+ """``isinstance`` checks the runtime_checkable Protocol — catches
+ adapters that lose a required method during refactors."""
+ for adapter in (QBittorrentAdapter(), TransmissionAdapter(), DelugeAdapter()):
+ assert isinstance(adapter, TorrentClientAdapter)
+
+
+# ---------------------------------------------------------------------------
+# State mapping
+# ---------------------------------------------------------------------------
+
+
+def test_qbittorrent_state_mapping() -> None:
+ assert qbit_map('downloading') == 'downloading'
+ assert qbit_map('forcedDL') == 'downloading'
+ assert qbit_map('stalledDL') == 'stalled'
+ assert qbit_map('uploading') == 'seeding'
+ assert qbit_map('pausedUP') == 'completed'
+ assert qbit_map('pausedDL') == 'paused'
+ assert qbit_map('error') == 'error'
+ assert qbit_map('missingFiles') == 'error'
+ # Unknown native value → error rather than swallowing silently.
+ assert qbit_map('not-a-real-state') == 'error'
+
+
+def test_transmission_state_mapping() -> None:
+ assert trans_map(4, 0.5) == 'downloading'
+ assert trans_map(6, 1.0) == 'seeding'
+ # Status 0 is the ambiguous one: paused vs completed-but-not-seeding.
+ assert trans_map(0, 0.3) == 'paused'
+ assert trans_map(0, 1.0) == 'completed'
+ assert trans_map(2, 0.0) == 'queued' # checking files
+ # Unknown numeric code → error.
+ assert trans_map(99, 0.0) == 'error'
+
+
+def test_deluge_state_mapping() -> None:
+ assert deluge_map('Downloading', 0.5) == 'downloading'
+ assert deluge_map('Seeding', 1.0) == 'seeding'
+ assert deluge_map('Paused', 0.4) == 'paused'
+ # Deluge reports 'Paused' for completed-not-seeding too.
+ assert deluge_map('Paused', 1.0) == 'completed'
+ assert deluge_map('Error', 0.0) == 'error'
+ assert deluge_map('', 0.0) == 'error'
+
+
+# ---------------------------------------------------------------------------
+# qBittorrent adapter
+# ---------------------------------------------------------------------------
+
+
+def _qbit_with_config(url='http://qbit:8080', username='admin', password='x'):
+ adapter = QBittorrentAdapter.__new__(QBittorrentAdapter)
+ import threading
+ adapter._session = None
+ adapter._session_lock = threading.Lock()
+ adapter._url = url.rstrip('/')
+ adapter._username = username
+ adapter._password = password
+ adapter._category = 'soulsync'
+ adapter._save_path = ''
+ return adapter
+
+
+def test_qbit_is_configured_requires_only_url() -> None:
+ # qBittorrent allows no-auth LAN setups — URL is enough.
+ assert _qbit_with_config('http://x', '', '').is_configured() is True
+ assert _qbit_with_config('', 'u', 'p').is_configured() is False
+
+
+def test_qbit_login_sends_referer_for_csrf() -> None:
+ """qBittorrent rejects login attempts without a Referer matching
+ its host — pin the header to catch regressions."""
+ adapter = _qbit_with_config()
+ fake_session = MagicMock()
+ fake_session.post.return_value = _mock_response(200, text='Ok.')
+ fake_session.post.return_value.text = 'Ok.'
+ with patch('core.torrent_clients.qbittorrent.http_requests.Session',
+ return_value=fake_session):
+ sess = adapter._ensure_session_sync()
+ assert sess is not None
+ args, kwargs = fake_session.post.call_args
+ assert args[0].endswith('/api/v2/auth/login')
+ assert kwargs['headers']['Referer'] == 'http://qbit:8080'
+ assert kwargs['data'] == {'username': 'admin', 'password': 'x'}
+
+
+def test_qbit_login_failure_returns_none() -> None:
+ adapter = _qbit_with_config()
+ fake_session = MagicMock()
+ bad_resp = _mock_response(200, text='Fails.')
+ bad_resp.text = 'Fails.'
+ fake_session.post.return_value = bad_resp
+ with patch('core.torrent_clients.qbittorrent.http_requests.Session',
+ return_value=fake_session):
+ sess = adapter._ensure_session_sync()
+ assert sess is None
+
+
+def test_qbit_parse_status_normalises_native_fields() -> None:
+ adapter = _qbit_with_config()
+ status = adapter._parse_status({
+ 'hash': 'abc123', 'name': 'Album',
+ 'state': 'downloading', 'progress': 0.5,
+ 'size': 1024, 'downloaded': 512,
+ 'dlspeed': 200, 'upspeed': 50,
+ 'num_seeds': 4, 'num_leechs': 1,
+ 'eta': 60, 'save_path': '/data/torrents',
+ })
+ assert status == TorrentStatus(
+ id='abc123', name='Album', state='downloading',
+ progress=0.5, size=1024, downloaded=512,
+ download_speed=200, upload_speed=50, seeders=4, peers=1,
+ eta=60, save_path='/data/torrents',
+ )
+
+
+def test_qbit_parse_status_zeros_eta_when_unknown() -> None:
+ adapter = _qbit_with_config()
+ # qBittorrent uses 8640000 for "unknown" but the adapter just
+ # treats anything <= 0 as unknown; pin that 0 maps to None.
+ status = adapter._parse_status({
+ 'hash': 'x', 'name': 'X', 'state': 'stalledDL',
+ 'progress': 0.0, 'size': 100, 'downloaded': 0,
+ 'dlspeed': 0, 'upspeed': 0, 'eta': 0,
+ })
+ assert status.eta is None
+
+
+# ---------------------------------------------------------------------------
+# Transmission adapter
+# ---------------------------------------------------------------------------
+
+
+def _trans_with_config(url='http://trans:9091/transmission/rpc'):
+ adapter = TransmissionAdapter.__new__(TransmissionAdapter)
+ import threading
+ adapter._session_id = None
+ adapter._session_id_lock = threading.Lock()
+ adapter._url = url
+ adapter._username = ''
+ adapter._password = ''
+ adapter._category = 'soulsync'
+ adapter._save_path = ''
+ return adapter
+
+
+def test_transmission_normalises_bare_host_to_rpc_path() -> None:
+ """Users sometimes paste ``http://host:9091``; the adapter must
+ append ``/transmission/rpc`` so the request hits the right
+ endpoint."""
+ adapter = TransmissionAdapter.__new__(TransmissionAdapter)
+ with patch('core.torrent_clients.transmission.config_manager') as cm:
+ cm.get.side_effect = lambda key, default='': {
+ 'torrent_client.url': 'http://host:9091',
+ 'torrent_client.username': '',
+ 'torrent_client.password': '',
+ 'torrent_client.category': 'soulsync',
+ 'torrent_client.save_path': '',
+ }.get(key, default)
+ import threading
+ adapter._session_id = None
+ adapter._session_id_lock = threading.Lock()
+ adapter._load_config()
+ assert adapter._url == 'http://host:9091/transmission/rpc'
+
+
+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
+ and retry the same call exactly once."""
+ adapter = _trans_with_config()
+ first = _mock_response(409, headers={'X-Transmission-Session-Id': 'sid-2'})
+ second = _mock_response(200, json_body={'result': 'success', 'arguments': {'session-id': 1}})
+ with patch('core.torrent_clients.transmission.http_requests.post',
+ side_effect=[first, second]) as mock_post:
+ result = adapter._rpc('session-get', {})
+ assert result == {'session-id': 1}
+ assert mock_post.call_count == 2
+ # Second call carried the new session id.
+ second_call_kwargs = mock_post.call_args_list[1].kwargs
+ assert second_call_kwargs['headers']['X-Transmission-Session-Id'] == 'sid-2'
+
+
+def test_transmission_rpc_returns_none_on_failure_result() -> None:
+ adapter = _trans_with_config()
+ with patch('core.torrent_clients.transmission.http_requests.post',
+ return_value=_mock_response(200, json_body={'result': 'unknown method'})):
+ assert adapter._rpc('bogus', {}) is None
+
+
+def test_transmission_add_torrent_handles_duplicate() -> None:
+ """torrent-add returns either ``torrent-added`` (new) or
+ ``torrent-duplicate`` (already-there) — both must surface the hash."""
+ adapter = _trans_with_config()
+ with patch.object(adapter, '_rpc', return_value={'torrent-duplicate': {'hashString': 'dup'}}):
+ hash_id = adapter._add_torrent_sync('magnet:?xt=urn:btih:abc', 'cat', None)
+ assert hash_id == 'dup'
+
+
+def test_transmission_parse_status() -> None:
+ adapter = _trans_with_config()
+ status = adapter._parse_status({
+ 'hashString': 'h', 'name': 'X', 'status': 4, 'percentDone': 0.42,
+ 'totalSize': 100, 'downloadedEver': 42,
+ 'rateDownload': 10, 'rateUpload': 5,
+ 'peersSendingToUs': 2, 'peersGettingFromUs': 0,
+ 'eta': 300, 'downloadDir': '/dl', 'errorString': '',
+ })
+ assert status.id == 'h'
+ assert status.state == 'downloading'
+ assert status.progress == 0.42
+ assert status.eta == 300
+
+
+def test_transmission_parse_status_negative_eta_is_none() -> None:
+ """Transmission reports -1 / -2 for 'unknown' ETA — must normalise to None."""
+ adapter = _trans_with_config()
+ status = adapter._parse_status({
+ 'hashString': 'h', 'name': 'X', 'status': 4, 'percentDone': 0.0,
+ 'totalSize': 100, 'downloadedEver': 0,
+ 'rateDownload': 0, 'rateUpload': 0,
+ 'peersSendingToUs': 0, 'peersGettingFromUs': 0,
+ 'eta': -1, 'downloadDir': '/dl',
+ })
+ assert status.eta is None
+
+
+# ---------------------------------------------------------------------------
+# Deluge adapter
+# ---------------------------------------------------------------------------
+
+
+def _deluge_with_config(url='http://deluge:8112', password='delugepass'):
+ adapter = DelugeAdapter.__new__(DelugeAdapter)
+ import threading
+ from itertools import count
+ adapter._session = None
+ adapter._session_lock = threading.Lock()
+ adapter._id_counter = count(1)
+ adapter._url = url.rstrip('/')
+ adapter._password = password
+ adapter._category = 'soulsync'
+ adapter._save_path = ''
+ return adapter
+
+
+def test_deluge_is_configured_requires_password() -> None:
+ assert _deluge_with_config('http://x', '').is_configured() is False
+ assert _deluge_with_config('http://x', 'pw').is_configured() is True
+
+
+def test_deluge_add_torrent_uses_magnet_method() -> None:
+ adapter = _deluge_with_config()
+ with patch.object(adapter, '_ensure_session_sync', return_value=MagicMock()), \
+ patch.object(adapter, '_rpc_sync', return_value='hash123') as mock_rpc:
+ hash_id = adapter._add_torrent_sync('magnet:?xt=urn:btih:abc', 'cat', None)
+ assert hash_id == 'hash123'
+ # First call was core.add_torrent_magnet, not the URL variant.
+ first_method = mock_rpc.call_args_list[0].args[0]
+ assert first_method == 'core.add_torrent_magnet'
+
+
+def test_deluge_add_torrent_uses_url_method_for_http() -> None:
+ adapter = _deluge_with_config()
+ with patch.object(adapter, '_ensure_session_sync', return_value=MagicMock()), \
+ patch.object(adapter, '_rpc_sync', return_value='hash456') as mock_rpc:
+ hash_id = adapter._add_torrent_sync('https://example.com/x.torrent', 'cat', None)
+ assert hash_id == 'hash456'
+ first_method = mock_rpc.call_args_list[0].args[0]
+ assert first_method == 'core.add_torrent_url'
+
+
+def test_deluge_parse_status_normalises_percent_progress() -> None:
+ """Deluge reports progress as 0-100 (not 0-1) — adapter must
+ normalise."""
+ adapter = _deluge_with_config()
+ status = adapter._parse_status({
+ 'hash': 'abc', 'name': 'X', 'state': 'Downloading',
+ 'progress': 42.0,
+ 'total_size': 1000, 'total_done': 420,
+ 'download_payload_rate': 100, 'upload_payload_rate': 0,
+ 'num_seeds': 1, 'num_peers': 0, 'eta': 0,
+ })
+ assert status.progress == pytest.approx(0.42)
+ assert status.state == 'downloading'
diff --git a/tests/test_usenet_client_adapters.py b/tests/test_usenet_client_adapters.py
new file mode 100644
index 00000000..8dfb28c6
--- /dev/null
+++ b/tests/test_usenet_client_adapters.py
@@ -0,0 +1,297 @@
+"""Tests for the SABnzbd and NZBGet usenet adapters.
+
+Pins state-mapping behavior and the queue-vs-history merge logic so
+get_all returns both active and completed jobs without losing
+either bucket.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from core.usenet_clients import adapter_for_type
+from core.usenet_clients.base import UsenetClientAdapter, UsenetStatus
+from core.usenet_clients.nzbget import NZBGetAdapter, _map_state as nzbget_map
+from core.usenet_clients.sabnzbd import SABnzbdAdapter, _map_state as sab_map
+
+
+def _run(coro):
+ return asyncio.new_event_loop().run_until_complete(coro)
+
+
+def _mock_response(status_code: int, json_body=None):
+ resp = MagicMock()
+ resp.ok = 200 <= status_code < 400
+ resp.status_code = status_code
+ if json_body is not None:
+ resp.json.return_value = json_body
+ return resp
+
+
+# ---------------------------------------------------------------------------
+# Factory + protocol conformance
+# ---------------------------------------------------------------------------
+
+
+def test_adapter_for_type_returns_concrete_classes() -> None:
+ assert isinstance(adapter_for_type('sabnzbd'), SABnzbdAdapter)
+ assert isinstance(adapter_for_type('nzbget'), NZBGetAdapter)
+
+
+def test_adapter_for_type_returns_none_for_unknown() -> None:
+ assert adapter_for_type('unknown') is None
+
+
+def test_adapters_conform_to_protocol() -> None:
+ for adapter in (SABnzbdAdapter(), NZBGetAdapter()):
+ assert isinstance(adapter, UsenetClientAdapter)
+
+
+# ---------------------------------------------------------------------------
+# SABnzbd
+# ---------------------------------------------------------------------------
+
+
+def _sab_with_config(url='http://sab:8080', api_key='k'):
+ adapter = SABnzbdAdapter.__new__(SABnzbdAdapter)
+ adapter._url = url.rstrip('/')
+ adapter._api_key = api_key
+ adapter._category = 'soulsync'
+ return adapter
+
+
+def test_sab_is_configured_requires_url_and_key() -> None:
+ assert _sab_with_config('http://x', '').is_configured() is False
+ assert _sab_with_config('', 'k').is_configured() is False
+ assert _sab_with_config('http://x', 'k').is_configured() is True
+
+
+def test_sab_state_mapping_covers_queue_states() -> None:
+ assert sab_map('Downloading') == 'downloading'
+ assert sab_map('Verifying') == 'verifying'
+ assert sab_map('Repairing') == 'repairing'
+ assert sab_map('Extracting') == 'extracting'
+ assert sab_map('Paused') == 'paused'
+ assert sab_map('Failed') == 'failed'
+ # Case-insensitive — SAB sometimes returns lowercase.
+ assert sab_map('downloading') == 'downloading'
+ assert sab_map('') == 'error'
+
+
+def test_sab_parse_timeleft_handles_hhmmss() -> None:
+ # SABnzbd's timeleft is always HH:MM:SS (or H:MM:SS).
+ assert SABnzbdAdapter._parse_timeleft('01:30:00') == 5400
+ assert SABnzbdAdapter._parse_timeleft('00:05:30') == 330
+ assert SABnzbdAdapter._parse_timeleft('00:30:00') == 1800
+ # 2-part fallback covers MM:SS for robustness.
+ assert SABnzbdAdapter._parse_timeleft('05:30') == 330
+ assert SABnzbdAdapter._parse_timeleft('garbage') is None
+ assert SABnzbdAdapter._parse_timeleft('') is None
+ assert SABnzbdAdapter._parse_timeleft(None) is None
+
+
+def test_sab_parse_queue_slot_converts_mb_to_bytes() -> None:
+ adapter = _sab_with_config()
+ status = adapter._parse_queue_slot({
+ 'nzo_id': 'SABnzbd_nzo_1',
+ 'filename': 'Album.nzb',
+ 'status': 'Downloading',
+ 'percentage': '42',
+ 'mb': '100',
+ 'mbleft': '58',
+ 'timeleft': '0:01:00',
+ 'cat': 'soulsync',
+ })
+ assert status.id == 'SABnzbd_nzo_1'
+ assert status.state == 'downloading'
+ assert status.progress == pytest.approx(0.42)
+ assert status.size == 100 * 1024 * 1024
+ assert status.downloaded == 42 * 1024 * 1024
+ assert status.eta == 60
+
+
+def test_sab_parse_history_slot_marks_failures() -> None:
+ adapter = _sab_with_config()
+ failed = adapter._parse_history_slot({
+ 'nzo_id': 'x', 'name': 'X', 'status': 'Failed',
+ 'bytes': 1024, 'fail_message': 'Damaged',
+ })
+ assert failed.state == 'failed'
+ assert failed.progress == 0.0
+ assert failed.error == 'Damaged'
+
+ success = adapter._parse_history_slot({
+ 'nzo_id': 'y', 'name': 'Y', 'status': 'Completed',
+ 'bytes': 1024, 'storage': '/done',
+ })
+ assert success.state == 'completed'
+ assert success.progress == 1.0
+ assert success.save_path == '/done'
+
+
+def test_sab_add_nzb_via_url_returns_first_nzo_id() -> None:
+ adapter = _sab_with_config()
+ with patch('core.usenet_clients.sabnzbd.http_requests.get',
+ return_value=_mock_response(200, {'status': True, 'nzo_ids': ['SABnzbd_1']})) as mock_get:
+ job_id = _run(adapter.add_nzb('https://example.com/x.nzb', category='cat'))
+ assert job_id == 'SABnzbd_1'
+ params = mock_get.call_args.kwargs['params']
+ assert params['mode'] == 'addurl'
+ assert params['apikey'] == 'k'
+ assert params['name'] == 'https://example.com/x.nzb'
+ assert params['cat'] == 'cat'
+
+
+def test_sab_add_nzb_via_bytes_uses_addfile_multipart() -> None:
+ adapter = _sab_with_config()
+ with patch('core.usenet_clients.sabnzbd.http_requests.post',
+ return_value=_mock_response(200, {'status': True, 'nzo_ids': ['SABnzbd_2']})) as mock_post:
+ job_id = _run(adapter.add_nzb(b'', category='cat'))
+ assert job_id == 'SABnzbd_2'
+ assert mock_post.call_args.kwargs['params']['mode'] == 'addfile'
+ files = mock_post.call_args.kwargs['files']
+ assert 'name' in files
+ assert files['name'][1] == b''
+
+
+def test_sab_get_all_merges_queue_and_history() -> None:
+ """SAB's queue and history are separate endpoints; the adapter
+ must hit both so completed jobs surface in the global list."""
+ adapter = _sab_with_config()
+ queue_resp = _mock_response(200, {'queue': {'slots': [
+ {'nzo_id': 'q1', 'filename': 'A.nzb', 'status': 'Downloading',
+ 'percentage': '10', 'mb': '100', 'mbleft': '90', 'timeleft': '0:01:00'},
+ ]}})
+ history_resp = _mock_response(200, {'history': {'slots': [
+ {'nzo_id': 'h1', 'name': 'B.nzb', 'status': 'Completed', 'bytes': 1024},
+ ]}})
+ with patch('core.usenet_clients.sabnzbd.http_requests.get',
+ side_effect=[queue_resp, history_resp]):
+ statuses = adapter._get_all_sync()
+ assert [s.id for s in statuses] == ['q1', 'h1']
+ assert [s.state for s in statuses] == ['downloading', 'completed']
+
+
+# ---------------------------------------------------------------------------
+# NZBGet
+# ---------------------------------------------------------------------------
+
+
+def _nzbget_with_config(url='http://nzbget:6789', username='u', password='p'):
+ adapter = NZBGetAdapter.__new__(NZBGetAdapter)
+ from itertools import count
+ adapter._id_counter = count(1)
+ adapter._url = url.rstrip('/')
+ adapter._username = username
+ adapter._password = password
+ adapter._category = 'soulsync'
+ return adapter
+
+
+def test_nzbget_is_configured_requires_all_three() -> None:
+ assert _nzbget_with_config('', 'u', 'p').is_configured() is False
+ assert _nzbget_with_config('http://x', '', 'p').is_configured() is False
+ assert _nzbget_with_config('http://x', 'u', '').is_configured() is False
+ assert _nzbget_with_config('http://x', 'u', 'p').is_configured() is True
+
+
+def test_nzbget_state_mapping_covers_post_process_phases() -> None:
+ assert nzbget_map('DOWNLOADING') == 'downloading'
+ assert nzbget_map('PAUSED') == 'paused'
+ assert nzbget_map('LOADING_PARS') == 'verifying'
+ assert nzbget_map('REPAIRING') == 'repairing'
+ assert nzbget_map('UNPACKING') == 'extracting'
+ assert nzbget_map('PP_FINISHED') == 'completed'
+ assert nzbget_map('') == 'error'
+
+
+def test_nzbget_mb_value_prefers_64bit_split() -> None:
+ """NZBGet ships size as FileSizeHi << 32 | FileSizeLo for clients
+ that need precision past 2 GB. Prefer that over the legacy MB
+ field when both are present."""
+ val = NZBGetAdapter._mb_value({'FileSizeLo': 1024 * 1024, 'FileSizeHi': 0, 'FileSizeMB': 999}, 'FileSize')
+ assert val == 1.0
+
+
+def test_nzbget_mb_value_falls_back_to_mb() -> None:
+ val = NZBGetAdapter._mb_value({'FileSizeMB': 500}, 'FileSize')
+ assert val == 500.0
+
+
+def test_nzbget_add_nzb_url_passes_through_unchanged() -> None:
+ adapter = _nzbget_with_config()
+ captured = {}
+
+ def fake_post(url, json=None, auth=None, headers=None, timeout=None):
+ captured['payload'] = json
+ return _mock_response(200, {'result': 42})
+
+ with patch('core.usenet_clients.nzbget.http_requests.post', side_effect=fake_post):
+ job_id = _run(adapter.add_nzb('https://x/x.nzb', category='cat'))
+
+ assert job_id == '42'
+ params = captured['payload']['params']
+ assert params[0] == '' # NZBFilename empty when content is a URL
+ assert params[1] == 'https://x/x.nzb'
+ assert params[2] == 'cat'
+
+
+def test_nzbget_add_nzb_bytes_base64_encodes() -> None:
+ adapter = _nzbget_with_config()
+ captured = {}
+
+ def fake_post(url, json=None, auth=None, headers=None, timeout=None):
+ captured['payload'] = json
+ return _mock_response(200, {'result': 7})
+
+ with patch('core.usenet_clients.nzbget.http_requests.post', side_effect=fake_post):
+ _run(adapter.add_nzb(b'', category='cat'))
+
+ params = captured['payload']['params']
+ assert params[0] == 'soulsync.nzb'
+ assert params[1] == base64.b64encode(b'').decode('ascii')
+
+
+def test_nzbget_remove_uses_groupfinal_when_deleting_files() -> None:
+ """``GroupFinalDelete`` deletes downloaded data on disk;
+ ``GroupDelete`` just removes the queue entry. The adapter must
+ pick the right one based on the ``delete_files`` flag."""
+ adapter = _nzbget_with_config()
+ with patch.object(adapter, '_rpc_sync', return_value=True) as mock_rpc:
+ adapter._remove_sync('42', delete_files=True)
+ adapter._remove_sync('42', delete_files=False)
+ cmds = [c.args[1][0] for c in mock_rpc.call_args_list]
+ assert cmds == ['GroupFinalDelete', 'GroupDelete']
+
+
+def test_nzbget_parse_group_computes_progress() -> None:
+ adapter = _nzbget_with_config()
+ status = adapter._parse_group({
+ 'NZBID': 99,
+ 'NZBName': 'Album.nzb',
+ 'Status': 'DOWNLOADING',
+ 'FileSizeLo': 200 * 1024 * 1024, 'FileSizeHi': 0,
+ 'RemainingSizeLo': 100 * 1024 * 1024, 'RemainingSizeHi': 0,
+ 'DownloadRate': 500_000,
+ 'DestDir': '/incomplete',
+ 'Category': 'soulsync',
+ })
+ assert status.id == '99'
+ assert status.state == 'downloading'
+ assert status.size == 200 * 1024 * 1024
+ assert status.downloaded == 100 * 1024 * 1024
+ assert status.progress == pytest.approx(0.5)
+ assert status.download_speed == 500_000
+
+
+def test_nzbget_remove_rejects_non_numeric_id() -> None:
+ """NZBGet IDs are ints; passing a string id like 'abc' must
+ fail fast instead of corrupting the editqueue call."""
+ adapter = _nzbget_with_config()
+ with patch.object(adapter, '_rpc_sync') as mock_rpc:
+ assert adapter._remove_sync('not-a-number', delete_files=False) is False
+ mock_rpc.assert_not_called()
diff --git a/webui/static/helper.js b/webui/static/helper.js
index b8cd1ce7..17bc8026 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3415,6 +3415,7 @@ function closeHelperSearch() {
const WHATS_NEW = {
'2.6.0': [
{ unreleased: true },
+ { title: 'Regression tests for the new indexer + downloader plumbing', desc: 'mocked unit tests for Prowlarr + all five downloader adapters (qBittorrent, Transmission, Deluge, SABnzbd, NZBGet). 54 tests pin the state-mapping tables, the parse logic, and the protocol quirks each client needs handled (qBit Referer header, Transmission session-id renegotiation, Deluge magnet-vs-URL method split, SAB queue+history merge, NZBGet 64-bit size fields). next time anyone touches one of these adapters, CI catches breakage before it hits a real downloader.' },
{ 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.' },