diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 1582bd74..dc7617d1 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -29,6 +29,7 @@ from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_clea from shelfmark.download.postprocess.router import post_process_download from shelfmark.release_sources import ( get_handler, + get_source, get_source_display_name, ) @@ -108,6 +109,13 @@ def _parse_release_search_mode(value: object) -> SearchMode: raise ValueError(msg) +def _source_unavailable_message(source_name: str) -> str | None: + source = get_source(source_name) + if source.is_available(): + return None + return f"{source.display_name} is unavailable. Enable and configure the source in Settings." + + def _optional_number(value: object) -> float | None: if isinstance(value, bool): return float(value) @@ -207,6 +215,11 @@ def queue_release( """Add a release to the download queue. Returns (success, error_message).""" try: source = release_data["source"] + unavailable_message = _source_unavailable_message(source) + if unavailable_message: + logger.warning("Rejected queue request for unavailable source %s", source) + return False, unavailable_message + extra = release_data.get("extra", {}) raw_request_id = release_data.get("_request_id") request_id: int | None = None @@ -598,6 +611,16 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None: logger.error("Task not found in queue: %s", task_id) return None + unavailable_message = _source_unavailable_message(task.source) + if unavailable_message: + logger.warning("Task %s: source unavailable: %s", task_id, unavailable_message) + _capture_task_error( + task, + message=unavailable_message, + exc_type="SourceUnavailable", + ) + return None + title_label = task.title or "Unknown title" logger.info( "Task %s: starting download (%s) - %s", diff --git a/tests/core/test_request_routes_api.py b/tests/core/test_request_routes_api.py index 30e27e85..a1fac085 100644 --- a/tests/core/test_request_routes_api.py +++ b/tests/core/test_request_routes_api.py @@ -1532,6 +1532,12 @@ class TestRequestRoutes: def test_admin_fulfil_uses_real_queue_and_preserves_requesting_identity( self, main_module, client ): + class AvailableSource: + display_name = "Direct Download" + + def is_available(self): + return True + user = _create_user(main_module, prefix="reader") other_user = _create_user(main_module, prefix="reader") admin = _create_user(main_module, prefix="admin", role="admin") @@ -1567,13 +1573,23 @@ class TestRequestRoutes: "shelfmark.core.request_routes.load_users_request_policy_settings", return_value=policy, ): - create_resp = client.post("/api/requests", json=create_payload) - request_id = create_resp.json["id"] + with patch.object( + main_module.backend, + "get_source", + return_value=AvailableSource(), + ): + create_resp = client.post("/api/requests", json=create_payload) + request_id = create_resp.json["id"] - _set_session( - client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True - ) - fulfil_resp = client.post(f"/api/admin/requests/{request_id}/fulfil", json={}) + _set_session( + client, + user_id=admin["username"], + db_user_id=admin["id"], + is_admin=True, + ) + fulfil_resp = client.post( + f"/api/admin/requests/{request_id}/fulfil", json={} + ) assert fulfil_resp.status_code == 200 assert fulfil_resp.json["status"] == "fulfilled" diff --git a/tests/download/test_orchestrator_retry.py b/tests/download/test_orchestrator_retry.py index 3f95bd3d..fc822f98 100644 --- a/tests/download/test_orchestrator_retry.py +++ b/tests/download/test_orchestrator_retry.py @@ -4,10 +4,26 @@ from pathlib import Path from threading import Event from unittest.mock import MagicMock +import pytest + from shelfmark.core.models import DownloadTask, QueueStatus from shelfmark.core.queue import BookQueue +class _AvailableSource: + display_name = "Test Source" + + def is_available(self): + return True + + +@pytest.fixture(autouse=True) +def source_available_by_default(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource()) + + def test_retry_download_requeues_error_task(monkeypatch): import shelfmark.download.orchestrator as orchestrator diff --git a/tests/download/test_orchestrator_user_output_mode.py b/tests/download/test_orchestrator_user_output_mode.py index b28b8847..e62623e7 100644 --- a/tests/download/test_orchestrator_user_output_mode.py +++ b/tests/download/test_orchestrator_user_output_mode.py @@ -1,6 +1,30 @@ +from threading import Event from unittest.mock import MagicMock -from shelfmark.core.models import SearchMode +import pytest + +from shelfmark.core.models import DownloadTask, SearchMode + + +class _AvailableSource: + display_name = "Test Source" + + def is_available(self): + return True + + +class _UnavailableSource: + display_name = "Direct Download" + + def is_available(self): + return False + + +@pytest.fixture(autouse=True) +def source_available_by_default(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource()) def enable_prowlarr_seed_preferences(monkeypatch, orchestrator): @@ -86,6 +110,28 @@ def test_queue_release_preserves_direct_search_mode_from_payload(monkeypatch): assert captured["task"].search_mode == SearchMode.DIRECT +def test_queue_release_rejects_unavailable_source(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + monkeypatch.setattr(orchestrator, "get_source", lambda _source: _UnavailableSource()) + monkeypatch.setattr(orchestrator.book_queue, "add", MagicMock()) + + success, error = orchestrator.queue_release( + { + "source": "direct_download", + "source_id": "release-disabled-direct", + "title": "Disabled Direct Release", + "content_type": "ebook", + }, + user_id=42, + username="alice", + ) + + assert success is False + assert error == "Direct Download is unavailable. Enable and configure the source in Settings." + orchestrator.book_queue.add.assert_not_called() + + def test_queue_release_email_mode_without_recipient_is_queued(monkeypatch): import shelfmark.download.orchestrator as orchestrator @@ -124,6 +170,41 @@ def test_queue_release_email_mode_without_recipient_is_queued(monkeypatch): assert task.output_args == {} +def test_download_task_rejects_unavailable_source_before_handler(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + task = DownloadTask( + task_id="disabled-task", + source="direct_download", + title="Disabled Direct Release", + ) + status_messages: list[tuple[str, str]] = [] + + monkeypatch.setattr(orchestrator, "get_source", lambda _source: _UnavailableSource()) + monkeypatch.setattr(orchestrator, "get_handler", MagicMock()) + monkeypatch.setattr(orchestrator.book_queue, "get_task", lambda _task_id: task) + monkeypatch.setattr( + orchestrator.book_queue, + "update_status_message", + lambda task_id, message: status_messages.append((task_id, message)), + ) + + result = orchestrator._download_task("disabled-task", Event()) + + assert result is None + assert task.last_error_type == "SourceUnavailable" + assert task.last_error_message == ( + "Direct Download is unavailable. Enable and configure the source in Settings." + ) + assert status_messages == [ + ( + "disabled-task", + "Direct Download is unavailable. Enable and configure the source in Settings.", + ) + ] + orchestrator.get_handler.assert_not_called() + + def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch): import shelfmark.download.orchestrator as orchestrator diff --git a/tests/newznab/test_source.py b/tests/newznab/test_source.py index 4b70919b..a50720cd 100644 --- a/tests/newznab/test_source.py +++ b/tests/newznab/test_source.py @@ -2,6 +2,8 @@ from unittest.mock import MagicMock +import pytest + from shelfmark.core.search_plan import ReleaseSearchPlan, ReleaseSearchVariant from shelfmark.metadata_providers import BookMetadata from shelfmark.release_sources import ReleaseProtocol @@ -13,6 +15,20 @@ from shelfmark.release_sources.newznab.source import ( # ── fixtures / helpers ───────────────────────────────────────────────────────── +class _AvailableSource: + display_name = "Newznab" + + def is_available(self): + return True + + +@pytest.fixture(autouse=True) +def source_available_by_default(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource()) + + def _make_book(**kwargs) -> BookMetadata: defaults = { "provider": "hardcover", diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index 732afe92..c5fd3718 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -5,6 +5,8 @@ Tests the utility functions for parsing release metadata. """ # Import the functions to test +import pytest + from shelfmark.metadata_providers import BookMetadata from shelfmark.release_sources.prowlarr.api import ProwlarrClient from shelfmark.release_sources.prowlarr.source import ( @@ -16,6 +18,20 @@ from shelfmark.release_sources.prowlarr.source import ( from shelfmark.release_sources.prowlarr.utils import get_protocol_display, sanitize_download_url +class _AvailableSource: + display_name = "Prowlarr" + + def is_available(self): + return True + + +@pytest.fixture(autouse=True) +def source_available_by_default(monkeypatch): + import shelfmark.download.orchestrator as orchestrator + + monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource()) + + class TestParseSize: """Tests for the _parse_size function."""