Added sabnzbd support

This commit is contained in:
Baretsky
2025-07-25 21:54:12 +02:00
parent 182052678d
commit 8b8e497fe7
12 changed files with 530 additions and 18 deletions

View File

@@ -3,18 +3,18 @@ name: Test, Build, Deploy
on:
push:
branches:
- '*'
- "*"
paths:
- 'src/**'
- "src/**"
pull_request:
branches:
- '*'
- "*"
paths:
- 'src/**'
- "src/**"
workflow_dispatch:
create:
branches:
- '*'
- "*"
jobs:
validate-branch-name:
@@ -44,7 +44,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10.13'
python-version: "3.10.13"
- name: Install testing dependencies
run: |
python -m pip install --upgrade pip
@@ -60,7 +60,7 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
fetch-depth: '0'
fetch-depth: "0"
- name: Bump version and push tag
if: ${{ github.ref_name == 'latest' }}
@@ -70,7 +70,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
WITH_V: true
RELEASE_BRANCHES: latest
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -104,7 +104,7 @@ jobs:
# Convert IMAGE_TAG to lowercase
IMAGE_TAG=$(echo $IMAGE_TAG | tr '[:upper:]' '[:lower:]')
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
# Convert repository owner to lowercase
REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')
echo "REPO_OWNER=$REPO_OWNER" >> $GITHUB_ENV

View File

@@ -3,7 +3,7 @@ name: Branch Delete
on:
delete:
branches:
- '*'
- "*"
jobs:
delete-branch-docker-image:
@@ -30,4 +30,4 @@ jobs:
uses: dataaxiom/ghcr-cleanup-action@v1.0.8
with:
delete-tags: ${{ env.BRANCH_NAME }}
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -66,3 +66,7 @@ download_clients:
# username: xxxx # (optional -> if not provided, assuming not needed)
# password: xxxx # (optional -> if not provided, assuming not needed)
# name: "qBittorrent" # (optional -> if not provided, assuming "qBittorrent". Must correspond with what is specified in your *arr as download client name)
# sabnzbd:
# - base_url: "http://sabnzbd:8080" # SABnzbd server URL
# api_key: "your_api_key_here" # (required -> SABnzbd API key)
# # name: "SABnzbd" # (optional -> if not provided, assuming "SABnzbd". Must correspond with what is specified in your *arr as download client name)

View File

@@ -44,7 +44,7 @@ async def main():
while True:
logger.info("-" * 50)
# Refresh qBit Cookies
# Refresh qBit Cookies (SABnzbd doesn't need cookie refresh)
for qbit in settings.download_clients.qbittorrent:
await qbit.refresh_cookie()

View File

@@ -92,6 +92,17 @@ class JobManager:
f">>> qBittorrent is disconnected. Skipping queue cleaning on {self.arr.name}.",
)
return False
for sabnzbd in self.settings.download_clients.sabnzbd:
logger.debug(
f"job_manager.py/_queue_has_items (Before any removal jobs): Checking if SABnzbd is connected"
)
# Check if any client is disconnected
if not await sabnzbd.check_sabnzbd_connected():
logger.warning(
f">>> SABnzbd is disconnected. Skipping queue cleaning on {self.arr.name}.",
)
return False
return True
def _get_removal_jobs(self):

View File

@@ -42,6 +42,11 @@ class RemoveBadFiles(RemovalJob):
if download_client_type == "qbittorrent":
client_items = await self._handle_qbit(download_client, download_ids)
affected_items.extend(client_items)
elif download_client_type == "sabnzbd":
# SABnzbd doesn't support bad file removal in the same way as BitTorrent
# Usenet doesn't have the concept of "availability" or individual file selection
logger.debug("remove_bad_files: Skipping SABnzbd downloads (not applicable for Usenet)")
continue
return affected_items
def _group_download_ids_by_client(self):

View File

@@ -31,7 +31,7 @@ class RemoveSlow(RemovalJob):
# Is Usenet -> skip
if self._is_usenet(item):
continue # No need to check for speed for usenet, since there users pay for speed
continue # No need to check for speed for usenet
# Completed but stuck -> skip
if self._is_completed_but_stuck(item):
@@ -103,7 +103,7 @@ class RemoveSlow(RemovalJob):
async def _get_download_progress(self, item, download_id):
# Grabs the progress from qbit if possible, else calculates it based on progress (imprecise)
# Grabs the progress from qbit or SABnzbd if possible, else calculates it based on progress (imprecise)
if item["download_client_type"] == "qbittorrent":
try:
progress = await item["download_client"].fetch_download_progress(download_id)
@@ -111,6 +111,14 @@ class RemoveSlow(RemovalJob):
return progress
except Exception: # noqa: BLE001
pass # fall back below
elif item["download_client_type"] == "sabnzbd":
try:
progress = await item["download_client"].get_download_progress(download_id)
if progress is not None:
# SABnzbd returns percentage, convert to bytes
return (progress / 100.0) * item["size"]
except Exception: # noqa: BLE001
pass # fall back below
return item["size"] - item["sizeleft"]
def _compute_increment_and_speed(self, download_id, current_progress):
@@ -135,6 +143,7 @@ class RemoveSlow(RemovalJob):
if download_client.bandwidth_usage > DISABLE_OVER_BANDWIDTH_USAGE:
self.strikes_handler.pause_entry(download_id, "High Bandwidth Usage")
return True
# SABnzbd: Users typically pay for their Usenet speed, so bandwidth checking isn't applicable
return False
@@ -157,4 +166,5 @@ class RemoveSlow(RemovalJob):
continue
if item["download_client_type"] == "qbittorrent":
await download_client.set_bandwidth_usage()
# SABnzbd doesn't need bandwidth usage tracking for the same reason as usenet
processed_clients.add(item["download_client"])

View File

@@ -35,6 +35,7 @@ class MinVersions:
readarr = "0.4.15.2787"
whisparr = "2.0.0.548"
qbittorrent = "4.3.0"
sabnzbd = "4.0.0"
class FullQueueParameter:

View File

@@ -1,16 +1,19 @@
from src.settings._config_as_yaml import get_config_as_yaml
from src.settings._download_clients_qbit import QbitClients
from src.settings._download_clients_sabnzbd import SabnzbdClients
DOWNLOAD_CLIENT_TYPES = ["qbittorrent"]
DOWNLOAD_CLIENT_TYPES = ["qbittorrent", "sabnzbd"]
class DownloadClients:
"""Represents all download clients."""
qbittorrent = None
sabnzbd = None
def __init__(self, config, settings):
self._set_qbit_clients(config, settings)
self._set_sabnzbd_clients(config, settings)
self.check_unique_download_client_types()
def _set_qbit_clients(self, config, settings):
@@ -28,11 +31,18 @@ class DownloadClients:
]:
setattr(settings.general, key, None)
def _set_sabnzbd_clients(self, config, settings):
download_clients = config.get("download_clients", {})
if isinstance(download_clients, dict):
self.sabnzbd = SabnzbdClients(config, settings)
if not self.sabnzbd:
self.sabnzbd = SabnzbdClients({}, settings) # Initialize empty list
def config_as_yaml(self):
"""Log all download clients."""
return get_config_as_yaml(
{"qbittorrent": self.qbittorrent},
sensitive_attributes={"username", "password", "cookie"},
{"qbittorrent": self.qbittorrent, "sabnzbd": self.sabnzbd},
sensitive_attributes={"username", "password", "cookie", "api_key"},
internal_attributes={"api_url", "cookie", "settings", "min_version"},
hide_internal_attr=True,
)
@@ -101,7 +111,7 @@ class DownloadClients:
"""
mapping = {
"QBittorrent": "qbittorrent",
# Only qbit configured for now
"SABnzbd": "sabnzbd",
}
download_client_type = mapping.get(arr_download_client_implementation)
return download_client_type

View File

@@ -0,0 +1,304 @@
from packaging import version
from src.settings._constants import MinVersions
from src.utils.common import make_request, wait_and_exit, extract_json_from_response
from src.utils.log_setup import logger
class SabnzbdError(Exception):
pass
class SabnzbdClients(list):
"""Represents all SABnzbd clients."""
def __init__(self, config, settings):
super().__init__()
self._set_sabnzbd_clients(config, settings)
def _set_sabnzbd_clients(self, config, settings):
sabnzbd_config = config.get("download_clients", {}).get("sabnzbd", [])
if not isinstance(sabnzbd_config, list):
logger.error(
"Invalid config format for sabnzbd clients. Expected a list.",
)
return
for client_config in sabnzbd_config:
try:
self.append(SabnzbdClient(settings, **client_config))
except (TypeError, ValueError) as e:
logger.error(f"Error parsing sabnzbd client config: {e}")
class SabnzbdClient:
"""Represents a single SABnzbd client."""
version: str = None
def __init__(
self,
settings,
base_url: str = None,
api_key: str = None,
name: str = None,
):
self.settings = settings
if not base_url:
logger.error("Skipping SABnzbd client entry: 'base_url' is required.")
error = "SABnzbd client must have a 'base_url'."
raise ValueError(error)
if not api_key:
logger.error("Skipping SABnzbd client entry: 'api_key' is required.")
error = "SABnzbd client must have an 'api_key'."
raise ValueError(error)
self.base_url = base_url.rstrip("/")
self.api_url = self.base_url + "/api"
self.min_version = MinVersions.sabnzbd
self.api_key = api_key
self.name = name
if not self.name:
logger.verbose(
"No name provided for sabnzbd client, assuming 'SABnzbd'. If the name used in your *arr is different, please correct either the name in your *arr, or set the name in your config"
)
self.name = "SABnzbd"
self._remove_none_attributes()
def _remove_none_attributes(self):
"""Remove attributes that are None to keep the object clean."""
for attr in list(vars(self)):
if getattr(self, attr) is None:
delattr(self, attr)
async def fetch_version(self):
"""Fetch the current SABnzbd version."""
logger.debug("_download_clients_sabnzbd.py/fetch_version: Getting SABnzbd Version")
params = {
"mode": "version",
"apikey": self.api_key,
"output": "json"
}
response = await make_request(
"get", self.api_url, self.settings, params=params
)
response_data = response.json()
self.version = response_data.get("version", "unknown")
logger.debug(
f"_download_clients_sabnzbd.py/fetch_version: SABnzbd version={self.version}"
)
async def validate_version(self):
"""Check if the SABnzbd version meets minimum requirements."""
min_version = self.settings.min_versions.sabnzbd
if version.parse(self.version) < version.parse(min_version):
logger.error(
f"Please update SABnzbd to at least version {min_version}. Current version: {self.version}",
)
error = f"SABnzbd version {self.version} is too old. Please update."
raise SabnzbdError(error)
async def check_sabnzbd_reachability(self):
"""Check if the SABnzbd URL is reachable."""
try:
logger.debug(
"_download_clients_sabnzbd.py/check_sabnzbd_reachability: Checking if SABnzbd is reachable"
)
params = {
"mode": "version",
"apikey": self.api_key,
"output": "json"
}
await make_request(
"get",
self.api_url,
self.settings,
params=params,
log_error=False,
ignore_test_run=True,
)
except Exception as e: # noqa: BLE001
tip = "💡 Tip: Did you specify the URL and API key correctly?"
logger.error(f"-- | SABnzbd\n❗️ {e}\n{tip}\n")
wait_and_exit()
async def check_sabnzbd_connected(self):
"""Check if SABnzbd is connected and operational."""
logger.debug(
"_download_clients_sabnzbd.py/check_sabnzbd_connected: Checking if SABnzbd is connected"
)
params = {
"mode": "status",
"apikey": self.api_key,
"output": "json"
}
response = await make_request(
"get",
self.api_url,
self.settings,
params=params,
)
status_data = response.json()
# SABnzbd doesn't have a direct "disconnected" status like qBittorrent
# We check if we can get status successfully
return "status" in status_data
async def setup(self):
"""Perform the SABnzbd setup by calling relevant managers."""
# Check reachability
await self.check_sabnzbd_reachability()
try:
# Fetch version and validate it
await self.fetch_version()
await self.validate_version()
logger.info(f"OK | SABnzbd ({self.base_url})")
except SabnzbdError as e:
logger.error(f"SABnzbd version check failed: {e}")
wait_and_exit() # Exit if version check fails
async def get_queue_items(self):
"""Fetch queue items from SABnzbd."""
logger.debug("_download_clients_sabnzbd.py/get_queue_items: Getting queue items")
params = {
"mode": "queue",
"apikey": self.api_key,
"output": "json"
}
response = await make_request(
"get",
self.api_url,
self.settings,
params=params,
)
queue_data = response.json()
return queue_data.get("queue", {}).get("slots", [])
async def get_history_items(self):
"""Fetch history items from SABnzbd."""
logger.debug("_download_clients_sabnzbd.py/get_history_items: Getting history items")
params = {
"mode": "history",
"apikey": self.api_key,
"output": "json"
}
response = await make_request(
"get",
self.api_url,
self.settings,
params=params,
)
history_data = response.json()
return history_data.get("history", {}).get("slots", [])
async def remove_download(self, nzo_id: str):
"""Remove a download from SABnzbd queue."""
logger.debug(f"_download_clients_sabnzbd.py/remove_download: Removing download {nzo_id}")
params = {
"mode": "queue",
"name": "delete",
"value": nzo_id,
"apikey": self.api_key,
"output": "json"
}
await make_request(
"get",
self.api_url,
self.settings,
params=params,
)
async def pause_download(self, nzo_id: str):
"""Pause a download in SABnzbd queue."""
logger.debug(f"_download_clients_sabnzbd.py/pause_download: Pausing download {nzo_id}")
params = {
"mode": "queue",
"name": "pause",
"value": nzo_id,
"apikey": self.api_key,
"output": "json"
}
await make_request(
"get",
self.api_url,
self.settings,
params=params,
)
async def resume_download(self, nzo_id: str):
"""Resume a download in SABnzbd queue."""
logger.debug(f"_download_clients_sabnzbd.py/resume_download: Resuming download {nzo_id}")
params = {
"mode": "queue",
"name": "resume",
"value": nzo_id,
"apikey": self.api_key,
"output": "json"
}
await make_request(
"get",
self.api_url,
self.settings,
params=params,
)
async def retry_download(self, nzo_id: str):
"""Retry a failed download from SABnzbd history."""
logger.debug(f"_download_clients_sabnzbd.py/retry_download: Retrying download {nzo_id}")
params = {
"mode": "retry",
"value": nzo_id,
"apikey": self.api_key,
"output": "json"
}
await make_request(
"get",
self.api_url,
self.settings,
params=params,
)
async def get_download_progress(self, nzo_id: str):
"""Get progress of a specific download."""
queue_items = await self.get_queue_items()
for item in queue_items:
if item.get("nzo_id") == nzo_id:
# Calculate progress: (size - remaining) / size * 100
size_total = float(item.get("size", 0))
size_left = float(item.get("sizeleft", 0))
if size_total > 0:
progress = ((size_total - size_left) / size_total) * 100
return progress
return None
async def get_download_speed(self, nzo_id: str = None):
"""Get current download speed from SABnzbd status."""
params = {
"mode": "status",
"apikey": self.api_key,
"output": "json"
}
response = await make_request(
"get",
self.api_url,
self.settings,
params=params,
)
status_data = response.json()
speed_str = status_data.get("status", {}).get("speed", "0 KB/s")
# Convert speed string to KB/s
# SABnzbd returns speed like "1.2 MB/s", "500 KB/s", etc.
if "MB/s" in speed_str:
speed_value = float(speed_str.replace(" MB/s", ""))
return speed_value * 1024 # Convert MB/s to KB/s
elif "KB/s" in speed_str:
speed_value = float(speed_str.replace(" KB/s", ""))
return speed_value
else:
return 0.0

View File

@@ -62,6 +62,10 @@ async def launch_steps(settings):
for qbit in settings.download_clients.qbittorrent:
await qbit.setup()
# Check SABnzbd connections and versions
for sabnzbd in settings.download_clients.sabnzbd:
await sabnzbd.setup()
# Setup arrs (apply checks, and store information)
settings.instances.check_any_arrs()
for arr in settings.instances:

View File

@@ -0,0 +1,163 @@
import pytest
from unittest.mock import Mock, AsyncMock
from src.settings._download_clients_sabnzbd import SabnzbdClient, SabnzbdClients, SabnzbdError
from src.settings.settings import Settings
class TestSabnzbdClient:
def test_init_minimal_config(self):
"""Test SabnzbdClient initialization with minimal required config."""
settings = Mock()
settings.min_versions = Mock()
settings.min_versions.sabnzbd = "4.0.0"
client = SabnzbdClient(
settings=settings,
base_url="http://sabnzbd:8080",
api_key="test_api_key"
)
assert client.base_url == "http://sabnzbd:8080"
assert client.api_url == "http://sabnzbd:8080/api"
assert client.api_key == "test_api_key"
assert client.name == "SABnzbd"
def test_init_full_config(self):
"""Test SabnzbdClient initialization with full config."""
settings = Mock()
settings.min_versions = Mock()
settings.min_versions.sabnzbd = "4.0.0"
client = SabnzbdClient(
settings=settings,
base_url="http://sabnzbd:8080/",
api_key="test_api_key",
name="Custom SABnzbd"
)
assert client.base_url == "http://sabnzbd:8080"
assert client.api_url == "http://sabnzbd:8080/api"
assert client.api_key == "test_api_key"
assert client.name == "Custom SABnzbd"
def test_init_missing_base_url(self):
"""Test SabnzbdClient initialization fails without base_url."""
settings = Mock()
with pytest.raises(ValueError, match="SABnzbd client must have a 'base_url'"):
SabnzbdClient(settings=settings, api_key="test_api_key")
def test_init_missing_api_key(self):
"""Test SabnzbdClient initialization fails without api_key."""
settings = Mock()
with pytest.raises(ValueError, match="SABnzbd client must have an 'api_key'"):
SabnzbdClient(settings=settings, base_url="http://sabnzbd:8080")
@pytest.mark.asyncio
async def test_get_download_progress(self):
"""Test getting download progress for a specific download."""
settings = Mock()
settings.min_versions = Mock()
settings.min_versions.sabnzbd = "4.0.0"
client = SabnzbdClient(
settings=settings,
base_url="http://sabnzbd:8080",
api_key="test_api_key"
)
# Mock the get_queue_items method
client.get_queue_items = AsyncMock(return_value=[
{
"nzo_id": "test_id_1",
"size": "1000",
"sizeleft": "200"
},
{
"nzo_id": "test_id_2",
"size": "2000",
"sizeleft": "1000"
}
])
# Test getting progress for existing download
progress = await client.get_download_progress("test_id_1")
expected_progress = ((1000 - 200) / 1000) * 100 # 80%
assert progress == expected_progress
# Test getting progress for non-existing download
progress = await client.get_download_progress("non_existing_id")
assert progress is None
class TestSabnzbdClients:
def test_init_empty_config(self):
"""Test SabnzbdClients initialization with empty config."""
config = {"download_clients": {}}
settings = Mock()
clients = SabnzbdClients(config, settings)
assert len(clients) == 0
def test_init_valid_config(self):
"""Test SabnzbdClients initialization with valid config."""
config = {
"download_clients": {
"sabnzbd": [
{
"base_url": "http://sabnzbd1:8080",
"api_key": "api_key_1"
},
{
"base_url": "http://sabnzbd2:8080",
"api_key": "api_key_2",
"name": "SABnzbd 2"
}
]
}
}
settings = Mock()
settings.min_versions = Mock()
settings.min_versions.sabnzbd = "4.0.0"
clients = SabnzbdClients(config, settings)
assert len(clients) == 2
assert clients[0].base_url == "http://sabnzbd1:8080"
assert clients[0].api_key == "api_key_1"
assert clients[0].name == "SABnzbd"
assert clients[1].base_url == "http://sabnzbd2:8080"
assert clients[1].api_key == "api_key_2"
assert clients[1].name == "SABnzbd 2"
def test_init_invalid_config_format(self, caplog):
"""Test SabnzbdClients initialization with invalid config format."""
config = {
"download_clients": {
"sabnzbd": "not_a_list"
}
}
settings = Mock()
clients = SabnzbdClients(config, settings)
assert len(clients) == 0
assert "Invalid config format for sabnzbd clients" in caplog.text
def test_init_missing_required_field(self, caplog):
"""Test SabnzbdClients initialization with missing required fields."""
config = {
"download_clients": {
"sabnzbd": [
{
"base_url": "http://sabnzbd:8080"
# Missing api_key
}
]
}
}
settings = Mock()
clients = SabnzbdClients(config, settings)
assert len(clients) == 0
assert "Error parsing sabnzbd client config" in caplog.text