mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-04 06:11:17 +01:00
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:
co-authored by
Claude Opus 5
parent
10554444a9
commit
21f2b6b95c
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user