mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 15:41:12 +01:00
Enforce release source availability before queueing (#971)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user