mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 06:51:12 +01:00
## 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
801 lines
31 KiB
Python
801 lines
31 KiB
Python
"""
|
|
Tests for torrent utility functions.
|
|
|
|
Tests:
|
|
- parse_transmission_url
|
|
- bencode_encode/decode
|
|
- extract_info_hash_from_torrent
|
|
- extract_hash_from_magnet
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from shelfmark.download.clients.torrent_utils import (
|
|
bencode_decode,
|
|
bencode_encode,
|
|
extract_hash_from_magnet,
|
|
extract_info_hash_from_torrent,
|
|
extract_torrent_info,
|
|
parse_transmission_url,
|
|
)
|
|
|
|
|
|
class TestParseTransmissionUrl:
|
|
"""Tests for parse_transmission_url function."""
|
|
|
|
def test_parse_simple_url(self):
|
|
"""Test parsing a simple URL with host and port."""
|
|
protocol, host, port, path = parse_transmission_url("http://localhost:9091")
|
|
assert protocol == "http"
|
|
assert host == "localhost"
|
|
assert port == 9091
|
|
assert path == "/transmission/rpc"
|
|
|
|
def test_parse_url_with_custom_port(self):
|
|
"""Test parsing URL with custom port."""
|
|
protocol, host, port, path = parse_transmission_url("http://myserver:8080")
|
|
assert protocol == "http"
|
|
assert host == "myserver"
|
|
assert port == 8080
|
|
assert path == "/transmission/rpc"
|
|
|
|
def test_parse_url_with_path(self):
|
|
"""Test parsing URL with existing path."""
|
|
protocol, host, port, path = parse_transmission_url(
|
|
"http://localhost:9091/transmission/rpc"
|
|
)
|
|
assert protocol == "http"
|
|
assert host == "localhost"
|
|
assert port == 9091
|
|
assert path == "/transmission/rpc"
|
|
|
|
def test_parse_url_with_partial_path(self):
|
|
"""Test parsing URL with partial path appends /rpc."""
|
|
protocol, host, port, path = parse_transmission_url("http://localhost:9091/custom")
|
|
assert protocol == "http"
|
|
assert host == "localhost"
|
|
assert port == 9091
|
|
assert path == "/custom/transmission/rpc"
|
|
|
|
def test_parse_url_with_trailing_slash(self):
|
|
"""Test parsing URL with trailing slash."""
|
|
protocol, host, port, path = parse_transmission_url("http://localhost:9091/")
|
|
assert protocol == "http"
|
|
assert host == "localhost"
|
|
assert port == 9091
|
|
assert path == "/transmission/rpc"
|
|
|
|
def test_parse_url_without_port(self):
|
|
"""Test parsing URL without port uses default 9091."""
|
|
protocol, host, port, path = parse_transmission_url("http://transmission")
|
|
assert protocol == "http"
|
|
assert host == "transmission"
|
|
assert port == 9091
|
|
assert path == "/transmission/rpc"
|
|
|
|
def test_parse_https_url(self):
|
|
"""Test parsing HTTPS URL."""
|
|
protocol, host, port, path = parse_transmission_url(
|
|
"https://secure.transmission.local:9091"
|
|
)
|
|
assert protocol == "https"
|
|
assert host == "secure.transmission.local"
|
|
assert port == 9091
|
|
assert path == "/transmission/rpc"
|
|
|
|
def test_parse_url_with_ip_address(self):
|
|
"""Test parsing URL with IP address."""
|
|
protocol, host, port, path = parse_transmission_url("http://192.168.1.100:9091")
|
|
assert protocol == "http"
|
|
assert host == "192.168.1.100"
|
|
assert port == 9091
|
|
assert path == "/transmission/rpc"
|
|
|
|
def test_parse_empty_url_uses_defaults(self):
|
|
"""Test parsing empty URL uses localhost defaults."""
|
|
protocol, host, port, path = parse_transmission_url("")
|
|
assert protocol == "http"
|
|
assert host == "localhost"
|
|
assert port == 9091
|
|
assert path == "/transmission/rpc"
|
|
|
|
|
|
class TestBencodeDecode:
|
|
"""Tests for bencode decoding."""
|
|
|
|
def test_decode_integer(self):
|
|
"""Test decoding integers."""
|
|
result, remaining = bencode_decode(b"i42e")
|
|
assert result == 42
|
|
assert remaining == b""
|
|
|
|
def test_decode_negative_integer(self):
|
|
"""Test decoding negative integers."""
|
|
result, _remaining = bencode_decode(b"i-42e")
|
|
assert result == -42
|
|
|
|
def test_decode_zero(self):
|
|
"""Test decoding zero."""
|
|
result, _remaining = bencode_decode(b"i0e")
|
|
assert result == 0
|
|
|
|
def test_decode_large_integer(self):
|
|
"""Test decoding large integers."""
|
|
result, _remaining = bencode_decode(b"i999999999999e")
|
|
assert result == 999999999999
|
|
|
|
def test_decode_string(self):
|
|
"""Test decoding byte strings."""
|
|
result, remaining = bencode_decode(b"5:hello")
|
|
assert result == b"hello"
|
|
assert remaining == b""
|
|
|
|
def test_decode_empty_string(self):
|
|
"""Test decoding empty string."""
|
|
result, _remaining = bencode_decode(b"0:")
|
|
assert result == b""
|
|
|
|
def test_decode_unicode_string(self):
|
|
"""Test decoding unicode bytes."""
|
|
data = "tëst".encode()
|
|
encoded = f"{len(data)}:".encode() + data
|
|
result, _remaining = bencode_decode(encoded)
|
|
assert result == data
|
|
|
|
def test_decode_list(self):
|
|
"""Test decoding lists."""
|
|
result, remaining = bencode_decode(b"li1ei2ei3ee")
|
|
assert result == [1, 2, 3]
|
|
assert remaining == b""
|
|
|
|
def test_decode_empty_list(self):
|
|
"""Test decoding empty list."""
|
|
result, _remaining = bencode_decode(b"le")
|
|
assert result == []
|
|
|
|
def test_decode_nested_list(self):
|
|
"""Test decoding nested lists."""
|
|
result, _remaining = bencode_decode(b"lli1eeli2eee")
|
|
assert result == [[1], [2]]
|
|
|
|
def test_decode_mixed_list(self):
|
|
"""Test decoding list with mixed types."""
|
|
result, _remaining = bencode_decode(b"l5:helloi42ee")
|
|
assert result == [b"hello", 42]
|
|
|
|
def test_decode_dict(self):
|
|
"""Test decoding dictionaries."""
|
|
result, remaining = bencode_decode(b"d3:key5:valuee")
|
|
assert result == {b"key": b"value"}
|
|
assert remaining == b""
|
|
|
|
def test_decode_empty_dict(self):
|
|
"""Test decoding empty dictionary."""
|
|
result, _remaining = bencode_decode(b"de")
|
|
assert result == {}
|
|
|
|
def test_decode_complex_structure(self):
|
|
"""Test decoding complex nested structures."""
|
|
# Dict with string, int, and list values
|
|
data = b"d3:agei25e4:name4:John5:itemsli1ei2ei3eee"
|
|
result, _remaining = bencode_decode(data)
|
|
assert result == {
|
|
b"age": 25,
|
|
b"name": b"John",
|
|
b"items": [1, 2, 3],
|
|
}
|
|
|
|
def test_decode_invalid_data_raises(self):
|
|
"""Test that invalid data raises ValueError."""
|
|
with pytest.raises(ValueError):
|
|
bencode_decode(b"x")
|
|
|
|
|
|
class TestBencodeEncode:
|
|
"""Tests for bencode encoding."""
|
|
|
|
def test_encode_integer(self):
|
|
"""Test encoding integers."""
|
|
assert bencode_encode(42) == b"i42e"
|
|
assert bencode_encode(-42) == b"i-42e"
|
|
assert bencode_encode(0) == b"i0e"
|
|
|
|
def test_encode_bytes(self):
|
|
"""Test encoding byte strings."""
|
|
assert bencode_encode(b"hello") == b"5:hello"
|
|
assert bencode_encode(b"") == b"0:"
|
|
|
|
def test_encode_string(self):
|
|
"""Test encoding regular strings (UTF-8 encoded)."""
|
|
assert bencode_encode("hello") == b"5:hello"
|
|
assert bencode_encode("") == b"0:"
|
|
|
|
def test_encode_list(self):
|
|
"""Test encoding lists."""
|
|
assert bencode_encode([1, 2, 3]) == b"li1ei2ei3ee"
|
|
assert bencode_encode([]) == b"le"
|
|
|
|
def test_encode_dict(self):
|
|
"""Test encoding dictionaries."""
|
|
result = bencode_encode({b"key": b"value"})
|
|
assert result == b"d3:key5:valuee"
|
|
|
|
def test_encode_dict_keys_sorted(self):
|
|
"""Test that dictionary keys are sorted."""
|
|
# Keys should be sorted: a < m < z
|
|
result = bencode_encode({b"z": 1, b"a": 2, b"m": 3})
|
|
assert result == b"d1:ai2e1:mi3e1:zi1ee"
|
|
|
|
def test_encode_nested_structure(self):
|
|
"""Test encoding nested structures."""
|
|
data = {b"list": [1, 2, 3], b"num": 42}
|
|
result = bencode_encode(data)
|
|
assert result == b"d4:listli1ei2ei3ee3:numi42ee"
|
|
|
|
def test_encode_invalid_type_raises(self):
|
|
"""Test that invalid types raise ValueError."""
|
|
with pytest.raises(ValueError):
|
|
bencode_encode(3.14) # floats not supported
|
|
|
|
|
|
class TestBencodeRoundTrip:
|
|
"""Tests for encoding then decoding (roundtrip)."""
|
|
|
|
def test_roundtrip_integer(self):
|
|
"""Test roundtrip for integers."""
|
|
original = 12345
|
|
encoded = bencode_encode(original)
|
|
decoded, _ = bencode_decode(encoded)
|
|
assert decoded == original
|
|
|
|
def test_roundtrip_bytes(self):
|
|
"""Test roundtrip for byte strings."""
|
|
original = b"hello world"
|
|
encoded = bencode_encode(original)
|
|
decoded, _ = bencode_decode(encoded)
|
|
assert decoded == original
|
|
|
|
def test_roundtrip_list(self):
|
|
"""Test roundtrip for lists."""
|
|
original = [1, 2, b"three", [4, 5]]
|
|
encoded = bencode_encode(original)
|
|
decoded, _ = bencode_decode(encoded)
|
|
assert decoded == original
|
|
|
|
def test_roundtrip_dict(self):
|
|
"""Test roundtrip for dictionaries."""
|
|
original = {b"name": b"test", b"value": 123}
|
|
encoded = bencode_encode(original)
|
|
decoded, _ = bencode_decode(encoded)
|
|
assert decoded == original
|
|
|
|
def test_roundtrip_complex_torrent_like_structure(self):
|
|
"""Test roundtrip for a structure similar to a torrent file."""
|
|
original = {
|
|
b"announce": b"http://tracker.example.com/announce",
|
|
b"info": {
|
|
b"name": b"TestFile.txt",
|
|
b"length": 1024,
|
|
b"piece length": 16384,
|
|
b"pieces": b"\x00" * 20, # SHA1 hashes
|
|
},
|
|
}
|
|
encoded = bencode_encode(original)
|
|
decoded, _ = bencode_decode(encoded)
|
|
assert decoded == original
|
|
|
|
|
|
class TestExtractInfoHash:
|
|
"""Tests for extracting info hash from torrent files."""
|
|
|
|
def test_extract_hash_from_simple_torrent(self):
|
|
"""Test extracting hash from a simple torrent structure."""
|
|
info_dict = {
|
|
b"name": b"test.txt",
|
|
b"length": 100,
|
|
b"piece length": 16384,
|
|
b"pieces": b"\x00" * 20,
|
|
}
|
|
torrent = {b"info": info_dict}
|
|
torrent_bytes = bencode_encode(torrent)
|
|
|
|
result = extract_info_hash_from_torrent(torrent_bytes)
|
|
|
|
# Should return a 40-character hex string
|
|
assert result is not None
|
|
assert len(result) == 40
|
|
assert all(c in "0123456789abcdef" for c in result)
|
|
|
|
def test_extract_hash_returns_none_for_invalid(self):
|
|
"""Test that invalid data returns None."""
|
|
assert extract_info_hash_from_torrent(b"not a torrent") is None
|
|
assert extract_info_hash_from_torrent(b"") is None
|
|
|
|
def test_extract_hash_returns_none_without_info(self):
|
|
"""Test that torrent without info dict returns None."""
|
|
torrent = {b"announce": b"http://tracker.example.com"}
|
|
torrent_bytes = bencode_encode(torrent)
|
|
|
|
result = extract_info_hash_from_torrent(torrent_bytes)
|
|
assert result is None
|
|
|
|
def test_extract_hash_is_consistent(self):
|
|
"""Test that same torrent always produces same hash."""
|
|
info_dict = {b"name": b"consistent.txt", b"length": 500}
|
|
torrent = {b"info": info_dict}
|
|
torrent_bytes = bencode_encode(torrent)
|
|
|
|
hash1 = extract_info_hash_from_torrent(torrent_bytes)
|
|
hash2 = extract_info_hash_from_torrent(torrent_bytes)
|
|
|
|
assert hash1 == hash2
|
|
|
|
def test_extract_hash_different_for_different_torrents(self):
|
|
"""Test that different torrents produce different hashes."""
|
|
torrent1 = {b"info": {b"name": b"file1.txt", b"length": 100}}
|
|
torrent2 = {b"info": {b"name": b"file2.txt", b"length": 100}}
|
|
|
|
hash1 = extract_info_hash_from_torrent(bencode_encode(torrent1))
|
|
hash2 = extract_info_hash_from_torrent(bencode_encode(torrent2))
|
|
|
|
assert hash1 != hash2
|
|
|
|
def test_extract_hash_v2_without_pieces(self):
|
|
"""Use SHA-256 when torrent lacks v1 pieces."""
|
|
info_dict = {
|
|
b"meta version": 2,
|
|
b"file tree": {b"test.txt": {b"": {b"length": 123}}},
|
|
b"piece length": 16384,
|
|
}
|
|
torrent = {b"info": info_dict}
|
|
torrent_bytes = bencode_encode(torrent)
|
|
|
|
expected = hashlib.sha256(bencode_encode(info_dict)).hexdigest().lower()
|
|
assert extract_info_hash_from_torrent(torrent_bytes) == expected
|
|
|
|
|
|
class TestExtractTorrentInfo:
|
|
"""Tests for extracting torrent info from user-supplied URLs."""
|
|
|
|
def test_fetches_untrusted_torrent_url_to_recover_missing_hash(self, monkeypatch):
|
|
"""Regression for #1012.
|
|
|
|
A download URL on a non-Prowlarr origin (e.g. a direct tracker link, or
|
|
Prowlarr reached through a separate proxy) must still be prefetched so
|
|
the info_hash can be recovered when the feed did not provide one.
|
|
Otherwise qBittorrent fails with "Could not determine torrent hash from
|
|
URL".
|
|
"""
|
|
info_dict = {
|
|
b"name": b"book.txt",
|
|
b"length": 100,
|
|
b"piece length": 16384,
|
|
b"pieces": b"\x00" * 20,
|
|
}
|
|
torrent_data = bencode_encode({b"info": info_dict})
|
|
expected_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower()
|
|
|
|
config_values = {"PROWLARR_URL": "https://prowlarr.example"}
|
|
monkeypatch.setattr(
|
|
"shelfmark.download.clients.torrent_utils.config.get",
|
|
lambda key, default="": config_values.get(key, default),
|
|
)
|
|
response = MagicMock(status_code=200, content=torrent_data)
|
|
response.raise_for_status = MagicMock()
|
|
mock_get = MagicMock(return_value=response)
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
# No expected_hash supplied: the hash can only come from the prefetch.
|
|
result = extract_torrent_info(
|
|
"https://tracker.example/download/book.torrent",
|
|
fetch_torrent=True,
|
|
)
|
|
|
|
assert result.info_hash == expected_hash
|
|
assert result.torrent_data == torrent_data
|
|
assert result.is_magnet is False
|
|
mock_get.assert_called_once()
|
|
|
|
def test_does_not_send_api_key_to_untrusted_torrent_url(self, monkeypatch):
|
|
"""The Prowlarr API key is never sent to a download URL on an untrusted origin."""
|
|
info_dict = {
|
|
b"name": b"book.txt",
|
|
b"length": 100,
|
|
b"piece length": 16384,
|
|
b"pieces": b"\x00" * 20,
|
|
}
|
|
torrent_data = bencode_encode({b"info": info_dict})
|
|
|
|
config_values = {
|
|
"PROWLARR_URL": "https://prowlarr.example",
|
|
"PROWLARR_API_KEY": "secret",
|
|
}
|
|
monkeypatch.setattr(
|
|
"shelfmark.download.clients.torrent_utils.config.get",
|
|
lambda key, default="": config_values.get(key, default),
|
|
)
|
|
response = MagicMock(status_code=200, content=torrent_data)
|
|
response.raise_for_status = MagicMock()
|
|
mock_get = MagicMock(return_value=response)
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
extract_torrent_info(
|
|
"https://attacker.example/book.torrent",
|
|
fetch_torrent=True,
|
|
)
|
|
|
|
mock_get.assert_called_once()
|
|
sent_headers = mock_get.call_args.kwargs.get("headers", {})
|
|
assert "X-Api-Key" not in sent_headers
|
|
|
|
def test_fetches_configured_prowlarr_torrent_url(self, monkeypatch):
|
|
"""Configured Prowlarr download URLs can still be prefetched and parsed."""
|
|
info_dict = {
|
|
b"name": b"trusted.txt",
|
|
b"length": 100,
|
|
b"piece length": 16384,
|
|
b"pieces": b"\x00" * 20,
|
|
}
|
|
torrent_data = bencode_encode({b"info": info_dict})
|
|
expected_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower()
|
|
|
|
config_values = {
|
|
"PROWLARR_URL": "https://prowlarr.example",
|
|
"PROWLARR_API_KEY": "secret",
|
|
}
|
|
monkeypatch.setattr(
|
|
"shelfmark.download.clients.torrent_utils.config.get",
|
|
lambda key, default="": config_values.get(key, default),
|
|
)
|
|
response = MagicMock(status_code=200, content=torrent_data)
|
|
response.raise_for_status = MagicMock()
|
|
mock_get = MagicMock(return_value=response)
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
result = extract_torrent_info(
|
|
"https://prowlarr.example/1/download?apikey=secret&indexer=7",
|
|
fetch_torrent=True,
|
|
)
|
|
|
|
assert result.info_hash == expected_hash
|
|
assert result.torrent_data == torrent_data
|
|
assert result.is_magnet is False
|
|
mock_get.assert_called_once()
|
|
|
|
def test_normalizes_configured_origin_before_trusting_torrent_url(self, monkeypatch):
|
|
"""Configured Prowlarr URLs match the same normalization used by the source."""
|
|
info_dict = {
|
|
b"name": b"trusted.txt",
|
|
b"length": 100,
|
|
b"piece length": 16384,
|
|
b"pieces": b"\x00" * 20,
|
|
}
|
|
torrent_data = bencode_encode({b"info": info_dict})
|
|
expected_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower()
|
|
|
|
config_values = {
|
|
"PROWLARR_URL": "prowlarr.example:9696/",
|
|
"PROWLARR_API_KEY": "secret",
|
|
}
|
|
monkeypatch.setattr(
|
|
"shelfmark.download.clients.torrent_utils.config.get",
|
|
lambda key, default="": config_values.get(key, default),
|
|
)
|
|
response = MagicMock(status_code=200, content=torrent_data)
|
|
response.raise_for_status = MagicMock()
|
|
mock_get = MagicMock(return_value=response)
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
result = extract_torrent_info(
|
|
"http://prowlarr.example:9696/1/download?apikey=secret&indexer=7",
|
|
fetch_torrent=True,
|
|
)
|
|
|
|
assert result.info_hash == expected_hash
|
|
assert result.torrent_data == torrent_data
|
|
mock_get.assert_called_once()
|
|
|
|
def test_follows_trusted_redirect_to_untrusted_host_without_api_key(self, monkeypatch):
|
|
"""Regression for #1012 on v1.3.2.
|
|
|
|
Prowlarr's download endpoint commonly redirects to the indexer's own
|
|
download link on another origin. The prefetch must follow that redirect
|
|
(it is often the only way to learn the info_hash) but must not forward
|
|
the Prowlarr API key to the untrusted origin.
|
|
"""
|
|
info_dict = {
|
|
b"name": b"book.txt",
|
|
b"length": 100,
|
|
b"piece length": 16384,
|
|
b"pieces": b"\x00" * 20,
|
|
}
|
|
torrent_data = bencode_encode({b"info": info_dict})
|
|
expected_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower()
|
|
|
|
config_values = {
|
|
"PROWLARR_URL": "https://prowlarr.example",
|
|
"PROWLARR_API_KEY": "secret",
|
|
}
|
|
monkeypatch.setattr(
|
|
"shelfmark.download.clients.torrent_utils.config.get",
|
|
lambda key, default="": config_values.get(key, default),
|
|
)
|
|
redirect = MagicMock(status_code=302)
|
|
redirect.headers = {"Location": "https://tracker.example/download/book.torrent"}
|
|
final = MagicMock(status_code=200, content=torrent_data)
|
|
final.raise_for_status = MagicMock()
|
|
mock_get = MagicMock(side_effect=[redirect, final])
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
# No expected_hash supplied: the hash can only come from the prefetch.
|
|
result = extract_torrent_info(
|
|
"https://prowlarr.example/1/download?apikey=secret&indexer=7",
|
|
fetch_torrent=True,
|
|
)
|
|
|
|
assert result.info_hash == expected_hash
|
|
assert result.torrent_data == torrent_data
|
|
assert result.is_magnet is False
|
|
assert mock_get.call_count == 2
|
|
trusted_headers = mock_get.call_args_list[0].kwargs["headers"]
|
|
untrusted_headers = mock_get.call_args_list[1].kwargs["headers"]
|
|
assert trusted_headers.get("X-Api-Key") == "secret"
|
|
assert "X-Api-Key" not in untrusted_headers
|
|
|
|
def test_redirect_to_magnet_link_returns_magnet_info(self, monkeypatch):
|
|
"""A download URL redirecting to a magnet link yields the magnet's hash."""
|
|
magnet = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=test"
|
|
monkeypatch.setattr(
|
|
"shelfmark.download.clients.torrent_utils.config.get",
|
|
lambda key, default="": "https://prowlarr.example" if key == "PROWLARR_URL" else "",
|
|
)
|
|
response = MagicMock(status_code=302)
|
|
response.headers = {"Location": magnet}
|
|
mock_get = MagicMock(return_value=response)
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
result = extract_torrent_info(
|
|
"https://prowlarr.example/1/download?apikey=secret&indexer=7",
|
|
fetch_torrent=True,
|
|
)
|
|
|
|
assert result.is_magnet is True
|
|
assert result.magnet_url == magnet
|
|
assert result.info_hash == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
|
mock_get.assert_called_once()
|
|
|
|
def test_gives_up_after_too_many_redirects(self, monkeypatch):
|
|
"""A redirect loop falls back to the expected hash instead of spinning."""
|
|
expected_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
|
monkeypatch.setattr(
|
|
"shelfmark.download.clients.torrent_utils.config.get",
|
|
lambda key, default="": "https://prowlarr.example" if key == "PROWLARR_URL" else "",
|
|
)
|
|
response = MagicMock(status_code=302)
|
|
response.headers = {"Location": "https://tracker.example/loop"}
|
|
mock_get = MagicMock(return_value=response)
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
result = extract_torrent_info(
|
|
"https://prowlarr.example/1/download?apikey=secret&indexer=7",
|
|
fetch_torrent=True,
|
|
expected_hash=expected_hash,
|
|
)
|
|
|
|
assert result.info_hash == expected_hash
|
|
assert result.torrent_data is None
|
|
assert result.is_magnet is False
|
|
# Initial request plus the maximum of five followed redirects.
|
|
assert mock_get.call_count == 6
|
|
|
|
|
|
class TestExtractHashFromMagnet:
|
|
"""Tests for extracting hash from magnet links."""
|
|
|
|
def test_extract_hash_from_hex_magnet(self):
|
|
"""Test extracting 40-char hex hash from magnet link."""
|
|
magnet = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=test"
|
|
result = extract_hash_from_magnet(magnet)
|
|
assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
|
|
|
def test_extract_hash_from_base32_magnet(self):
|
|
"""Test extracting 32-char base32 hash from magnet link."""
|
|
# Base32 encoded hash: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
|
magnet = "magnet:?xt=urn:btih:ABCDEFGHIJKLMNOPQRSTUVWXYZ234567&dn=test"
|
|
result = extract_hash_from_magnet(magnet)
|
|
# Should be converted to hex (lowercase)
|
|
assert result is not None
|
|
assert len(result) == 40
|
|
assert all(c in "0123456789abcdef" for c in result)
|
|
|
|
def test_extract_hash_uppercase_hex(self):
|
|
"""Test that uppercase hex is converted to lowercase."""
|
|
magnet = "magnet:?xt=urn:btih:3B245504CF5F11BBDBE1201CEA6A6BF45AEE1BC0&dn=test"
|
|
result = extract_hash_from_magnet(magnet)
|
|
assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
|
|
|
def test_extract_hash_no_btih(self):
|
|
"""Test that magnets without btih return None."""
|
|
magnet = "magnet:?dn=test"
|
|
result = extract_hash_from_magnet(magnet)
|
|
assert result is None
|
|
|
|
def test_extract_hash_invalid_format(self):
|
|
"""Test that invalid hash format returns None."""
|
|
magnet = "magnet:?xt=urn:btih:invalid&dn=test"
|
|
result = extract_hash_from_magnet(magnet)
|
|
assert result is None
|
|
|
|
def test_extract_hash_not_magnet(self):
|
|
"""Test that non-magnet URLs return None."""
|
|
result = extract_hash_from_magnet("https://example.com/file.torrent")
|
|
assert result is None
|
|
|
|
def test_extract_hash_empty_string(self):
|
|
"""Test that empty string returns None."""
|
|
result = extract_hash_from_magnet("")
|
|
assert result is None
|
|
|
|
def test_extract_hash_complex_magnet(self):
|
|
"""Test extracting from complex magnet with many parameters."""
|
|
magnet = (
|
|
"magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
|
"&dn=Ubuntu+22.04"
|
|
"&tr=udp://tracker.example.com:80"
|
|
"&tr=udp://tracker2.example.com:6969"
|
|
"&xl=12345"
|
|
)
|
|
result = extract_hash_from_magnet(magnet)
|
|
assert result == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
|
|
|
def test_extract_hash_from_btmh_hex(self):
|
|
"""Test extracting v2 hash from btmh (hex multihash)."""
|
|
digest = bytes(range(1, 33))
|
|
multihash = b"\x12\x20" + digest
|
|
magnet = f"magnet:?xt=urn:btmh:{multihash.hex()}&dn=test"
|
|
result = extract_hash_from_magnet(magnet)
|
|
assert result == digest.hex()
|
|
|
|
def test_extract_hash_from_btmh_base32(self):
|
|
"""Test extracting v2 hash from btmh (base32 multihash)."""
|
|
digest = bytes(range(1, 33))
|
|
multihash = b"\x12\x20" + digest
|
|
b32 = base64.b32encode(multihash).decode("ascii").rstrip("=")
|
|
magnet = f"magnet:?xt=urn:btmh:{b32}&dn=test"
|
|
result = extract_hash_from_magnet(magnet)
|
|
assert result == digest.hex()
|
|
|
|
|
|
class TestTorrentFetchCache:
|
|
"""Tests for reusing a fetched .torrent across find_existing/add_download (#1111)."""
|
|
|
|
@staticmethod
|
|
def _valid_torrent():
|
|
info_dict = {
|
|
b"name": b"book.txt",
|
|
b"length": 100,
|
|
b"piece length": 16384,
|
|
b"pieces": b"\x00" * 20,
|
|
}
|
|
torrent_data = bencode_encode({b"info": info_dict})
|
|
info_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower()
|
|
return torrent_data, info_hash
|
|
|
|
def test_repeated_url_reuses_fetched_torrent_data(self, monkeypatch):
|
|
"""The same download URL is fetched once per add attempt, not once per caller.
|
|
|
|
find_existing() and add_download() both resolve the request URL; private
|
|
tracker links behind Prowlarr's proxy can be rate-limited or single-use,
|
|
so the second fetch must be served from the cache.
|
|
"""
|
|
torrent_data, info_hash = self._valid_torrent()
|
|
response = MagicMock(status_code=200, content=torrent_data)
|
|
response.raise_for_status = MagicMock()
|
|
mock_get = MagicMock(return_value=response)
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
url = "https://prowlarr.example/26/download?apikey=secret&link=token"
|
|
first = extract_torrent_info(url, fetch_torrent=True)
|
|
second = extract_torrent_info(url, fetch_torrent=True)
|
|
|
|
mock_get.assert_called_once()
|
|
assert first.info_hash == info_hash
|
|
assert second.info_hash == info_hash
|
|
assert second.torrent_data == torrent_data
|
|
|
|
def test_failed_fetch_is_not_cached_and_records_reason(self, monkeypatch):
|
|
"""Fetch failures are retried on the next call and expose the reason."""
|
|
import requests as requests_module
|
|
|
|
mock_get = MagicMock(
|
|
side_effect=requests_module.exceptions.HTTPError(
|
|
"500 Server Error: Internal Server Error for url: https://prowlarr.example/26/download"
|
|
)
|
|
)
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
url = "https://prowlarr.example/26/download?apikey=secret&link=token"
|
|
first = extract_torrent_info(url, fetch_torrent=True)
|
|
second = extract_torrent_info(url, fetch_torrent=True)
|
|
|
|
assert mock_get.call_count == 2
|
|
assert first.fetch_error is not None
|
|
assert "500 Server Error" in first.fetch_error
|
|
assert first.info_hash is None
|
|
assert second.fetch_error is not None
|
|
|
|
def test_fetch_failure_still_falls_back_to_expected_hash(self, monkeypatch):
|
|
"""A known infohash keeps working when the prefetch fails."""
|
|
import requests as requests_module
|
|
|
|
mock_get = MagicMock(side_effect=requests_module.exceptions.ConnectionError("boom"))
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
known_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
|
result = extract_torrent_info(
|
|
"https://tracker.example/download/book.torrent",
|
|
fetch_torrent=True,
|
|
expected_hash=known_hash,
|
|
)
|
|
|
|
assert result.info_hash == known_hash
|
|
assert result.fetch_error is not None
|
|
assert "boom" in result.fetch_error
|
|
|
|
def test_expected_hash_applies_to_cached_hashless_result(self, monkeypatch):
|
|
"""A cached fetch without a hash still honors a caller's expected_hash."""
|
|
response = MagicMock(status_code=200, content=b"<html>not a torrent</html>")
|
|
response.raise_for_status = MagicMock()
|
|
mock_get = MagicMock(return_value=response)
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
url = "https://tracker.example/download/book.torrent"
|
|
known_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
|
first = extract_torrent_info(url, fetch_torrent=True)
|
|
second = extract_torrent_info(url, fetch_torrent=True, expected_hash=known_hash)
|
|
|
|
mock_get.assert_called_once()
|
|
assert first.info_hash is None
|
|
assert second.info_hash == known_hash
|
|
|
|
def test_cache_expires_after_ttl(self, monkeypatch):
|
|
"""Stale cache entries are refetched instead of reused."""
|
|
torrent_data, _ = self._valid_torrent()
|
|
response = MagicMock(status_code=200, content=torrent_data)
|
|
response.raise_for_status = MagicMock()
|
|
mock_get = MagicMock(return_value=response)
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
clock = {"now": 1000.0}
|
|
monkeypatch.setattr(
|
|
"shelfmark.download.clients.torrent_utils.time.monotonic",
|
|
lambda: clock["now"],
|
|
)
|
|
|
|
url = "https://tracker.example/download/book.torrent"
|
|
extract_torrent_info(url, fetch_torrent=True)
|
|
clock["now"] += 121.0
|
|
extract_torrent_info(url, fetch_torrent=True)
|
|
|
|
assert mock_get.call_count == 2
|
|
|
|
def test_magnet_redirect_result_is_reused(self, monkeypatch):
|
|
"""A URL that redirects to a magnet link is also only resolved once."""
|
|
magnet = "magnet:?xt=urn:btih:3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0&dn=test"
|
|
response = MagicMock(status_code=302, headers={"Location": magnet})
|
|
mock_get = MagicMock(return_value=response)
|
|
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
|
|
|
url = "https://tracker.example/download/book.torrent"
|
|
first = extract_torrent_info(url, fetch_torrent=True)
|
|
second = extract_torrent_info(url, fetch_torrent=True)
|
|
|
|
mock_get.assert_called_once()
|
|
assert first.is_magnet is True
|
|
assert second.magnet_url == magnet
|
|
assert second.info_hash == "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|