Files
shelfmark/tests/prowlarr/test_integration_handler.py
T
Aidan AbbottandAidan Abbott f8d3f990ca fix: Prowlarr expired download link refresh (#1109)
## Summary

Fixes Prowlarr downloads that fail after a queued torrent result’s
tracker download link expires.

Prowlarr torrent results can expose a `downloadUrl` that is only a
short-lived proxy to the upstream tracker. Some trackers, including MAM,
include expiring credentials in that URL. Shelfmark was persisting that
URL as retry data and later treating it as durable. If the in-memory
Prowlarr cache was gone, or if qBittorrent tried to add a stale URL,
Shelfmark could fail with a misleading torrent-hash error instead of
refreshing the release.

## What changed

- Stop persisting Prowlarr `downloadUrl` values as durable retry data.
- Persist only source context needed to refresh the release later.
- On a Prowlarr cache miss, re-query Prowlarr using the queued task
context.
- Accept refreshed results only when the stable identity matches the
original release:
  - `guid == task.task_id`
  - or `infoUrl == task.task_id`
- Cache the fresh raw Prowlarr result and build the download request
from its current `downloadUrl` / `magnetUrl`.
- If qBittorrent add fails with `Could not determine torrent hash from
URL`, remove the stale cached Prowlarr result, refresh once, and retry
with the fresh URL.
- Preserve existing magnet handling: torrent results continue to use
`magnetUrl` first, so the refresh path only targets Prowlarr proxy
`downloadUrl` values that can expire.
- Improve the user-facing failure when refresh cannot find the same
release:
`The indexer download link expired and the release could not be
refreshed. Search again for a fresh result.`

## Why this approach

The important constraint is avoiding accidental downloads of a different
edition or format after the original tracker link expires. Re-running a
search by title can return many plausible results, so the refresh path
deliberately requires an exact stable identity match before using any
new URL.

This treats Prowlarr `downloadUrl` as a short-lived hint, while still
allowing retries to recover when Prowlarr can find the same release
again. The one-shot retry after qBittorrent add failure handles the case
where Shelfmark still has a cached Prowlarr result, but that cached
result contains an expired proxy URL.

The refresh hook is source-specific and defaults to no-op for other
external download handlers, so Newznab and other sources keep their
existing retry behavior.

## Bug

Fixes #1012

Context:
https://github.com/calibrain/shelfmark/issues/1012#issuecomment-4917148398

## Validation

- `uv run pytest tests/prowlarr -q`
- `uv run pytest tests/download/test_orchestrator_user_output_mode.py
-q`
- `uv run pytest tests/newznab/test_handler.py
tests/audiobookbay/test_handler.py -q`
- `uv run ruff check shelfmark/core/models.py
shelfmark/download/orchestrator.py
shelfmark/download/clients/base_handler.py
shelfmark/release_sources/prowlarr/handler.py
tests/prowlarr/test_handler.py
tests/prowlarr/test_integration_handler.py
tests/prowlarr/test_failure_scenarios.py tests/prowlarr/test_source.py
tests/download/test_orchestrator_user_output_mode.py`
- after resolving conflicts with latest `main`: `uv run pytest
tests/prowlarr -q`
- after resolving conflicts with latest `main`: `uv run ruff check
shelfmark/release_sources/prowlarr/handler.py`
- after resolving conflicts with latest `main`: `uv run pytest
tests/prowlarr/test_handler.py tests/prowlarr/test_source.py -q`

Co-authored-by: Aidan Abbott <aidanabbott@Aidans-MacBook-Pro.local>
2026-07-14 14:22:29 -04:00

302 lines
9.1 KiB
Python

"""
Integration tests for the Prowlarr download handler.
These tests verify the end-to-end download flow through the Prowlarr handler.
Run with: docker compose -f docker-compose.test-clients.yml exec shelfmark uv run pytest /app/tests/prowlarr/test_integration_handler.py -v -m integration
"""
import time
from threading import Event
import pytest
from shelfmark.core.config import config
from shelfmark.core.models import DownloadTask
from shelfmark.core.settings_registry import save_config_file
from shelfmark.release_sources.prowlarr.cache import cache_release, get_release, remove_release
from shelfmark.release_sources.prowlarr.handler import ProwlarrHandler
from shelfmark.release_sources.prowlarr.utils import get_protocol
# Test magnet link
TEST_MAGNET = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=ubuntu-22.04.3-live-server-amd64.iso"
def _setup_transmission_config():
"""Set up Transmission configuration via config files and refresh config."""
save_config_file(
"prowlarr_clients",
{
"PROWLARR_TORRENT_CLIENT": "transmission",
"TRANSMISSION_URL": "http://transmission:9091",
"TRANSMISSION_USERNAME": "admin",
"TRANSMISSION_PASSWORD": "admin",
"TRANSMISSION_CATEGORY": "test",
},
)
config.refresh()
def _is_transmission_available():
"""Check if Transmission is available."""
_setup_transmission_config()
try:
from shelfmark.download.clients.transmission import TransmissionClient
client = TransmissionClient()
success, _ = client.test_connection()
return success
except Exception:
return False
class ProgressRecorder:
"""Records progress and status updates during download."""
def __init__(self):
self.progress_values: list[float] = []
self.status_updates: list[tuple[str, str | None]] = []
def progress_callback(self, progress: float):
self.progress_values.append(progress)
def status_callback(self, status: str, message: str | None):
self.status_updates.append((status, message))
@property
def last_status(self) -> str | None:
return self.status_updates[-1][0] if self.status_updates else None
@property
def last_message(self) -> str | None:
return self.status_updates[-1][1] if self.status_updates else None
@property
def statuses(self) -> list[str]:
return [s[0] for s in self.status_updates]
class TestGetProtocol:
"""Tests for the get_protocol function."""
def test_get_protocol_torrent(self):
"""Test detecting torrent protocol."""
result = {"protocol": "torrent"}
assert get_protocol(result) == "torrent"
def test_get_protocol_usenet(self):
"""Test detecting usenet protocol."""
result = {"protocol": "usenet"}
assert get_protocol(result) == "usenet"
def test_get_protocol_unknown(self):
"""Test unknown protocol."""
result = {"protocol": "ftp"}
assert get_protocol(result) == "unknown"
def test_get_protocol_empty(self):
"""Test empty protocol."""
result = {}
assert get_protocol(result) == "unknown"
@pytest.mark.integration
class TestHandlerCacheOperations:
"""Tests for handler cache-related behavior."""
def test_download_fails_without_cached_release(self):
"""Test that download fails when release is not in cache."""
_setup_transmission_config()
handler = ProwlarrHandler()
task = DownloadTask(
task_id="non-existent-id-12345",
source="prowlarr",
title="Test Book",
)
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is None
assert recorder.last_status == "error"
assert "could not be refreshed" in recorder.last_message
def test_download_fails_without_download_url(self):
"""Test that download fails when release has no download URL."""
_setup_transmission_config()
handler = ProwlarrHandler()
task_id = "no-url-release-test"
cache_release(
task_id,
{
"protocol": "torrent",
"title": "Test Release",
# No downloadUrl or magnetUrl
},
)
try:
task = DownloadTask(
task_id=task_id,
source="prowlarr",
title="Test Book",
)
cancel_flag = Event()
recorder = ProgressRecorder()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
assert result is None
assert recorder.last_status == "error"
assert "url" in recorder.last_message.lower()
finally:
remove_release(task_id)
def test_cancel_removes_from_cache(self):
"""Test that cancel removes release from cache."""
handler = ProwlarrHandler()
task_id = "cancel-test-id-unique"
cache_release(task_id, {"title": "Test"})
assert get_release(task_id) is not None
result = handler.cancel(task_id)
assert result is True
assert get_release(task_id) is None
def test_cancel_handles_missing_task(self):
"""Test that cancel handles non-existent task gracefully."""
handler = ProwlarrHandler()
result = handler.cancel("definitely-non-existent-task-id")
assert result is True # Should still return True
@pytest.fixture(scope="module")
def transmission_available():
"""Check if Transmission is available, skip if not."""
if not _is_transmission_available():
pytest.skip(
"Transmission not available - ensure docker-compose.test-clients.yml is running"
)
return True
@pytest.mark.integration
class TestProwlarrHandlerWithTransmission:
"""Integration tests for ProwlarrHandler with Transmission."""
def test_download_starts_and_can_be_cancelled(self, transmission_available):
"""Test that download starts and can be cancelled."""
_setup_transmission_config()
handler = ProwlarrHandler()
# Cache a valid release
task_id = f"test-cancel-release-{time.time()}"
cache_release(
task_id,
{
"protocol": "torrent",
"title": "Ubuntu Test ISO",
"magnetUrl": TEST_MAGNET,
},
)
task = DownloadTask(
task_id=task_id,
source="prowlarr",
title="Ubuntu Test ISO",
)
cancel_flag = Event()
recorder = ProgressRecorder()
# Start download in a thread and cancel after a short delay
import threading
def cancel_after_delay():
time.sleep(4) # Let it start
cancel_flag.set()
cancel_thread = threading.Thread(target=cancel_after_delay)
cancel_thread.start()
result = handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
cancel_thread.join()
# Download was cancelled
assert result is None
# Should have some status updates
assert len(recorder.status_updates) > 0
# Should see resolving or downloading status (not just error)
assert (
"resolving" in recorder.statuses
or "downloading" in recorder.statuses
or "cancelled" in recorder.statuses
)
def test_handler_sends_to_transmission(self, transmission_available):
"""Test that handler properly sends downloads to Transmission."""
_setup_transmission_config()
handler = ProwlarrHandler()
task_id = f"transmission-test-{time.time()}"
cache_release(
task_id,
{
"protocol": "torrent",
"title": "Integration Test Torrent",
"magnetUrl": TEST_MAGNET,
},
)
task = DownloadTask(
task_id=task_id,
source="prowlarr",
title="Integration Test Torrent",
)
cancel_flag = Event()
recorder = ProgressRecorder()
import threading
def cancel_soon():
time.sleep(5)
cancel_flag.set()
t = threading.Thread(target=cancel_soon)
t.start()
handler.download(
task=task,
cancel_flag=cancel_flag,
progress_callback=recorder.progress_callback,
status_callback=recorder.status_callback,
)
t.join()
# Should have seen resolving status (means it tried to send to client)
assert "resolving" in recorder.statuses or "downloading" in recorder.statuses