from __future__ import annotations import hashlib import sys import time from pathlib import Path from typing import Any, Dict, Iterable, List, Optional, Callable, Tuple from urllib.parse import urlparse from API.HTTP import HTTPClient from API.alldebrid import AllDebridClient, parse_magnet_or_hash, is_magnet_link, is_torrent_file from ProviderCore.base import Provider, SearchResult from ProviderCore.download import sanitize_filename from SYS.download import _download_direct_file from SYS.logger import log def _get_debrid_api_key(config: Dict[str, Any]) -> Optional[str]: """Read AllDebrid API key from config. Preferred formats: - config.conf provider block: [provider=alldebrid] api_key=... -> config["provider"]["alldebrid"]["api_key"] - store-style debrid block: config["store"]["debrid"]["all-debrid"]["api_key"] Falls back to some legacy keys if present. """ # 1) provider block: [provider=alldebrid] provider = config.get("provider") if isinstance(provider, dict): entry = provider.get("alldebrid") if isinstance(entry, dict): for k in ("api_key", "apikey", "API_KEY", "APIKEY"): val = entry.get(k) if isinstance(val, str) and val.strip(): return val.strip() if isinstance(entry, str) and entry.strip(): return entry.strip() # 2) store.debrid block (canonical for debrid store configuration) try: from SYS.config import get_debrid_api_key key = get_debrid_api_key(config, service="All-debrid") return key.strip() if key else None except Exception: pass # Legacy fallback (kept permissive so older configs still work) for legacy_key in ("alldebrid_api_key", "AllDebrid", "all_debrid_api_key"): val = config.get(legacy_key) if isinstance(val, str) and val.strip(): return val.strip() return None def _consume_bencoded_value(data: bytes, pos: int) -> int: if pos >= len(data): raise ValueError("Unexpected end of bencode") token = data[pos:pos + 1] if token == b"i": end = data.find(b"e", pos + 1) if end == -1: raise ValueError("Unterminated integer") return end + 1 if token == b"l" or token == b"d": cursor = pos + 1 while cursor < len(data): if data[cursor:cursor + 1] == b"e": return cursor + 1 cursor = _consume_bencoded_value(data, cursor) raise ValueError("Unterminated list/dict") if token and b"0" <= token <= b"9": colon = data.find(b":", pos) if colon == -1: raise ValueError("Invalid string length") length = int(data[pos:colon]) return colon + 1 + length raise ValueError("Unknown bencode token") def _info_hash_from_torrent_bytes(data: bytes) -> Optional[str]: needle = b"4:info" idx = data.find(needle) if idx == -1: return None start = idx + len(needle) try: end = _consume_bencoded_value(data, start) except ValueError: return None info_bytes = data[start:end] try: return hashlib.sha1(info_bytes).hexdigest() except Exception: return None def _fetch_torrent_bytes(target: str) -> Optional[bytes]: path_obj = Path(str(target)) try: if path_obj.exists() and path_obj.is_file(): return path_obj.read_bytes() except Exception: pass try: parsed = urlparse(target) except Exception: parsed = None if parsed is None or not parsed.scheme or parsed.scheme.lower() not in {"http", "https"}: return None if not target.lower().endswith(".torrent"): return None try: with HTTPClient(timeout=30.0) as client: response = client.get(target) return response.content except Exception as exc: log(f"Failed to download .torrent from {target}: {exc}", file=sys.stderr) return None def resolve_magnet_spec(target: str) -> Optional[str]: """Resolve a magnet/hash/torrent URL into a magnet/hash string.""" candidate = str(target or "").strip() if not candidate: return None parsed = parse_magnet_or_hash(candidate) if parsed: return parsed if is_torrent_file(candidate): torrent_bytes = _fetch_torrent_bytes(candidate) if not torrent_bytes: return None hash_value = _info_hash_from_torrent_bytes(torrent_bytes) if hash_value: return hash_value return None def _dispatch_alldebrid_magnet_search( magnet_id: int, config: Dict[str, Any], ) -> None: try: from cmdlet.search_file import CMDLET as _SEARCH_FILE_CMDLET exec_fn = getattr(_SEARCH_FILE_CMDLET, "exec", None) if callable(exec_fn): exec_fn( None, ["-provider", "alldebrid", f"ID={magnet_id}"], config, ) except Exception: pass log(f"[alldebrid] Sent magnet {magnet_id} to AllDebrid for download", file=sys.stderr) def prepare_magnet( magnet_spec: str, config: Dict[str, Any], ) -> tuple[Optional[AllDebridClient], Optional[int]]: api_key = _get_debrid_api_key(config or {}) if not api_key: try: from ProviderCore.registry import show_provider_config_panel show_provider_config_panel("alldebrid", ["api_key"]) except Exception: pass log("AllDebrid API key not configured (provider.alldebrid.api_key)", file=sys.stderr) return None, None try: client = AllDebridClient(api_key) except Exception as exc: log(f"Failed to initialize AllDebrid client: {exc}", file=sys.stderr) return None, None try: magnet_info = client.magnet_add(magnet_spec) magnet_id = int(magnet_info.get("id", 0)) if magnet_id <= 0: log(f"AllDebrid magnet submission failed: {magnet_info}", file=sys.stderr) return None, None except Exception as exc: log(f"Failed to submit magnet to AllDebrid: {exc}", file=sys.stderr) return None, None _dispatch_alldebrid_magnet_search(magnet_id, config) return client, magnet_id def _flatten_files_with_relpath(items: Any) -> Iterable[Dict[str, Any]]: for node in AllDebrid._flatten_files(items): enriched = dict(node) rel = node.get("_relpath") or node.get("relpath") if not rel: name = node.get("n") or node.get("name") rel = str(name or "").strip() enriched["relpath"] = rel yield enriched def download_magnet( magnet_spec: str, original_url: str, final_output_dir: Path, config: Dict[str, Any], progress: Any, quiet_mode: bool, path_from_result: Callable[[Any], Path], on_emit: Callable[[Path, str, str, Dict[str, Any]], None], ) -> tuple[int, Optional[int]]: client, magnet_id = prepare_magnet(magnet_spec, config) if client is None or magnet_id is None: return 0, None wait_timeout = 300 try: streaming_config = config.get("streaming", {}) if isinstance(config, dict) else {} wait_timeout = int(streaming_config.get("wait_timeout", 300)) except Exception: wait_timeout = 300 elapsed = 0 while elapsed < wait_timeout: try: status = client.magnet_status(magnet_id) except Exception as exc: log(f"Failed to read magnet status {magnet_id}: {exc}", file=sys.stderr) return 0, magnet_id ready = bool(status.get("ready")) or status.get("statusCode") == 4 if ready: break time.sleep(5) elapsed += 5 else: log(f"AllDebrid magnet {magnet_id} timed out after {wait_timeout}s", file=sys.stderr) return 0, magnet_id try: files_result = client.magnet_links([magnet_id]) except Exception as exc: log(f"Failed to list AllDebrid magnet files: {exc}", file=sys.stderr) return 0, magnet_id magnet_files = files_result.get(str(magnet_id), {}) if isinstance(files_result, dict) else {} file_nodes = magnet_files.get("files") if isinstance(magnet_files, dict) else [] if not file_nodes: log(f"AllDebrid magnet {magnet_id} produced no files", file=sys.stderr) return 0, magnet_id downloaded = 0 for node in _flatten_files_with_relpath(file_nodes): file_url = str(node.get("link") or "").strip() file_name = str(node.get("name") or "").strip() relpath = str(node.get("relpath") or file_name).strip() if not file_url or not relpath: continue target_path = final_output_dir rel_path_obj = Path(relpath) output_dir = target_path if rel_path_obj.parent: output_dir = target_path / rel_path_obj.parent try: output_dir.mkdir(parents=True, exist_ok=True) except Exception: output_dir = target_path try: result_obj = _download_direct_file( file_url, output_dir, quiet=quiet_mode, suggested_filename=rel_path_obj.name, pipeline_progress=progress, ) except Exception as exc: log(f"Failed to download AllDebrid file {file_url}: {exc}", file=sys.stderr) continue downloaded_path = path_from_result(result_obj) metadata = { "magnet_id": magnet_id, "relpath": relpath, "name": file_name, } on_emit(downloaded_path, file_url or original_url, relpath, metadata) downloaded += 1 return downloaded, magnet_id def expand_folder_item( item: Any, get_search_provider: Optional[Callable[[str, Dict[str, Any]], Any]], config: Dict[str, Any], ) -> Tuple[List[Any], Optional[str]]: table = getattr(item, "table", None) if not isinstance(item, dict) else item.get("table") media_kind = getattr(item, "media_kind", None) if not isinstance(item, dict) else item.get("media_kind") full_metadata = getattr(item, "full_metadata", None) if not isinstance(item, dict) else item.get("full_metadata") target = None if isinstance(item, dict): target = item.get("path") or item.get("url") else: target = getattr(item, "path", None) or getattr(item, "url", None) if (str(table or "").lower() != "alldebrid") or (str(media_kind or "").lower() != "folder"): return [], None magnet_id = None if isinstance(full_metadata, dict): magnet_id = full_metadata.get("magnet_id") if magnet_id is None and isinstance(target, str) and target.lower().startswith("alldebrid:magnet:"): try: magnet_id = int(target.split(":")[-1]) except Exception: magnet_id = None if magnet_id is None or get_search_provider is None: return [], None provider = get_search_provider("alldebrid", config) if get_search_provider else None if provider is None: return [], None try: files = provider.search("*", limit=10_000, filters={"view": "files", "magnet_id": int(magnet_id)}) except Exception: files = [] if files and len(files) == 1 and getattr(files[0], "media_kind", "") == "folder": detail = getattr(files[0], "detail", "") return [], str(detail or "unknown") expanded: List[Any] = [] for sr in files: expanded.append(sr.to_dict() if hasattr(sr, "to_dict") else sr) return expanded, None def adjust_output_dir_for_alldebrid( base_output_dir: Path, full_metadata: Optional[Dict[str, Any]], item: Any, ) -> Path: from ProviderCore.download import sanitize_filename as _sf output_dir = base_output_dir md = full_metadata if isinstance(full_metadata, dict) else {} magnet_name = md.get("magnet_name") or md.get("folder") if not magnet_name: try: detail_val = getattr(item, "detail", None) if not isinstance(item, dict) else item.get("detail") magnet_name = str(detail_val or "").strip() or None except Exception: magnet_name = None magnet_dir_name = _sf(str(magnet_name)) if magnet_name else "" try: base_tail = str(Path(output_dir).name or "") except Exception: base_tail = "" base_tail_norm = _sf(base_tail).lower() if base_tail.strip() else "" magnet_dir_norm = magnet_dir_name.lower() if magnet_dir_name else "" if magnet_dir_name and (not base_tail_norm or base_tail_norm != magnet_dir_norm): output_dir = Path(output_dir) / magnet_dir_name relpath = md.get("relpath") if isinstance(md, dict) else None if (not relpath) and isinstance(md.get("file"), dict): relpath = md["file"].get("_relpath") if relpath: parts = [p for p in str(relpath).replace("\\", "/").split("/") if p and p not in {".", ".."}] if magnet_dir_name and parts: try: if _sf(parts[0]).lower() == magnet_dir_norm: parts = parts[1:] except Exception: pass for part in parts[:-1]: output_dir = Path(output_dir) / _sf(part) try: Path(output_dir).mkdir(parents=True, exist_ok=True) except Exception: output_dir = base_output_dir return output_dir class AllDebrid(Provider): # Magnet URIs should be routed through this provider. URL = ("magnet:",) """Search provider for AllDebrid account content. This provider lists and searches the files/magnets already present in the user's AllDebrid account. Query behavior: - "*" / "all" / "list": list recent files from ready magnets - otherwise: substring match on file name OR magnet name, or exact magnet id """ def validate(self) -> bool: # Consider "available" when configured; actual API connectivity can vary. return bool(_get_debrid_api_key(self.config or {})) def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]: """Download an AllDebrid SearchResult into output_dir. AllDebrid magnet file listings often provide links that require an API "unlock" step to produce a true direct-download URL. Without unlocking, callers may download a small HTML/redirect page instead of file bytes. This is used by the download-file cmdlet when a provider item is piped. """ try: api_key = _get_debrid_api_key(self.config or {}) if not api_key: return None target = str(getattr(result, "path", "") or "").strip() if not target.startswith(("http://", "https://")): return None try: from API.alldebrid import AllDebridClient client = AllDebridClient(api_key) except Exception as exc: log(f"[alldebrid] Failed to init client: {exc}", file=sys.stderr) return None # Quiet mode when download-file is mid-pipeline. quiet = ( bool(self.config.get("_quiet_background_output")) if isinstance(self.config, dict) else False ) unlocked_url = target try: unlocked = client.unlock_link(target) if isinstance(unlocked, str) and unlocked.strip().startswith(("http://", "https://")): unlocked_url = unlocked.strip() except Exception as exc: # Fall back to the raw link, but warn. log(f"[alldebrid] Failed to unlock link: {exc}", file=sys.stderr) # Prefer provider title as the output filename. suggested = sanitize_filename( str(getattr(result, "title", "") or "").strip() ) suggested_name = suggested if suggested else None try: from SYS.download import _download_direct_file pipe_progress = None try: if isinstance(self.config, dict): pipe_progress = self.config.get("_pipeline_progress") except Exception: pipe_progress = None dl_res = _download_direct_file( unlocked_url, Path(output_dir), quiet=quiet, suggested_filename=suggested_name, pipeline_progress=pipe_progress, ) downloaded_path = getattr(dl_res, "path", None) if downloaded_path is None: return None downloaded_path = Path(str(downloaded_path)) # Guard: if we got an HTML error/redirect page, treat as failure. try: if downloaded_path.exists(): size = downloaded_path.stat().st_size if (size > 0 and size <= 250_000 and downloaded_path.suffix.lower() not in (".html", ".htm")): head = downloaded_path.read_bytes()[:512] try: text = head.decode("utf-8", errors="ignore").lower() except Exception: text = "" if " Iterable[Dict[str, Any]]: """Flatten AllDebrid magnet file tree into file dicts, preserving relative paths. API commonly returns: - file: {n: name, s: size, l: link} - folder: {n: name, e: [sub_items]} This flattener attaches a best-effort relative path to each yielded file node as `_relpath` using POSIX separators (e.g., "Season 1/E01.mkv"). Some call sites in this repo also expect {name, size, link}, so we accept both. """ prefix = list(_prefix or []) if not items: return if isinstance(items, dict): items = [items] if not isinstance(items, list): return for node in items: if not isinstance(node, dict): continue children = node.get("e") or node.get("children") if isinstance(children, list): folder_name = node.get("n") or node.get("name") next_prefix = prefix if isinstance(folder_name, str) and folder_name.strip(): next_prefix = prefix + [folder_name.strip()] yield from AllDebrid._flatten_files(children, _prefix=next_prefix) continue name = node.get("n") or node.get("name") link = node.get("l") or node.get("link") if isinstance(name, str) and name.strip() and isinstance(link, str) and link.strip(): rel_parts = prefix + [name.strip()] relpath = "/".join([p for p in rel_parts if p]) enriched = dict(node) enriched["_relpath"] = relpath yield enriched def search( self, query: str, limit: int = 50, filters: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> List[SearchResult]: q = (query or "").strip() if not q: return [] api_key = _get_debrid_api_key(self.config or {}) if not api_key: return [] view = None if isinstance(filters, dict): view = str(filters.get("view") or "").strip().lower() or None view = view or "folders" try: from API.alldebrid import AllDebridClient client = AllDebridClient(api_key) except Exception as exc: log(f"[alldebrid] Failed to init client: {exc}", file=sys.stderr) return [] q_lower = q.lower() needle = "" if q_lower in {"*", "all", "list"} else q_lower # Second-stage: list files for a specific magnet id. if view == "files": magnet_id_val = None if isinstance(filters, dict): magnet_id_val = filters.get("magnet_id") if magnet_id_val is None: magnet_id_val = kwargs.get("magnet_id") try: magnet_id = int(magnet_id_val) except Exception: return [] magnet_status: Dict[str, Any] = {} try: magnet_status = client.magnet_status(magnet_id) except Exception: magnet_status = {} magnet_name = str( magnet_status.get("filename") or magnet_status.get("name") or magnet_status.get("hash") or f"magnet-{magnet_id}" ) status_code = magnet_status.get("statusCode") status_text = str(magnet_status.get("status") or "").strip() or "unknown" ready = status_code == 4 or bool(magnet_status.get("ready")) if not ready: return [ SearchResult( table="alldebrid", title=magnet_name, path=f"alldebrid:magnet:{magnet_id}", detail=status_text, annotations=["folder", "not-ready"], media_kind="folder", tag={"alldebrid", "folder", str(magnet_id), "not-ready"}, columns=[ ("Folder", magnet_name), ("ID", str(magnet_id)), ("Status", status_text), ("Ready", "no"), ], full_metadata={ "magnet": magnet_status, "magnet_id": magnet_id, "provider": "alldebrid", "provider_view": "files", "magnet_name": magnet_name, }, ) ] try: files_result = client.magnet_links([magnet_id]) magnet_files = ( files_result.get(str(magnet_id), {}) if isinstance(files_result, dict) else {} ) file_tree = magnet_files.get("files", []) if isinstance(magnet_files, dict) else [] except Exception as exc: log( f"[alldebrid] Failed to list files for magnet {magnet_id}: {exc}", file=sys.stderr, ) file_tree = [] results: List[SearchResult] = [] for file_node in self._flatten_files(file_tree): file_name = str(file_node.get("n") or file_node.get("name") or "").strip() file_url = str(file_node.get("l") or file_node.get("link") or "").strip() relpath = str(file_node.get("_relpath") or file_name or "").strip() file_size = file_node.get("s") or file_node.get("size") if not file_name or not file_url: continue if needle and needle not in file_name.lower(): continue size_bytes: Optional[int] = None try: if isinstance(file_size, (int, float)): size_bytes = int(file_size) elif isinstance(file_size, str) and file_size.isdigit(): size_bytes = int(file_size) except Exception: size_bytes = None results.append( SearchResult( table="alldebrid", title=file_name, path=file_url, detail=magnet_name, annotations=["file"], media_kind="file", size_bytes=size_bytes, tag={"alldebrid", "file", str(magnet_id)}, columns=[ ("File", file_name), ("Folder", magnet_name), ("ID", str(magnet_id)), ], full_metadata={ "magnet": magnet_status, "magnet_id": magnet_id, "magnet_name": magnet_name, "relpath": relpath, "file": file_node, "provider": "alldebrid", "provider_view": "files", }, ) ) if len(results) >= max(1, limit): break return results # Default: folders view (magnets) try: magnets = client.magnet_list() or [] except Exception as exc: log(f"[alldebrid] Failed to list account magnets: {exc}", file=sys.stderr) return [] wanted_id: Optional[int] = None if needle.isdigit(): try: wanted_id = int(needle) except Exception: wanted_id = None results: List[SearchResult] = [] for magnet in magnets: if not isinstance(magnet, dict): continue try: magnet_id = int(magnet.get("id")) except Exception: continue magnet_name = str( magnet.get("filename") or magnet.get("name") or magnet.get("hash") or f"magnet-{magnet_id}" ) magnet_name_lower = magnet_name.lower() status_text = str(magnet.get("status") or "").strip() or "unknown" status_code = magnet.get("statusCode") ready = status_code == 4 or bool(magnet.get("ready")) if wanted_id is not None: if magnet_id != wanted_id: continue elif needle and (needle not in magnet_name_lower): continue size_bytes: Optional[int] = None try: size_val = magnet.get("size") if isinstance(size_val, (int, float)): size_bytes = int(size_val) elif isinstance(size_val, str) and size_val.isdigit(): size_bytes = int(size_val) except Exception: size_bytes = None results.append( SearchResult( table="alldebrid", title=magnet_name, path=f"alldebrid:magnet:{magnet_id}", detail=status_text, annotations=["folder"], media_kind="folder", size_bytes=size_bytes, tag={"alldebrid", "folder", str(magnet_id)} | ({"ready"} if ready else {"not-ready"}), columns=[ ("Folder", magnet_name), ("ID", str(magnet_id)), ("Status", status_text), ("Ready", "yes" if ready else "no"), ], full_metadata={ "magnet": magnet, "magnet_id": magnet_id, "provider": "alldebrid", "provider_view": "folders", "magnet_name": magnet_name, }, ) ) if len(results) >= max(1, limit): break return results def selector( self, selected_items: List[Any], *, ctx: Any, stage_is_last: bool = True, **_kwargs: Any, ) -> bool: """Handle AllDebrid `@N` selection by drilling into magnet files.""" if not stage_is_last: return False def _as_payload(item: Any) -> Dict[str, Any]: if isinstance(item, dict): return dict(item) try: if hasattr(item, "to_dict"): maybe = item.to_dict() # type: ignore[attr-defined] if isinstance(maybe, dict): return maybe except Exception: pass payload: Dict[str, Any] = {} try: payload = { "title": getattr(item, "title", None), "path": getattr(item, "path", None), "table": getattr(item, "table", None), "annotations": getattr(item, "annotations", None), "media_kind": getattr(item, "media_kind", None), "full_metadata": getattr(item, "full_metadata", None), } except Exception: payload = {} return payload chosen: List[Dict[str, Any]] = [] for item in selected_items or []: payload = _as_payload(item) meta = payload.get("full_metadata") or payload.get("metadata") or {} if not isinstance(meta, dict): meta = {} ann_set: set[str] = set() for ann_source in (payload.get("annotations"), meta.get("annotations")): if isinstance(ann_source, (list, tuple, set)): for ann in ann_source: ann_text = str(ann or "").strip().lower() if ann_text: ann_set.add(ann_text) media_kind = str(payload.get("media_kind") or meta.get("media_kind") or "").strip().lower() is_folder = (media_kind == "folder") or ("folder" in ann_set) magnet_id = meta.get("magnet_id") if magnet_id is None or (not is_folder): continue title = str(payload.get("title") or meta.get("magnet_name") or meta.get("name") or "").strip() if not title: title = f"magnet-{magnet_id}" chosen.append({ "magnet_id": magnet_id, "title": title, }) if not chosen: return False target = chosen[0] magnet_id = target.get("magnet_id") title = target.get("title") or f"magnet-{magnet_id}" try: files = self.search("*", limit=200, filters={"view": "files", "magnet_id": magnet_id}) except Exception as exc: print(f"alldebrid selector failed: {exc}\n") return True try: from SYS.result_table import ResultTable from SYS.rich_display import stdout_console except Exception: return True table = ResultTable(f"AllDebrid Files: {title}").set_preserve_order(True) table.set_table("alldebrid") try: table.set_table_metadata({"provider": "alldebrid", "view": "files", "magnet_id": magnet_id}) except Exception: pass table.set_source_command( "search-file", ["-provider", "alldebrid", "-open", str(magnet_id), "-query", "*"], ) results_payload: List[Dict[str, Any]] = [] for r in files or []: table.add_result(r) try: results_payload.append(r.to_dict()) except Exception: results_payload.append( { "table": getattr(r, "table", "alldebrid"), "title": getattr(r, "title", ""), "path": getattr(r, "path", ""), "full_metadata": getattr(r, "full_metadata", None), } ) try: ctx.set_last_result_table(table, results_payload) ctx.set_current_stage_table(table) except Exception: pass try: stdout_console().print() stdout_console().print(table) except Exception: pass return True