Fix torrent post-import category follow-ups and clear the lint backlog (#1154)

rTorrent set_category and remove now uppercase the info hash, which
reaches us lowercase while rTorrent's XML-RPC lookups are case
sensitive. remove() had this bug before #1148, making
PROWLARR_TORRENT_ACTION=remove a silent no-op for rTorrent.

Transmission set_category appends the post-import label instead of
replacing the whole label list. The unsupported-client path in
post_process_cleanup logs at debug instead of warning, so Real-Debrid
and AllDebrid users stop seeing a warning on every successful import.

The Real-Debrid and AllDebrid clients now follow the conventions used by
the other clients (_raise_runtime_error helpers, narrow error tuples,
ClassVar, Path.open), and register_client is generic over a TypeVar
bound to DownloadClient so decorated classes keep their concrete type.
make fix and the lint, format and typecheck targets all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
CaliBrain
2026-07-31 00:17:28 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 10554444a9
commit 21f2b6b95c
10 changed files with 220 additions and 122 deletions
+3 -2
View File
@@ -372,6 +372,7 @@ class DownloadClient(ABC):
# Client registry: protocol -> list of client classes
_CLIENTS: dict[str, list[type[DownloadClient]]] = {}
ClientType = TypeVar("ClientType", bound=DownloadClient)
_BUILTIN_CLIENT_MODULES = (
"shelfmark.download.clients.alldebrid",
"shelfmark.download.clients.deluge",
@@ -398,7 +399,7 @@ def _ensure_builtin_clients_registered() -> None:
def register_client(
protocol: str,
) -> Callable[[type[DownloadClient]], type[DownloadClient]]:
) -> Callable[[type[ClientType]], type[ClientType]]:
"""Register a download client for a protocol.
Multiple clients can be registered for the same protocol.
@@ -414,7 +415,7 @@ def register_client(
"""
def decorator(cls: type[DownloadClient]) -> type[DownloadClient]:
def decorator(cls: type[ClientType]) -> type[ClientType]:
if protocol not in _CLIENTS:
_CLIENTS[protocol] = []
_CLIENTS[protocol].append(cls)
+76 -60
View File
@@ -11,7 +11,7 @@ import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from typing import Any, ClassVar, NoReturn
from urllib.parse import quote
import requests
@@ -34,6 +34,15 @@ logger = setup_logger(__name__)
_API_BASE = "https://api.alldebrid.com/v4"
_AGENT = "shelfmark"
_ALLDEBRID_CLIENT_ERRORS = (
AttributeError,
OSError,
requests.exceptions.RequestException,
RuntimeError,
TypeError,
ValueError,
)
# AllDebrid magnet status codes (from API v4.1 documentation).
_STATUS_DOWNLOADING = frozenset({0, 1, 2, 3})
_STATUS_READY = 4
@@ -93,14 +102,20 @@ def _flatten_magnet_files(
_flatten_magnet_files(entry["e"], prefix=f"{prefix}{name}/"),
)
elif entry.get("l"):
flat.append({
"filename": f"{prefix}{name}",
"size": entry.get("s", 0),
"link": entry["l"],
})
flat.append(
{
"filename": f"{prefix}{name}",
"size": entry.get("s", 0),
"link": entry["l"],
}
)
return flat
def _raise_runtime_error(message: str) -> NoReturn:
raise RuntimeError(message)
@dataclass
class _DownloadState:
"""Internal mutable state for an in-progress AllDebrid download."""
@@ -129,7 +144,7 @@ class AllDebridClient(DownloadClient):
protocol = "torrent"
name = "alldebrid"
_downloads: dict[str, _DownloadState] = {}
_downloads: ClassVar[dict[str, _DownloadState]] = {}
_downloads_lock = threading.Lock()
def __init__(self) -> None:
@@ -172,12 +187,12 @@ class AllDebridClient(DownloadClient):
if not user.get("isPremium", False):
return (
False,
f"AllDebrid user '{username}' does not have "
f"a Premium subscription",
f"AllDebrid user '{username}' does not have a Premium subscription",
)
return True, f"Connected to AllDebrid as '{username}' (Premium)"
except Exception as e:
except _ALLDEBRID_CLIENT_ERRORS as e:
return False, f"Connection failed: {e}"
else:
return True, f"Connected to AllDebrid as '{username}' (Premium)"
def add_download(
self,
@@ -210,23 +225,23 @@ class AllDebridClient(DownloadClient):
if data.get("status") != "success":
code = data.get("error", {}).get("code", "UNKNOWN")
msg = f"AllDebrid upload failed: {code}"
raise RuntimeError(msg)
_raise_runtime_error(msg)
magnets = data.get("data", {}).get("magnets", [])
if not magnets:
msg = "No magnet returned from AllDebrid"
raise RuntimeError(msg)
_raise_runtime_error(msg)
info = magnets[0]
if info.get("error"):
code = info["error"].get("code", "UNKNOWN")
msg = f"AllDebrid magnet error: {code}"
raise RuntimeError(msg)
_raise_runtime_error(msg)
magnet_id = str(info.get("id", ""))
if not magnet_id:
msg = "No magnet ID returned from AllDebrid"
raise RuntimeError(msg)
_raise_runtime_error(msg)
target_dir = TMP_DIR / f"alldebrid_{magnet_id}"
target_dir.mkdir(parents=True, exist_ok=True)
@@ -245,12 +260,14 @@ class AllDebridClient(DownloadClient):
magnet_id,
name,
)
return magnet_id
except Exception:
logger.exception("Failed to upload magnet to AllDebrid")
raise
else:
return magnet_id
def get_status(self, download_id: str) -> DownloadStatus:
"""Poll AllDebrid for magnet status and drive the download."""
state = self._ensure_state(download_id)
@@ -301,12 +318,16 @@ class AllDebridClient(DownloadClient):
except Exception as e:
logger.exception(
"Error checking AllDebrid status for %s", download_id,
"Error checking AllDebrid status for %s",
download_id,
)
return DownloadStatus.error(str(e))
def remove(
self, download_id: str, *, delete_files: bool = False,
self,
download_id: str,
*,
delete_files: bool = False,
) -> bool:
"""Delete the magnet from AllDebrid and clean up local files."""
try:
@@ -318,7 +339,7 @@ class AllDebridClient(DownloadClient):
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(url),
)
except Exception as e:
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.warning("Failed to delete magnet from AllDebrid: %s", e)
with self._downloads_lock:
@@ -386,10 +407,7 @@ class AllDebridClient(DownloadClient):
return DownloadStatus(
progress=pct * 0.5,
state=DownloadState.DOWNLOADING,
message=(
f"AllDebrid downloading torrent "
f"({mag.get('filename', state.name)})"
),
message=(f"AllDebrid downloading torrent ({mag.get('filename', state.name)})"),
complete=False,
file_path=None,
download_speed=mag.get("downloadSpeed", 0),
@@ -406,10 +424,7 @@ class AllDebridClient(DownloadClient):
)
# Terminal error from AllDebrid.
error_txt = (
mag.get("error", {}).get("message")
or f"AllDebrid status code {status_code}"
)
error_txt = mag.get("error", {}).get("message") or f"AllDebrid status code {status_code}"
with state.lock:
state.phase = "error"
state.error_message = error_txt
@@ -419,12 +434,11 @@ class AllDebridClient(DownloadClient):
"""Spawn a background thread to unlock and download files."""
with state.lock:
already_running = state.phase in (
"unlocking", "downloading_http", "complete",
)
thread_alive = (
state.download_thread is not None
and state.download_thread.is_alive()
"unlocking",
"downloading_http",
"complete",
)
thread_alive = state.download_thread is not None and state.download_thread.is_alive()
if already_running or thread_alive:
return
state.phase = "unlocking"
@@ -476,24 +490,23 @@ class AllDebridClient(DownloadClient):
body = resp.json()
if body.get("status") == "success":
direct = self._resolve_unlock_data(
body.get("data", {}), headers,
body.get("data", {}),
headers,
)
if direct:
return direct
err_msg = body.get("error", {}).get(
"message", "Unlock failed",
"message",
"Unlock failed",
)
except Exception as e:
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.debug("POST unlock exception: %s", e)
# 3. GET unlock fallback with URL-encoded link.
try:
encoded = quote(link, safe="")
get_url = (
f"{_API_BASE}/link/unlock"
f"?agent={_AGENT}"
f"&apikey={self._api_key}"
f"&link={encoded}"
f"{_API_BASE}/link/unlock?agent={_AGENT}&apikey={self._api_key}&link={encoded}"
)
resp = requests.get(
get_url,
@@ -508,13 +521,14 @@ class AllDebridClient(DownloadClient):
if direct:
return direct
err_msg = body.get("error", {}).get("message", err_msg)
except Exception as e:
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.debug("GET unlock exception: %s", e)
# 4. Last-resort: append apikey to alldebrid.com/f/ links.
if "alldebrid.com/f/" in link:
logger.info(
"Using apikey fallback for AllDebrid file link: %s", link,
"Using apikey fallback for AllDebrid file link: %s",
link,
)
if "apikey=" not in link:
sep = "&" if "?" in link else "?"
@@ -522,7 +536,9 @@ class AllDebridClient(DownloadClient):
return link
logger.error(
"AllDebrid unlock failed for '%s': %s", link, err_msg,
"AllDebrid unlock failed for '%s': %s",
link,
err_msg,
)
msg = f"AllDebrid unlock failed: {err_msg}"
raise RuntimeError(msg)
@@ -541,7 +557,8 @@ class AllDebridClient(DownloadClient):
if "delayed" in data:
delayed_id = data["delayed"]
logger.info(
"AllDebrid link delayed (ID %s), polling...", delayed_id,
"AllDebrid link delayed (ID %s), polling...",
delayed_id,
)
delayed_url = f"{_API_BASE}/link/delayed"
for _ in range(_DELAYED_POLL_MAX_ATTEMPTS):
@@ -558,13 +575,9 @@ class AllDebridClient(DownloadClient):
continue
body = resp.json()
d = body.get("data", {})
if (
body.get("status") == "success"
and d.get("status") == 2
and d.get("link")
):
if body.get("status") == "success" and d.get("status") == 2 and d.get("link"):
return d["link"]
except Exception as e:
except _ALLDEBRID_CLIENT_ERRORS as e:
logger.debug("Delayed poll exception: %s", e)
return data.get("link")
@@ -580,10 +593,7 @@ class AllDebridClient(DownloadClient):
"""
try:
files = self._fetch_file_list(state.magnet_id)
relevant = [
f for f in files
if f["filename"].lower().endswith(_BOOK_EXTENSIONS)
]
relevant = [f for f in files if f["filename"].lower().endswith(_BOOK_EXTENSIONS)]
if not relevant:
relevant = files
@@ -600,17 +610,20 @@ class AllDebridClient(DownloadClient):
logger.info(
"Downloading AllDebrid file %d/%d: %s",
idx + 1, total, rel_path,
idx + 1,
total,
rel_path,
)
buf = download_url(
direct_link, referer="https://alldebrid.com/",
direct_link,
referer="https://alldebrid.com/",
)
if not buf:
msg = f"Failed to download from {direct_link}"
raise RuntimeError(msg)
_raise_runtime_error(msg)
with open(dest, "wb") as fh:
with dest.open("wb") as fh:
fh.write(buf.getvalue())
with state.lock:
@@ -622,12 +635,14 @@ class AllDebridClient(DownloadClient):
logger.info(
"AllDebrid download complete for ID %s at %s",
state.magnet_id, state.target_dir,
state.magnet_id,
state.target_dir,
)
except Exception:
logger.exception(
"Error in AllDebrid download for ID %s", state.magnet_id,
"Error in AllDebrid download for ID %s",
state.magnet_id,
)
with state.lock:
state.phase = "error"
@@ -636,7 +651,8 @@ class AllDebridClient(DownloadClient):
)
def _fetch_file_list(
self, magnet_id: str,
self,
magnet_id: str,
) -> list[dict[str, Any]]:
"""Retrieve and flatten the file tree for a magnet."""
url = f"{_API_BASE}/magnet/files"
+4 -2
View File
@@ -318,8 +318,10 @@ class ExternalClientHandler(DownloadHandler, ABC):
return
if not category_updated:
logger.warning(
"Failed to set post-import category for torrent %s in %s",
# Clients that cannot label torrents (debrid services) return False here,
# and the ones that can already log the specific failure themselves.
logger.debug(
"Post-import category not applied to torrent %s in %s",
download_id,
getattr(client, "name", "client"),
)
+55 -30
View File
@@ -8,10 +8,9 @@ from __future__ import annotations
import shutil
import threading
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from typing import Any, ClassVar, NoReturn
import requests
@@ -32,14 +31,25 @@ logger = setup_logger(__name__)
_API_BASE = "https://api.real-debrid.com/rest/1.0"
_REALDEBRID_CLIENT_ERRORS = (
AttributeError,
OSError,
requests.exceptions.RequestException,
RuntimeError,
TypeError,
ValueError,
)
# Real-Debrid torrent status values.
_STATUS_DOWNLOADING = frozenset({
"magnet_conversion",
"waiting_files_selection",
"downloading",
"compressing",
"uploading",
})
_STATUS_DOWNLOADING = frozenset(
{
"magnet_conversion",
"waiting_files_selection",
"downloading",
"compressing",
"uploading",
}
)
_STATUS_READY = "downloaded"
_STATUS_ERROR = frozenset({"error", "virus", "dead"})
@@ -74,6 +84,10 @@ _BOOK_EXTENSIONS = (
)
def _raise_runtime_error(message: str) -> NoReturn:
raise RuntimeError(message)
@dataclass
class _DownloadState:
"""Internal mutable state for an in-progress Real-Debrid download."""
@@ -103,7 +117,7 @@ class RealDebridClient(DownloadClient):
protocol = "torrent"
name = "realdebrid"
_downloads: dict[str, _DownloadState] = {}
_downloads: ClassVar[dict[str, _DownloadState]] = {}
_downloads_lock = threading.Lock()
def __init__(self) -> None:
@@ -146,9 +160,10 @@ class RealDebridClient(DownloadClient):
f"Real-Debrid user '{username}' does not have "
f"a Premium subscription (type: {account_type})",
)
return True, f"Connected to Real-Debrid as '{username}' (Premium)"
except Exception as e:
except _REALDEBRID_CLIENT_ERRORS as e:
return False, f"Connection failed: {e}"
else:
return True, f"Connected to Real-Debrid as '{username}' (Premium)"
def add_download(
self,
@@ -182,7 +197,7 @@ class RealDebridClient(DownloadClient):
torrent_id = str(data.get("id", ""))
if not torrent_id:
msg = "No torrent ID returned from Real-Debrid"
raise RuntimeError(msg)
_raise_runtime_error(msg)
# Select all files so Real-Debrid starts downloading the torrent
select_url = f"{_API_BASE}/torrents/selectFiles/{torrent_id}"
@@ -212,12 +227,14 @@ class RealDebridClient(DownloadClient):
torrent_id,
name,
)
return torrent_id
except Exception:
logger.exception("Failed to upload magnet to Real-Debrid")
raise
else:
return torrent_id
def get_status(self, download_id: str) -> DownloadStatus:
"""Poll Real-Debrid for torrent status and drive the download."""
state = self._ensure_state(download_id)
@@ -260,12 +277,16 @@ class RealDebridClient(DownloadClient):
except Exception as e:
logger.exception(
"Error checking Real-Debrid status for %s", download_id,
"Error checking Real-Debrid status for %s",
download_id,
)
return DownloadStatus.error(str(e))
def remove(
self, download_id: str, *, delete_files: bool = False,
self,
download_id: str,
*,
delete_files: bool = False,
) -> bool:
"""Delete the torrent from Real-Debrid and clean up local files."""
try:
@@ -276,7 +297,7 @@ class RealDebridClient(DownloadClient):
timeout=_STATUS_TIMEOUT,
verify=get_ssl_verify(url),
)
except Exception as e:
except _REALDEBRID_CLIENT_ERRORS as e:
logger.warning("Failed to delete torrent from Real-Debrid: %s", e)
with self._downloads_lock:
@@ -368,12 +389,11 @@ class RealDebridClient(DownloadClient):
"""Spawn a background thread to unrestrict and download files."""
with state.lock:
already_running = state.phase in (
"unrestricting", "downloading_http", "complete",
)
thread_alive = (
state.download_thread is not None
and state.download_thread.is_alive()
"unrestricting",
"downloading_http",
"complete",
)
thread_alive = state.download_thread is not None and state.download_thread.is_alive()
if already_running or thread_alive:
return
state.phase = "unrestricting"
@@ -402,7 +422,7 @@ class RealDebridClient(DownloadClient):
try:
if not links:
msg = "No download links returned by Real-Debrid"
raise RuntimeError(msg)
_raise_runtime_error(msg)
# Match selected files with links
selected_files = [f for f in files if f.get("selected") == 1]
@@ -442,7 +462,7 @@ class RealDebridClient(DownloadClient):
filename = unl_data.get("filename")
if not direct_url:
msg = f"Failed to unrestrict Real-Debrid link: {link}"
raise RuntimeError(msg)
_raise_runtime_error(msg)
# Determine relative file path
if rel_idx < len(selected_files):
@@ -456,17 +476,20 @@ class RealDebridClient(DownloadClient):
logger.info(
"Downloading Real-Debrid file %d/%d: %s",
idx + 1, total, rel_path,
idx + 1,
total,
rel_path,
)
buf = download_url(
direct_url, referer="https://real-debrid.com/",
direct_url,
referer="https://real-debrid.com/",
)
if not buf:
msg = f"Failed to download from {direct_url}"
raise RuntimeError(msg)
_raise_runtime_error(msg)
with open(dest, "wb") as fh:
with dest.open("wb") as fh:
fh.write(buf.getvalue())
with state.lock:
@@ -478,12 +501,14 @@ class RealDebridClient(DownloadClient):
logger.info(
"Real-Debrid download complete for ID %s at %s",
state.torrent_id, state.target_dir,
state.torrent_id,
state.target_dir,
)
except Exception:
logger.exception(
"Error in Real-Debrid download for ID %s", state.torrent_id,
"Error in Real-Debrid download for ID %s",
state.torrent_id,
)
with state.lock:
state.phase = "error"
+8 -5
View File
@@ -338,12 +338,14 @@ class RTorrentClient(DownloadClient):
"""
try:
# rtorrent is somehow case sensitive and requires uppercase hashes for look
torrent_hash = download_id.upper()
if delete_files:
self._rpc.d.delete_tied(download_id)
self._rpc.d.erase(download_id)
self._rpc.d.delete_tied(torrent_hash)
self._rpc.d.erase(torrent_hash)
else:
self._rpc.d.stop(download_id)
self._rpc.d.erase(download_id)
self._rpc.d.stop(torrent_hash)
self._rpc.d.erase(torrent_hash)
logger.info(
"Removed torrent from rTorrent: %s%s",
@@ -360,7 +362,8 @@ class RTorrentClient(DownloadClient):
def set_category(self, download_id: str, category: str) -> bool:
"""Assign a label to a torrent using rTorrent's custom1 field."""
try:
self._rpc.d.custom1.set(download_id, category)
# rtorrent is somehow case sensitive and requires uppercase hashes for look
self._rpc.d.custom1.set(download_id.upper(), category)
logger.info("Set rTorrent label for %s to '%s'", download_id, category)
except _RTORRENT_CLIENT_ERRORS as e:
error_type = type(e).__name__
+16 -3
View File
@@ -390,11 +390,24 @@ class TransmissionClient(DownloadClient):
else:
return True
def _get_torrent_labels(self, download_id: str) -> list[str]:
"""Return a torrent's current labels, preserving their order."""
torrent = self._client.get_torrent(download_id)
raw_labels = getattr(torrent, "labels", None) or []
return [str(label) for label in raw_labels if str(label)]
def set_category(self, download_id: str, category: str) -> bool:
"""Replace a torrent's labels with the post-import label."""
"""Add the post-import label to a torrent, keeping labels set elsewhere."""
try:
self._client.change_torrent(ids=download_id, labels=[category])
logger.info("Set Transmission label for %s to '%s'", download_id, category)
existing_labels = self._get_torrent_labels(download_id)
if category in existing_labels:
logger.debug(
"Transmission torrent %s already has label '%s'", download_id, category
)
return True
self._client.change_torrent(ids=download_id, labels=[*existing_labels, category])
logger.info("Added Transmission label '%s' to %s", category, download_id)
except _TRANSMISSION_CLIENT_ERRORS as e:
self._log_error("set_category", e)
return False
+17 -14
View File
@@ -24,12 +24,12 @@ Dataclass representing a book from a metadata provider:
```python
@dataclass
class BookMetadata:
provider: str # Internal provider name (e.g., "hardcover")
provider_id: str # ID in that provider's system
provider: str # Internal provider name (e.g., "hardcover")
provider_id: str # ID in that provider's system
title: str
# Optional fields
provider_display_name: str # Human-readable name (e.g., "Hardcover")
provider_display_name: str # Human-readable name (e.g., "Hardcover")
authors: List[str]
isbn_10: str
isbn_13: str
@@ -39,7 +39,7 @@ class BookMetadata:
publish_year: int
language: str
genres: List[str]
source_url: str # Link to book on provider's site
source_url: str # Link to book on provider's site
display_fields: List[DisplayField] # Provider-specific display data
```
@@ -50,9 +50,9 @@ Provider-specific metadata for UI cards (ratings, page counts, reader counts, et
```python
@dataclass
class DisplayField:
label: str # e.g., "Rating", "Pages", "Readers"
value: str # e.g., "4.5", "496", "8,041"
icon: str # Icon name: "star", "book", "users", "editions"
label: str # e.g., "Rating", "Pages", "Readers"
value: str # e.g., "4.5", "496", "8,041"
icon: str # Icon name: "star", "book", "users", "editions"
```
### MetadataSearchOptions
@@ -64,7 +64,7 @@ Unified search options that work across all providers:
class MetadataSearchOptions:
query: str
search_type: SearchType = SearchType.GENERAL # GENERAL, TITLE, AUTHOR, ISBN
language: str = None # ISO 639-1 code (e.g., "en")
language: str = None # ISO 639-1 code (e.g., "en")
sort: SortOrder = SortOrder.RELEVANCE
limit: int = 40
page: int = 1
@@ -88,10 +88,10 @@ All providers must implement this interface:
```python
class MetadataProvider(ABC):
name: str # Internal identifier
display_name: str # Human-readable name
requires_auth: bool # True if API key required
supported_sorts: List[SortOrder] # Supported sort options
name: str # Internal identifier
display_name: str # Human-readable name
requires_auth: bool # True if API key required
supported_sorts: List[SortOrder] # Supported sort options
@abstractmethod
def search(self, options: MetadataSearchOptions) -> List[BookMetadata]:
@@ -121,9 +121,9 @@ class MetadataProvider(ABC):
```python
from shelfmark.metadata_providers import register_provider
@register_provider("my_provider")
class MyProvider(MetadataProvider):
...
class MyProvider(MetadataProvider): ...
```
### Getting Providers
@@ -281,11 +281,13 @@ from shelfmark.config.env import (
METADATA_CACHE_BOOK_TTL,
)
@cacheable(ttl=METADATA_CACHE_SEARCH_TTL, key_prefix="myprovider:search")
def _search_cached(self, cache_key: str, options: MetadataSearchOptions):
# Cached search implementation
pass
@cacheable(ttl=METADATA_CACHE_BOOK_TTL, key_prefix="myprovider:book")
def get_book(self, book_id: str):
# Cached book lookup
@@ -302,6 +304,7 @@ from shelfmark.metadata_providers.openlibrary import RateLimiter
# 90 requests per 60 seconds
rate_limiter = RateLimiter(max_requests=90, window_seconds=60)
def make_request(self):
rate_limiter.wait_if_needed() # Blocks if rate limited
# ... make request
+7 -5
View File
@@ -33,8 +33,9 @@ def test_set_category_updates_custom1_label():
client = RTorrentClient.__new__(RTorrentClient)
client._rpc = MagicMock()
# rTorrent lookups are case sensitive and info hashes reach us lowercase.
assert client.set_category("abc123", "imported") is True
client._rpc.d.custom1.set.assert_called_once_with("abc123", "imported")
client._rpc.d.custom1.set.assert_called_once_with("ABC123", "imported")
class TestRTorrentClientIsConfigured:
@@ -779,8 +780,9 @@ class TestRTorrentClientRemove:
result = client.remove("abc123def456", delete_files=False)
assert result is True
mock_rpc.d.stop.assert_called_once_with("abc123def456")
mock_rpc.d.erase.assert_called_once_with("abc123def456")
# rTorrent lookups are case sensitive and info hashes reach us lowercase.
mock_rpc.d.stop.assert_called_once_with("ABC123DEF456")
mock_rpc.d.erase.assert_called_once_with("ABC123DEF456")
def test_remove_with_files(self, monkeypatch):
"""Test torrent removal with file deletion."""
@@ -813,8 +815,8 @@ class TestRTorrentClientRemove:
result = client.remove("abc123def456", delete_files=True)
assert result is True
mock_rpc.d.delete_tied.assert_called_once_with("abc123def456")
mock_rpc.d.erase.assert_called_once_with("abc123def456")
mock_rpc.d.delete_tied.assert_called_once_with("ABC123DEF456")
mock_rpc.d.erase.assert_called_once_with("ABC123DEF456")
def test_remove_failure(self, monkeypatch):
"""Test failed torrent removal."""
+2
View File
@@ -776,6 +776,8 @@ class TestMamLanguageCoverage:
}
for tag, expected in cases.items():
assert _extract_mam_language(f"Book [{tag} / M4B]") == expected, tag
class _MultiIndexerClient:
"""Torznab client where each indexer entry returns its own result set.
+32 -1
View File
@@ -68,11 +68,27 @@ def create_mock_transmission_rpc_module():
return mock_module
def test_set_category_replaces_labels():
def _transmission_client_with_labels(labels):
from shelfmark.download.clients.transmission import TransmissionClient
client = TransmissionClient.__new__(TransmissionClient)
client._client = MagicMock()
client._client.get_torrent.return_value = types.SimpleNamespace(labels=labels)
return client
def test_set_category_appends_to_existing_labels():
client = _transmission_client_with_labels(["books", "seeding"])
assert client.set_category("abc123", "imported") is True
client._client.change_torrent.assert_called_once_with(
ids="abc123",
labels=["books", "seeding", "imported"],
)
def test_set_category_adds_label_when_torrent_has_none():
client = _transmission_client_with_labels([])
assert client.set_category("abc123", "imported") is True
client._client.change_torrent.assert_called_once_with(
@@ -81,6 +97,21 @@ def test_set_category_replaces_labels():
)
def test_set_category_is_a_noop_when_label_already_present():
client = _transmission_client_with_labels(["books", "imported"])
assert client.set_category("abc123", "imported") is True
client._client.change_torrent.assert_not_called()
def test_set_category_does_not_clobber_labels_when_lookup_fails():
client = _transmission_client_with_labels([])
client._client.get_torrent.side_effect = ValueError("boom")
assert client.set_category("abc123", "imported") is False
client._client.change_torrent.assert_not_called()
class TestTransmissionClientIsConfigured:
"""Tests for TransmissionClient.is_configured()."""