diff --git a/Provider/__init__.py b/Provider/__init__.py index 6c8aad4..bec937c 100644 --- a/Provider/__init__.py +++ b/Provider/__init__.py @@ -3,3 +3,9 @@ Concrete provider implementations live in this package. The public entrypoint/registry is ProviderCore.registry. """ + +# Register providers with the strict ResultTable adapter system +try: + from . import alldebrid +except Exception: + pass diff --git a/Provider/alldebrid.py b/Provider/alldebrid.py index bb2c9e9..3882ea0 100644 --- a/Provider/alldebrid.py +++ b/Provider/alldebrid.py @@ -544,6 +544,7 @@ def adjust_output_dir_for_alldebrid( class AllDebrid(Provider): # Magnet URIs should be routed through this provider. TABLE_AUTO_STAGES = {"alldebrid": ["download-file"]} + AUTO_STAGE_USE_SELECTION_ARGS = True URL = ("magnet:",) URL_DOMAINS = () @@ -818,6 +819,29 @@ class AllDebrid(Provider): path_from_result: Callable[[Any], Path], config: Optional[Dict[str, Any]] = None, ) -> int: + # Check if this is a direct magnet_id from the account (e.g., from selector) + full_metadata = getattr(result, "full_metadata", None) or {} + if isinstance(full_metadata, dict): + magnet_id_direct = full_metadata.get("magnet_id") + if magnet_id_direct is not None: + try: + magnet_id = int(magnet_id_direct) + debug(f"[download_items] Found magnet_id {magnet_id} in metadata, downloading files directly") + cfg = config if isinstance(config, dict) else (self.config or {}) + count = self._download_magnet_by_id( + magnet_id, + output_dir, + cfg, + emit, + progress, + quiet_mode, + path_from_result, + ) + debug(f"[download_items] _download_magnet_by_id returned {count}") + return count + except Exception as e: + debug(f"[download_items] Failed to download by magnet_id: {e}") + spec = self._resolve_magnet_spec_from_result(result) if not spec: return 0 @@ -885,6 +909,97 @@ class AllDebrid(Provider): enriched["_relpath"] = relpath yield enriched + def _download_magnet_by_id( + self, + magnet_id: int, + output_dir: Path, + config: Dict[str, Any], + emit: Callable[[Path, str, str, Dict[str, Any]], None], + progress: Any, + quiet_mode: bool, + path_from_result: Callable[[Any], Path], + ) -> int: + """Download files from an existing magnet ID (already in account).""" + api_key = _get_debrid_api_key(config or {}) + if not api_key: + log("AllDebrid API key not configured", file=sys.stderr) + return 0 + + try: + client = AllDebridClient(api_key) + except Exception as exc: + log(f"Failed to init AllDebrid client: {exc}", file=sys.stderr) + return 0 + + try: + files_result = client.magnet_links([magnet_id]) + except Exception as exc: + log(f"Failed to list files for magnet {magnet_id}: {exc}", file=sys.stderr) + return 0 + + 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} has no files", file=sys.stderr) + return 0 + + downloaded = 0 + for node in self._flatten_files(file_nodes): + locked_url = str(node.get("l") or node.get("link") or "").strip() + file_name = str(node.get("n") or node.get("name") or "").strip() + relpath = str(node.get("_relpath") or file_name or "").strip() + + if not locked_url or not relpath: + continue + + # Unlock the URL if it's restricted (contains /f/) + file_url = locked_url + if "/f/" in locked_url: + try: + unlocked = client.unlock_link(locked_url) + if unlocked: + file_url = unlocked + debug(f"[alldebrid] Unlocked restricted link for {file_name}") + else: + debug(f"[alldebrid] Failed to unlock {locked_url}, trying locked URL") + except Exception as exc: + debug(f"[alldebrid] unlock_link failed: {exc}, trying locked URL") + + target_path = output_dir + rel_path_obj = Path(relpath) + if rel_path_obj.parent: + target_path = output_dir / rel_path_obj.parent + try: + target_path.mkdir(parents=True, exist_ok=True) + except Exception: + target_path = output_dir + + try: + result_obj = _download_direct_file( + file_url, + target_path, + quiet=quiet_mode, + suggested_filename=rel_path_obj.name, + pipeline_progress=progress, + ) + except Exception as exc: + debug(f"Failed to download {file_url}: {exc}") + continue + + downloaded_path = path_from_result(result_obj) + metadata = { + "magnet_id": magnet_id, + "relpath": relpath, + "name": file_name, + } + emit(downloaded_path, file_url, relpath, metadata) + downloaded += 1 + + if downloaded == 0: + log(f"AllDebrid magnet {magnet_id} produced no downloads", file=sys.stderr) + + return downloaded + def search( self, query: str, @@ -1032,10 +1147,9 @@ class AllDebrid(Provider): "file": file_node, "provider": "alldebrid", "provider_view": "files", + "_selection_args": ["-magnet-id", str(magnet_id)], + "_selection_action": ["download-file", "-provider", "alldebrid", "-magnet-id", str(magnet_id)], } - if file_url: - metadata["_selection_args"] = ["-url", file_url] - metadata["_selection_action"] = ["download-file", "-url", file_url] results.append( SearchResult( @@ -1147,6 +1261,9 @@ class AllDebrid(Provider): "provider": "alldebrid", "provider_view": "folders", "magnet_name": magnet_name, + # Selection metadata: allow @N expansion to drive downloads directly + "_selection_args": ["-magnet-id", str(magnet_id)], + "_selection_action": ["download-file", "-provider", "alldebrid", "-magnet-id", str(magnet_id)], }, ) ) @@ -1247,10 +1364,7 @@ class AllDebrid(Provider): 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", "*"], - ) + table.set_source_command("download-file", ["-provider", "alldebrid"]) results_payload: List[Dict[str, Any]] = [] for r in files or []: @@ -1280,3 +1394,177 @@ class AllDebrid(Provider): pass return True + + +try: + from SYS.result_table_adapters import register_provider + from SYS.result_table_api import ColumnSpec, ResultModel, metadata_column, title_column + + def _as_payload(item: Any) -> Dict[str, Any]: + if isinstance(item, dict): + return dict(item) + try: + if hasattr(item, "to_dict"): + result = item.to_dict() # type: ignore[attr-defined] + if isinstance(result, dict): + return result + except Exception: + pass + payload: Dict[str, Any] = {} + for attr in ("title", "path", "columns", "full_metadata", "table", "source", "size_bytes", "size", "ext"): + try: + val = getattr(item, attr, None) + except Exception: + val = None + if val is not None: + payload.setdefault(attr, val) + return payload + + + def _coerce_size(value: Any) -> Optional[int]: + if value is None: + return None + if isinstance(value, (int, float)): + try: + return int(value) + except Exception: + return None + try: + return int(float(str(value).strip())) + except Exception: + return None + + + def _normalize_columns(columns: Any, metadata: Dict[str, Any]) -> None: + if not isinstance(columns, list): + return + for entry in columns: + if not isinstance(entry, (list, tuple)) or len(entry) < 2: + continue + key, value = entry[0], entry[1] + if not key: + continue + normalized = str(key).replace(" ", "_").strip().lower() + if not normalized: + continue + metadata.setdefault(normalized, value) + + + def _convert_to_model(item: Any) -> ResultModel: + payload = _as_payload(item) + title = str(payload.get("title") or payload.get("name") or "").strip() + if not title: + candidate = payload.get("path") or payload.get("detail") or payload.get("magnet_name") + title = str(candidate or "").strip() + if not title: + title = "alldebrid" + + path_val = payload.get("path") + if path_val is not None and not isinstance(path_val, str): + try: + path_val = str(path_val) + except Exception: + path_val = None + + size_bytes = _coerce_size(payload.get("size_bytes") or payload.get("size") or payload.get("file_size")) + metadata: Dict[str, Any] = {} + full_metadata = payload.get("full_metadata") + if isinstance(full_metadata, dict): + metadata.update(full_metadata) + _normalize_columns(payload.get("columns"), metadata) + + table_name = str(payload.get("table") or payload.get("source") or "alldebrid").strip().lower() + if table_name: + metadata.setdefault("table", table_name) + metadata.setdefault("source", table_name) + metadata.setdefault("provider", table_name) + + ext = payload.get("ext") + if not ext and isinstance(path_val, str): + try: + suffix = Path(path_val).suffix + if suffix: + ext = suffix.lstrip(".") + except Exception: + ext = None + + return ResultModel( + title=title, + path=path_val, + ext=str(ext) if ext is not None else None, + size_bytes=size_bytes, + metadata=metadata, + source="alldebrid", + ) + + + def _adapter(items: Iterable[Any]) -> Iterable[ResultModel]: + for item in items or []: + try: + model = _convert_to_model(item) + except Exception: + continue + yield model + + + def _has_metadata(rows: List[ResultModel], key: str) -> bool: + for row in rows: + md = row.metadata or {} + if key in md: + val = md[key] + if val is None: + continue + if isinstance(val, str) and not val.strip(): + continue + return True + return False + + + def _columns_factory(rows: List[ResultModel]) -> List[ColumnSpec]: + cols = [title_column()] + if _has_metadata(rows, "magnet_name"): + cols.append(metadata_column("magnet_name", "Magnet")) + if _has_metadata(rows, "magnet_id"): + cols.append(metadata_column("magnet_id", "Magnet ID")) + if _has_metadata(rows, "status"): + cols.append(metadata_column("status", "Status")) + if _has_metadata(rows, "ready"): + cols.append(metadata_column("ready", "Ready")) + if _has_metadata(rows, "relpath"): + cols.append(metadata_column("relpath", "Relpath")) + if _has_metadata(rows, "provider_view"): + cols.append(metadata_column("provider_view", "View")) + if _has_metadata(rows, "size"): + cols.append(metadata_column("size", "Size")) + return cols + + + def _selection_fn(row: ResultModel) -> List[str]: + metadata = row.metadata or {} + action = metadata.get("_selection_action") or metadata.get("selection_action") + if isinstance(action, (list, tuple)) and action: + return [str(x) for x in action if x is not None] + args = metadata.get("_selection_args") or metadata.get("selection_args") + if isinstance(args, (list, tuple)) and args: + return [str(x) for x in args if x is not None] + view = metadata.get("provider_view") or metadata.get("view") or "" + if view == "files": + if row.path: + return ["-url", row.path] + magnet_id = metadata.get("magnet_id") + if magnet_id is not None: + return ["-magnet-id", str(magnet_id)] + if row.path: + return ["-url", row.path] + return ["-title", row.title or ""] + + + register_provider( + "alldebrid", + _adapter, + columns=_columns_factory, + selection_fn=_selection_fn, + metadata={"description": "AllDebrid account provider"}, + ) +except Exception: + pass diff --git a/cmdlet/download_file.py b/cmdlet/download_file.py index befefe9..1631934 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/download_file.py @@ -86,6 +86,11 @@ class Download_File(Cmdlet): alias="a", description="Download audio only (yt-dlp)", ), + CmdletArg( + name="-magnet-id", + type="string", + description="(internal) AllDebrid magnet id used by provider selection hooks", + ), CmdletArg( name="format", type="string", @@ -3789,6 +3794,94 @@ class Download_File(Cmdlet): registry = self._load_provider_registry() downloaded_count = 0 + + # Special-case: support selection-inserted magnet-id arg to drive provider downloads + magnet_id_raw = parsed.get("magnet-id") + if magnet_id_raw: + try: + magnet_id = int(str(magnet_id_raw).strip()) + except Exception: + log(f"[download-file] invalid magnet-id: {magnet_id_raw}", file=sys.stderr) + return 1 + + get_provider = registry.get("get_provider") + provider_name = str(parsed.get("provider") or "alldebrid").strip().lower() + provider_obj = None + if get_provider is not None: + try: + provider_obj = get_provider(provider_name, config) + except Exception: + provider_obj = None + + if provider_obj is None: + log(f"[download-file] provider '{provider_name}' not available", file=sys.stderr) + return 1 + + SearchResult = registry.get("SearchResult") + try: + if SearchResult is not None: + sr = SearchResult( + table=provider_name, + title=f"magnet-{magnet_id}", + path=f"alldebrid:magnet:{magnet_id}", + full_metadata={ + "magnet_id": magnet_id, + "provider": provider_name, + "provider_view": "files", + }, + ) + else: + sr = None + except Exception: + sr = None + + def _on_emit(path: Path, file_url: str, relpath: str, metadata: Dict[str, Any]) -> None: + title_hint = metadata.get("name") or relpath or f"magnet-{magnet_id}" + self._emit_local_file( + downloaded_path=path, + source=file_url or f"alldebrid:magnet:{magnet_id}", + title_hint=title_hint, + tags_hint=None, + media_kind_hint="file", + full_metadata=metadata, + progress=progress, + config=config, + provider_hint=provider_name, + ) + + try: + downloaded_extra = provider_obj.download_items( + sr, + final_output_dir, + emit=_on_emit, + progress=progress, + quiet_mode=quiet_mode, + path_from_result=self._path_from_download_result, + config=config, + ) + except TypeError: + downloaded_extra = provider_obj.download_items( + sr, + final_output_dir, + emit=_on_emit, + progress=progress, + quiet_mode=quiet_mode, + path_from_result=self._path_from_download_result, + ) + except Exception as exc: + log(f"[download-file] failed to download magnet {magnet_id}: {exc}", file=sys.stderr) + return 1 + + if downloaded_extra: + debug(f"[download-file] AllDebrid magnet {magnet_id} emitted {downloaded_extra} files") + return 0 + + log( + f"[download-file] AllDebrid magnet {magnet_id} produced no downloads", + file=sys.stderr, + ) + return 1 + urls_downloaded, early_exit = self._process_explicit_urls( raw_urls=raw_url, final_output_dir=final_output_dir,