diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f1dc321..80aedc7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/.github/workflows/delete.yml b/.github/workflows/delete.yml index 421d6d8..aaedfd5 100644 --- a/.github/workflows/delete.yml +++ b/.github/workflows/delete.yml @@ -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 }} \ No newline at end of file + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/config/config_example.yaml b/config/config_example.yaml index 5688a54..59bcb15 100644 --- a/config/config_example.yaml +++ b/config/config_example.yaml @@ -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) diff --git a/main.py b/main.py index 44746ad..b5dd868 100644 --- a/main.py +++ b/main.py @@ -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() diff --git a/src/job_manager.py b/src/job_manager.py index 01342cf..6d3c79b 100644 --- a/src/job_manager.py +++ b/src/job_manager.py @@ -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): diff --git a/src/jobs/remove_bad_files.py b/src/jobs/remove_bad_files.py index f050014..be90c98 100644 --- a/src/jobs/remove_bad_files.py +++ b/src/jobs/remove_bad_files.py @@ -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): diff --git a/src/jobs/remove_slow.py b/src/jobs/remove_slow.py index 04c214d..5ffcecf 100644 --- a/src/jobs/remove_slow.py +++ b/src/jobs/remove_slow.py @@ -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"]) diff --git a/src/settings/_constants.py b/src/settings/_constants.py index 2397993..6e38b89 100644 --- a/src/settings/_constants.py +++ b/src/settings/_constants.py @@ -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: diff --git a/src/settings/_download_clients.py b/src/settings/_download_clients.py index 20af494..bffec0c 100644 --- a/src/settings/_download_clients.py +++ b/src/settings/_download_clients.py @@ -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 diff --git a/src/settings/_download_clients_sabnzbd.py b/src/settings/_download_clients_sabnzbd.py new file mode 100644 index 0000000..5e81c6e --- /dev/null +++ b/src/settings/_download_clients_sabnzbd.py @@ -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 \ No newline at end of file diff --git a/src/utils/startup.py b/src/utils/startup.py index 2cdd8ee..e3aacc5 100644 --- a/src/utils/startup.py +++ b/src/utils/startup.py @@ -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: diff --git a/tests/settings/test_sabnzbd_clients.py b/tests/settings/test_sabnzbd_clients.py new file mode 100644 index 0000000..828b154 --- /dev/null +++ b/tests/settings/test_sabnzbd_clients.py @@ -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 \ No newline at end of file