diff --git a/shelfmark/download/clients/__init__.py b/shelfmark/download/clients/__init__.py index c0b7f25..a8942b7 100644 --- a/shelfmark/download/clients/__init__.py +++ b/shelfmark/download/clients/__init__.py @@ -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) diff --git a/shelfmark/download/clients/alldebrid.py b/shelfmark/download/clients/alldebrid.py index 10edbba..e5e6850 100644 --- a/shelfmark/download/clients/alldebrid.py +++ b/shelfmark/download/clients/alldebrid.py @@ -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" diff --git a/shelfmark/download/clients/base_handler.py b/shelfmark/download/clients/base_handler.py index 4f76d3b..16b98e6 100644 --- a/shelfmark/download/clients/base_handler.py +++ b/shelfmark/download/clients/base_handler.py @@ -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"), ) diff --git a/shelfmark/download/clients/realdebrid.py b/shelfmark/download/clients/realdebrid.py index 006833f..f2eb00a 100644 --- a/shelfmark/download/clients/realdebrid.py +++ b/shelfmark/download/clients/realdebrid.py @@ -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" diff --git a/shelfmark/download/clients/rtorrent.py b/shelfmark/download/clients/rtorrent.py index 7bf3d55..9cc7782 100644 --- a/shelfmark/download/clients/rtorrent.py +++ b/shelfmark/download/clients/rtorrent.py @@ -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__ diff --git a/shelfmark/download/clients/transmission.py b/shelfmark/download/clients/transmission.py index 42d5fd1..428d494 100644 --- a/shelfmark/download/clients/transmission.py +++ b/shelfmark/download/clients/transmission.py @@ -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 diff --git a/shelfmark/metadata_providers/README.md b/shelfmark/metadata_providers/README.md index 8124d67..5a78944 100644 --- a/shelfmark/metadata_providers/README.md +++ b/shelfmark/metadata_providers/README.md @@ -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 diff --git a/tests/prowlarr/test_rtorrent_client.py b/tests/prowlarr/test_rtorrent_client.py index a681491..a35dbda 100644 --- a/tests/prowlarr/test_rtorrent_client.py +++ b/tests/prowlarr/test_rtorrent_client.py @@ -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.""" diff --git a/tests/prowlarr/test_source.py b/tests/prowlarr/test_source.py index cadef3c..8172c92 100644 --- a/tests/prowlarr/test_source.py +++ b/tests/prowlarr/test_source.py @@ -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. diff --git a/tests/prowlarr/test_transmission_client.py b/tests/prowlarr/test_transmission_client.py index 7372c42..faf1668 100644 --- a/tests/prowlarr/test_transmission_client.py +++ b/tests/prowlarr/test_transmission_client.py @@ -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()."""