Files
shelfmark/tests/conftest.py
CaliBrain 6bab9989ab fix: fetch tracker .torrent links once per add and surface fetch failures (#1115)
## Summary

Fixes Prowlarr torrent downloads that fail with `Could not determine
torrent hash
from URL` when the result has no magnet link and no infohash (e.g.
MyAnonaMouse),
where fetching the .torrent from Prowlarr's proxy download link is the
only path.

Two problems compounded here:

1. **Every add attempt fetched the download link twice.**
`find_existing()`
prefetched the .torrent to compute a dedup hash, discarded the result,
and
`add_download()` fetched the same URL again seconds later. Private
tracker
links behind Prowlarr's proxy can be slow, rate-limited, or effectively
single-use, so the second hit could fail even when the link itself was
valid —
   which is why the reporter's manual fetch of the same URL succeeded.
2. **The real failure reason was invisible.** When the fetch failed
(e.g.
Prowlarr returning HTTP 500 because the tracker rejected the request —
see the
2026-07-07 MAM report on #476, which turned out to be a MAM IP-settings
problem), the reason was logged at DEBUG only and the user saw the
misleading
   generic hash error.

## What changed

- `extract_torrent_info()` now reuses a recent successful fetch of the
same URL
(short-TTL in-memory cache, successes only), so one add attempt hits the
tracker download link exactly once across `find_existing()` +
`add_download()`.
All four torrent clients (qBittorrent, Deluge, Transmission, rTorrent)
share
  this path and benefit. Failures are never cached, so retries refetch.
- `TorrentInfo` gains a `fetch_error` field. qBittorrent and rTorrent
append it
to the hash error (`... (torrent file fetch failed: 500 Server Error
...)`),
Deluge to its "Failed to fetch torrent file" error. The enriched message
still
contains the exact substring the #1109 expired-link refresh hook matches
on,
  so the refresh-and-retry path keeps working.
- Torrent fetch failures are logged at WARNING instead of DEBUG, so
non-debug
  logs show the cause.

## Validation

- `uv run pytest tests/prowlarr tests/download -q` — 498 passed
- `uv run pytest tests/newznab tests/audiobookbay -q` — 147 passed
- `uv run ruff check` / `ruff format --check` on all changed files
- New tests: fetch-cache reuse, failure-not-cached + reason capture,
expected-hash fallback on failed/hashless fetches, TTL expiry,
magnet-redirect
  reuse, and the enriched qBittorrent error message.

Fixes #1111
2026-07-14 16:21:31 -04:00

97 lines
2.9 KiB
Python

"""
Pytest configuration and shared fixtures.
"""
import os
import sys
import tempfile
# Set environment variables BEFORE importing the application
# These override the defaults that try to use system paths like /var/log
_temp_base = tempfile.mkdtemp(prefix="cwabd_test_")
# LOG_ROOT is the base - LOG_DIR is computed as LOG_ROOT / "shelfmark"
# So we set LOG_ROOT to our temp directory to get LOG_DIR = _temp_base/shelfmark
os.environ["LOG_ROOT"] = _temp_base
os.environ["CONFIG_DIR"] = os.path.join(_temp_base, "config")
os.environ["INGEST_DIR"] = os.path.join(_temp_base, "ingest")
os.environ["TMP_DIR"] = os.path.join(_temp_base, "tmp")
# Create the directories that will be used
os.makedirs(os.path.join(_temp_base, "shelfmark"), exist_ok=True) # LOG_DIR
os.makedirs(os.path.join(_temp_base, "config"), exist_ok=True)
os.makedirs(os.path.join(_temp_base, "ingest"), exist_ok=True)
os.makedirs(os.path.join(_temp_base, "tmp"), exist_ok=True)
# Add the project root to Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import pytest
@pytest.fixture(autouse=True)
def _clear_torrent_fetch_cache():
"""Keep the shared torrent fetch cache from leaking between tests."""
from shelfmark.download.clients.torrent_utils import clear_torrent_fetch_cache
clear_torrent_fetch_cache()
yield
clear_torrent_fetch_cache()
@pytest.fixture
def sample_prowlarr_result():
"""Sample Prowlarr API search result."""
return {
"guid": "abc123-guid",
"title": "The Great Book by Author Name.epub",
"indexer": "MyIndexer",
"protocol": "torrent",
"size": 5242880, # 5 MB
"downloadUrl": "magnet:?xt=urn:btih:abc123",
"infoUrl": "https://example.com/book/123",
"seeders": 10,
"leechers": 2,
"publishDate": "2024-01-15T12:00:00Z",
"categories": [{"id": 7020, "name": "Books/EBook"}],
"indexerId": 1,
}
@pytest.fixture
def sample_nzb_result():
"""Sample Prowlarr API NZB result."""
return {
"guid": "nzb456-guid",
"title": "Another Book [PDF] by Writer",
"indexer": "NZBIndexer",
"protocol": "usenet",
"size": 10485760, # 10 MB
"downloadUrl": "https://example.com/download.nzb",
"infoUrl": "https://example.com/nzb/456",
"grabs": 50,
"publishDate": "2024-02-20T10:30:00Z",
"categories": [{"id": 7020, "name": "Books/EBook"}],
"indexerId": 2,
}
@pytest.fixture
def mock_config(monkeypatch):
"""Fixture to mock config values."""
config_values = {}
def mock_get(key, default=""):
return config_values.get(key, default)
def set_config(key, value):
config_values[key] = value
# Create a mock config module
class MockConfig:
get = staticmethod(mock_get)
set = staticmethod(set_config)
_values = config_values
return MockConfig