Enforce release source availability before queueing (#971)

This commit is contained in:
Alex
2026-05-10 08:52:17 +01:00
committed by GitHub
parent 2aee1d587e
commit e615797e69
6 changed files with 175 additions and 7 deletions
+23
View File
@@ -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",
+22 -6
View File
@@ -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"
+16
View File
@@ -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
@@ -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
+16
View File
@@ -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",
+16
View File
@@ -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."""