This commit is contained in:
nose
2025-12-16 01:45:01 -08:00
parent a03eb0d1be
commit 9873280f0e
36 changed files with 4911 additions and 1225 deletions

View File

@@ -13,7 +13,7 @@ from SYS.logger import log, debug
import time
import logging
from typing import Any, Dict, Optional, Set, List, Sequence, Tuple
from urllib.parse import urlencode, urlparse
from urllib.parse import urlparse
from .HTTP import HTTPClient
logger = logging.getLogger(__name__)
@@ -51,11 +51,34 @@ def _ping_alldebrid(base_url: str) -> Tuple[bool, Optional[str]]:
class AllDebridClient:
"""Client for AllDebrid API."""
# Try both v4 and v3 APIs
BASE_url = [
"https://api.alldebrid.com/v4",
"https://api.alldebrid.com/v3",
]
# Default to v4 for most endpoints.
# Some endpoints have a newer /v4.1/ variant (e.g., magnet/status, user/hosts, pin/get).
BASE_URL = "https://api.alldebrid.com/v4"
BASE_URL_V41 = "https://api.alldebrid.com/v4.1"
# Endpoints documented as POST in v4 API.
_POST_ENDPOINTS: Set[str] = {
"pin/check",
"user/verif",
"user/verif/resend",
"user/notification/clear",
"link/infos",
"link/redirector",
"link/unlock",
"link/streaming",
"link/delayed",
"magnet/upload",
"magnet/upload/file",
"magnet/status", # v4.1 variant exists; method stays POST
"magnet/files",
"magnet/delete",
"magnet/restart",
"user/links/save",
"user/links/delete",
"user/history/delete",
"voucher/get",
"voucher/generate",
}
def __init__(self, api_key: str):
"""Initialize AllDebrid client with API key.
@@ -66,7 +89,7 @@ class AllDebridClient:
self.api_key = api_key.strip()
if not self.api_key:
raise AllDebridError("AllDebrid API key is empty")
self.base_url = self.BASE_url[0] # Start with v4
self.base_url = self.BASE_URL # Start with v4
# Init-time availability validation (cached per process)
fingerprint = f"base:{self.base_url}" # /ping does not require the api key
@@ -80,7 +103,13 @@ class AllDebridClient:
if not ok:
raise AllDebridError(reason or "AllDebrid unavailable")
def _request(self, endpoint: str, params: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
def _request(
self,
endpoint: str,
params: Optional[Dict[str, Any]] = None,
*,
method: Optional[str] = None,
) -> Dict[str, Any]:
"""Make a request to AllDebrid API.
Args:
@@ -95,25 +124,38 @@ class AllDebridClient:
"""
if params is None:
params = {}
# Add API key to params
params['apikey'] = self.api_key
# Determine HTTP method (v4 docs default to POST for most write/unlock endpoints).
if method is None:
method = "POST" if endpoint in self._POST_ENDPOINTS else "GET"
method = str(method).upper().strip() or "GET"
# Auth header is the preferred mechanism per v4.1 docs.
# Keep apikey in params too for backward compatibility.
request_params: Dict[str, Any] = dict(params)
request_params["apikey"] = self.api_key
url = f"{self.base_url}/{endpoint}"
query_string = urlencode(params)
full_url = f"{url}?{query_string}"
logger.debug(f"[AllDebrid] {endpoint} request to {full_url[:80]}...")
# Avoid logging full URLs with query params (can leak apikey).
logger.debug(f"[AllDebrid] {method} {endpoint} @ {self.base_url}")
try:
# Pass timeout to HTTPClient init, not to get()
with HTTPClient(timeout=30.0, headers={'User-Agent': 'downlow/1.0'}) as client:
headers = {
"User-Agent": "downlow/1.0",
"Authorization": f"Bearer {self.api_key}",
}
# Pass timeout to HTTPClient init.
with HTTPClient(timeout=30.0, headers=headers) as client:
try:
response = client.get(full_url)
if method == "POST":
response = client.post(url, data=request_params)
else:
response = client.get(url, params=request_params)
response.raise_for_status()
except Exception as req_err:
# Log detailed error info
logger.error(f"[AllDebrid] Request error to {full_url[:80]}: {req_err}", exc_info=True)
logger.error(f"[AllDebrid] Request error to {endpoint}: {req_err}", exc_info=True)
if hasattr(req_err, 'response') and req_err.response is not None: # type: ignore
try:
error_body = req_err.response.content.decode('utf-8') # type: ignore
@@ -190,13 +232,26 @@ class AllDebridClient:
Raises:
AllDebridError: If request fails
"""
try:
response = self._request('host', {'name': hostname})
if response.get('status') == 'success':
return response.get('data', {})
# The v4 API does not expose a `/host` endpoint. Use `/hosts/domains` and
# check membership.
if not hostname:
return {}
try:
host = str(hostname).strip().lower()
if host.startswith("www."):
host = host[4:]
domains = self.get_supported_hosters()
if not domains:
return {}
for category in ("hosts", "streams", "redirectors"):
values = domains.get(category)
if isinstance(values, list) and any(str(d).lower() == host for d in values):
return {"supported": True, "category": category, "domain": host}
return {"supported": False, "domain": host}
except AllDebridError:
raise
except Exception as exc:
@@ -212,7 +267,8 @@ class AllDebridClient:
AllDebridError: If request fails
"""
try:
response = self._request('user/profile')
# v4 endpoint is `/user`
response = self._request('user')
if response.get('status') == 'success':
return response.get('data', {})
@@ -227,7 +283,8 @@ class AllDebridClient:
"""Get list of all supported hosters from AllDebrid API.
Returns:
Dict mapping domain to host info (status, name, etc)
Dict with keys `hosts`, `streams`, `redirectors` each containing an array
of domains.
Raises:
AllDebridError: If request fails
@@ -237,7 +294,6 @@ class AllDebridClient:
if response.get('status') == 'success':
data = response.get('data', {})
# The API returns hosts keyed by domain
return data if isinstance(data, dict) else {}
return {}
@@ -334,7 +390,7 @@ class AllDebridClient:
# Use v4.1 endpoint for better response format
# Temporarily override base_url for this request
old_base = self.base_url
self.base_url = "https://api.alldebrid.com/v4.1"
self.base_url = self.BASE_URL_V41
try:
response = self._request('magnet/status', {'id': str(magnet_id)})
@@ -358,8 +414,48 @@ class AllDebridClient:
raise
except Exception as exc:
raise AllDebridError(f"Failed to get magnet status: {exc}")
def magnet_list(self) -> List[Dict[str, Any]]:
"""List magnets stored in the AllDebrid account.
The AllDebrid API returns an array of magnets when calling the status
endpoint without an id.
Returns:
List of magnet objects.
"""
try:
# Use v4.1 endpoint for better response format
old_base = self.base_url
self.base_url = self.BASE_URL_V41
try:
response = self._request('magnet/status')
finally:
self.base_url = old_base
if response.get('status') != 'success':
return []
data = response.get('data', {})
magnets = data.get('magnets', [])
if isinstance(magnets, list):
return [m for m in magnets if isinstance(m, dict)]
# Some API variants may return a dict.
if isinstance(magnets, dict):
# If it's a single magnet dict, wrap it; if it's an id->magnet mapping, return values.
if 'id' in magnets:
return [magnets]
return [m for m in magnets.values() if isinstance(m, dict)]
return []
except AllDebridError:
raise
except Exception as exc:
raise AllDebridError(f"Failed to list magnets: {exc}")
def magnet_status_live(self, magnet_id: int, session: int = None, counter: int = 0) -> Dict[str, Any]:
def magnet_status_live(self, magnet_id: int, session: Optional[int] = None, counter: int = 0) -> Dict[str, Any]:
"""Get live status of a magnet using delta sync mode.
The live mode endpoint provides real-time progress by only sending
@@ -388,21 +484,32 @@ class AllDebridClient:
raise AllDebridError(f"Invalid magnet ID: {magnet_id}")
try:
# For single magnet queries, just use regular endpoint with ID
# The "live mode" with session/counter is for multi-magnet dashboards
# where bandwidth savings from diffs matter
response = self._request('magnet/status', {'id': magnet_id})
# v4.1 is the up-to-date endpoint for magnet/status.
old_base = self.base_url
self.base_url = self.BASE_URL_V41
try:
payload: Dict[str, Any] = {"id": str(magnet_id)}
if session is not None:
payload["session"] = str(int(session))
payload["counter"] = str(int(counter))
response = self._request('magnet/status', payload)
finally:
self.base_url = old_base
if response.get('status') == 'success':
data = response.get('data', {})
magnets = data.get('magnets', [])
# Handle list response
# For specific magnet id, return the first match from the array.
if isinstance(magnets, list) and len(magnets) > 0:
return magnets[0]
# Some API variants may return a dict.
if isinstance(magnets, dict) and magnets:
return magnets
raise AllDebridError(f"No magnet found with ID {magnet_id}")
raise AllDebridError(f"API error: {response.get('error', 'Unknown')}")
except AllDebridError:
raise
@@ -784,28 +891,65 @@ def unlock_link_cmdlet(result: Any, args: Sequence[str], config: Dict[str, Any])
Returns:
0 on success, 1 on failure
"""
try:
from .link_utils import (
extract_link,
get_api_key,
add_direct_link_to_result,
)
except ImportError as e:
log(f"Required modules unavailable: {e}", file=sys.stderr)
return 1
def _extract_link_from_args_or_result(result_obj: Any, argv: Sequence[str]) -> Optional[str]:
# Prefer an explicit URL in args.
for a in argv or []:
if isinstance(a, str) and a.startswith(("http://", "https://")):
return a.strip()
# Fall back to common pipeline fields.
if isinstance(result_obj, dict):
for key in ("url", "source_url", "path", "target"):
v = result_obj.get(key)
if isinstance(v, str) and v.startswith(("http://", "https://")):
return v.strip()
return None
def _get_alldebrid_api_key_from_config(cfg: Dict[str, Any]) -> Optional[str]:
# Current config format
try:
provider_cfg = cfg.get("provider") if isinstance(cfg, dict) else None
ad_cfg = provider_cfg.get("alldebrid") if isinstance(provider_cfg, dict) else None
api_key = ad_cfg.get("api_key") if isinstance(ad_cfg, dict) else None
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
except Exception:
pass
# Legacy config format fallback (best-effort)
try:
debrid_cfg = cfg.get("Debrid") if isinstance(cfg, dict) else None
api_key = None
if isinstance(debrid_cfg, dict):
api_key = debrid_cfg.get("All-debrid") or debrid_cfg.get("AllDebrid")
if isinstance(api_key, str) and api_key.strip():
return api_key.strip()
except Exception:
pass
return None
def _add_direct_link_to_result(result_obj: Any, direct_link: str, original_link: str) -> None:
if not isinstance(direct_link, str) or not direct_link.strip():
return
if isinstance(result_obj, dict):
# Keep original and promote unlocked link to the fields commonly used downstream.
result_obj.setdefault("source_url", original_link)
result_obj["url"] = direct_link
result_obj["path"] = direct_link
# Get link from args or result
link = extract_link(result, args)
link = _extract_link_from_args_or_result(result, args)
if not link:
log("No valid URL provided", file=sys.stderr)
return 1
# Get AllDebrid API key from config
api_key = get_api_key(config, "AllDebrid", "Debrid.All-debrid")
api_key = _get_alldebrid_api_key_from_config(config)
if not api_key:
log("AllDebrid API key not configured in Debrid.All-debrid", file=sys.stderr)
log("AllDebrid API key not configured (provider.alldebrid.api_key)", file=sys.stderr)
return 1
# Try to unlock the link
@@ -816,7 +960,7 @@ def unlock_link_cmdlet(result: Any, args: Sequence[str], config: Dict[str, Any])
debug(f"✓ Direct link: {direct_link}")
# Update result with direct link
add_direct_link_to_result(result, direct_link, link)
_add_direct_link_to_result(result, direct_link, link)
# Return the updated result via pipeline context
# Note: The cmdlet wrapper will handle emitting to pipeline