updated plugin refactor and added FTP and SCP plugins , also hydrusnetwork plugin migration

This commit is contained in:
2026-04-27 21:17:53 -07:00
parent bfd5c20dc3
commit 8685fbb723
24 changed files with 3650 additions and 405 deletions
+9 -1
View File
@@ -48,4 +48,12 @@ class MyPlugin(Provider):
path=f"https://example.com/{text}",
)
]
```
```
Bundled walkthrough:
- The repo now includes a real FTP example plugin in [plugins/ftp/__init__.py](plugins/ftp/__init__.py).
- The walkthrough is in [docs/ftp_plugin_tutorial.md](docs/ftp_plugin_tutorial.md) and shows `search-file -plugin ftp`, folder drill-in via `@N`, file download routing, `@N | add-file -store ...`, and `add-file -plugin ftp` uploads.
- The repo also includes an SCP example plugin in [plugins/scp/__init__.py](plugins/scp/__init__.py).
- The walkthrough is in [docs/scp_plugin_tutorial.md](docs/scp_plugin_tutorial.md) and shows `search-file -plugin scp`, SSH-backed directory drill-in, file download routing, `@N | add-file -store ...`, and `add-file -plugin scp` uploads.
- The repo now also includes a built-in HydrusNetwork provider in [plugins/hydrusnetwork/__init__.py](plugins/hydrusnetwork/__init__.py). It delegates to configured `store.hydrusnetwork.*` backends so Hydrus features can be reached through the normal plugin registry without cmdlets importing Hydrus modules directly.
+57 -10
View File
@@ -23,24 +23,56 @@ from SYS.models import DownloadError, PipeObject
_HOSTS_CACHE_TTL_SECONDS = 24 * 60 * 60
def _repo_root() -> Path:
def _plugin_dir() -> Path:
try:
return Path(__file__).resolve().parents[1]
return Path(__file__).resolve().parent
except Exception:
return Path(".")
def _legacy_hosts_cache_paths() -> Tuple[Path, ...]:
try:
repo_root = Path(__file__).resolve().parents[2]
plugins_root = Path(__file__).resolve().parents[1]
except Exception:
return tuple()
return (
plugins_root / "API" / "data" / "alldebrid.json",
repo_root / "API" / "data" / "alldebrid.json",
)
def _hosts_cache_path() -> Path:
# Keep this local to the repo so it works in portable installs.
# The registry's URL routing can read this file without instantiating providers.
# Keep this local to the plugin so plugin-specific cache/state stays bundled
# with the plugin itself in portable installs.
#
# This file is expected to be the JSON payload shape from AllDebrid:
# {"status":"success","data":{"hosts":[...],"streams":[...],"redirectors":[...]}}
return _repo_root() / "API" / "data" / "alldebrid.json"
return _plugin_dir() / "alldebrid.json"
def _resolve_hosts_cache_path() -> Path:
path = _hosts_cache_path()
try:
if path.exists() and path.is_file():
return path
except Exception:
return path
for legacy in _legacy_hosts_cache_paths():
try:
if not legacy.exists() or not legacy.is_file():
continue
path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(legacy, path)
return path
except Exception:
continue
return path
def _load_cached_domains(category: str) -> List[str]:
"""Load cached domain list from API/data/alldebrid.json.
"""Load cached domain list from the plugin-local alldebrid.json cache.
category: "hosts" | "streams" | "redirectors"
"""
@@ -49,7 +81,7 @@ def _load_cached_domains(category: str) -> List[str]:
if wanted not in {"hosts", "streams", "redirectors"}:
return []
path = _hosts_cache_path()
path = _resolve_hosts_cache_path()
try:
if not path.exists() or not path.is_file():
return []
@@ -68,12 +100,27 @@ def _load_cached_domains(category: str) -> List[str]:
return []
raw_list = data.get(wanted)
if not isinstance(raw_list, list):
if not isinstance(raw_list, (list, dict)):
return []
out: List[str] = []
seen: set[str] = set()
for d in raw_list:
domain_candidates: List[Any] = []
if isinstance(raw_list, list):
domain_candidates.extend(raw_list)
else:
for entry in raw_list.values():
if isinstance(entry, dict):
nested_domains = entry.get("domains")
if isinstance(nested_domains, list):
domain_candidates.extend(nested_domains)
elif isinstance(nested_domains, str):
domain_candidates.append(nested_domains)
elif isinstance(entry, str):
domain_candidates.append(entry)
for d in domain_candidates:
try:
dom = str(d or "").strip().lower()
except Exception:
@@ -115,7 +162,7 @@ def _save_cached_hosts_payload(payload: Dict[str, Any]) -> None:
def _cache_is_fresh() -> bool:
path = _hosts_cache_path()
path = _resolve_hosts_cache_path()
try:
if not path.exists() or not path.is_file():
return False
File diff suppressed because one or more lines are too long
+778
View File
@@ -0,0 +1,778 @@
from __future__ import annotations
import fnmatch
import ftplib
import posixpath
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote, unquote, urlparse
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments
def _pick_provider_config(config: Any) -> Dict[str, Any]:
if not isinstance(config, dict):
return {}
provider = config.get("provider")
if not isinstance(provider, dict):
return {}
entry = provider.get("ftp")
if isinstance(entry, dict):
return entry
return {}
def _coerce_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
text = str(value).strip().lower()
if not text:
return default
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return default
def _coerce_int(value: Any, default: int) -> int:
try:
return int(value)
except Exception:
return default
def _format_timestamp(raw_value: Any) -> str:
text = str(raw_value or "").strip()
if not text:
return ""
for pattern in ("%Y%m%d%H%M%S", "%Y%m%d%H%M%S.%f"):
try:
parsed = datetime.strptime(text, pattern)
return parsed.strftime("%Y-%m-%d %H:%M")
except Exception:
continue
return text
def _safe_filename(name: Any) -> str:
raw = str(name or "").strip()
if not raw:
raw = "download"
cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_", ".", " "} else "_" for ch in raw)
cleaned = cleaned.strip(" ._")
return cleaned or "download"
def _unique_path(path: Path) -> Path:
if not path.exists():
return path
stem = path.stem or "download"
suffix = path.suffix
counter = 1
while True:
candidate = path.with_name(f"{stem}_{counter}{suffix}")
if not candidate.exists():
return candidate
counter += 1
class FTP(Provider):
PLUGIN_NAME = "ftp"
URL = ("ftp://", "ftps://")
@property
def label(self) -> str:
return "FTP"
@property
def preserve_order(self) -> bool:
return True
@classmethod
def config_schema(cls) -> List[Dict[str, Any]]:
return [
{
"key": "host",
"label": "Host",
"default": "",
"required": True,
"placeholder": "ftp.example.com",
},
{
"key": "port",
"label": "Port",
"type": "integer",
"default": 21,
},
{
"key": "username",
"label": "Username",
"default": "anonymous",
},
{
"key": "password",
"label": "Password",
"type": "secret",
"secret": True,
"default": "",
},
{
"key": "base_path",
"label": "Base Path",
"default": "/",
"placeholder": "/incoming",
},
{
"key": "tls",
"label": "Use FTPS",
"type": "boolean",
"default": False,
},
{
"key": "passive",
"label": "Passive Mode",
"type": "boolean",
"default": True,
},
{
"key": "timeout",
"label": "Timeout Seconds",
"type": "integer",
"default": 20,
},
{
"key": "search_depth",
"label": "Default Search Depth",
"type": "integer",
"default": 1,
},
]
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
conf = _pick_provider_config(self.config)
self._host = str(conf.get("host") or "").strip()
self._tls = _coerce_bool(conf.get("tls"), False)
self._port = _coerce_int(conf.get("port"), 21)
self._username = str(conf.get("username") or conf.get("user") or "anonymous").strip() or "anonymous"
password_value = conf.get("password")
self._password = str(password_value).strip() if password_value not in (None, "") else "anonymous@"
self._passive = _coerce_bool(conf.get("passive"), True)
self._timeout = max(1, _coerce_int(conf.get("timeout"), 20))
self._search_depth = max(0, _coerce_int(conf.get("search_depth"), 1))
self._base_path = self._normalize_remote_path(conf.get("base_path") or "/", default="/")
def validate(self) -> bool:
return bool(self._host)
def config_helper_text(self) -> str:
return "Test the configured FTP/FTPS settings before searching or uploading."
def config_actions(self) -> List[Dict[str, Any]]:
return [
{
"id": "test_connection",
"label": "Test connection",
"variant": "primary",
}
]
def run_config_action(self, action_id: str, **_kwargs: Any) -> Dict[str, Any]:
if str(action_id or "").strip().lower() != "test_connection":
return super().run_config_action(action_id, **_kwargs)
if not self._host:
return {"ok": False, "message": "Set 'host' before testing the FTP connection."}
ftp = None
try:
ftp = self._connect()
active_path = self._base_path or "/"
try:
ftp.cwd(active_path)
resolved_path = ftp.pwd()
except Exception:
resolved_path = active_path
return {
"ok": True,
"message": f"Connected to FTP {self._host}:{self._port} and reached {resolved_path}.",
}
except Exception as exc:
return {"ok": False, "message": f"FTP connection failed: {exc}"}
finally:
self._close(ftp)
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
text, inline = parse_inline_query_arguments(query)
filters: Dict[str, Any] = {}
if inline.get("path"):
filters["path"] = inline.get("path")
if inline.get("depth"):
filters["depth"] = max(0, _coerce_int(inline.get("depth"), self._search_depth))
if inline.get("type"):
filters["type"] = str(inline.get("type") or "").strip().lower()
return text, filters
def get_table_title(self, query: str, filters: Optional[Dict[str, Any]] = None) -> str:
active_path = self._normalize_remote_path((filters or {}).get("path") or self._base_path, default=self._base_path)
text = str(query or "").strip()
if not text or text == "*":
return f"FTP: {active_path}"
return f"FTP: {text} @ {active_path}"
def get_table_metadata(self, query: str, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return {
"plugin": self.name,
"host": self._host,
"path": self._normalize_remote_path((filters or {}).get("path") or self._base_path, default=self._base_path),
"query": str(query or "").strip(),
}
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> List[SearchResult]:
_ = kwargs
active_filters = dict(filters or {})
start_path = self._normalize_remote_path(active_filters.get("path") or self._base_path, default=self._base_path)
search_depth = max(0, _coerce_int(active_filters.get("depth"), self._search_depth))
type_filter = str(active_filters.get("type") or "any").strip().lower()
needle = str(query or "").strip()
max_results = max(0, int(limit or 0))
if max_results <= 0:
return []
ftp = self._connect()
try:
return self._search_directory(
ftp,
start_path,
needle=needle,
limit=max_results,
search_depth=search_depth,
type_filter=type_filter,
)
finally:
self._close(ftp)
def selector(
self,
selected_items: List[Any],
*,
ctx: Any,
stage_is_last: bool = True,
**_kwargs: Any,
) -> bool:
if not stage_is_last:
return False
target_path = ""
target_title = ""
for item in selected_items or []:
metadata = self._item_metadata(item)
if not metadata.get("is_dir"):
continue
target_path = self._normalize_remote_path(metadata.get("ftp_path") or metadata.get("selection_path"), default=self._base_path)
target_title = str(metadata.get("title") or metadata.get("name") or "").strip()
if target_path:
break
if not target_path:
return False
ftp = self._connect()
try:
rows = self._search_directory(
ftp,
target_path,
needle="*",
limit=500,
search_depth=0,
type_filter="any",
)
finally:
self._close(ftp)
try:
from SYS.result_table import Table
from SYS.rich_display import stdout_console
except Exception:
return True
title = target_title or target_path
table = Table(f"FTP: {title}")._perseverance(True)
table.set_table("ftp")
try:
table.set_table_metadata({
"provider": "ftp",
"host": self._host,
"path": target_path,
"view": "directory",
})
except Exception:
pass
table.set_source_command("search-file", ["-plugin", "ftp", f"path:{target_path}", "*"])
payloads: List[Dict[str, Any]] = []
for row in rows:
table.add_result(row)
payloads.append(row.to_dict())
try:
ctx.set_last_result_table(table, payloads, subject={"plugin": "ftp", "path": target_path})
ctx.set_current_stage_table(table)
except Exception:
pass
try:
stdout_console().print()
stdout_console().print(table)
except Exception:
pass
return True
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
metadata = getattr(result, "full_metadata", None)
if isinstance(metadata, dict) and metadata.get("is_dir"):
return None
target = str(getattr(result, "path", "") or "").strip()
if not target:
return None
return self.download_url(target, output_dir, title=getattr(result, "title", None))
def download_url(self, url: str, output_dir: Path, **kwargs: Any) -> Optional[Path]:
settings = self._connection_settings_for_url(url)
remote_path = settings["path"]
if not remote_path or remote_path == "/":
return None
filename_hint = str(kwargs.get("title") or "").strip()
parsed_name = posixpath.basename(remote_path.rstrip("/"))
filename = _safe_filename(filename_hint or unquote(parsed_name) or "download")
destination_dir = Path(output_dir)
destination_dir.mkdir(parents=True, exist_ok=True)
destination = _unique_path(destination_dir / filename)
ftp = self._connect(
host=settings["host"],
port=settings["port"],
username=settings["username"],
password=settings["password"],
tls=settings["tls"],
)
try:
with destination.open("wb") as handle:
ftp.retrbinary(f"RETR {remote_path}", handle.write)
return destination
except Exception:
try:
destination.unlink(missing_ok=True)
except Exception:
pass
return None
finally:
self._close(ftp)
def resolve_pipe_result_download(
self,
result: Any,
pipe_obj: Any,
) -> Tuple[Optional[Path], Optional[str], Optional[Path]]:
metadata = self._item_metadata(result, pipe_obj=pipe_obj)
if metadata.get("is_dir"):
return None, None, None
download_url = str(
metadata.get("selection_url")
or metadata.get("ftp_url")
or metadata.get("path")
or ""
).strip()
if not download_url.startswith(("ftp://", "ftps://")):
return None, None, None
temp_dir = Path(tempfile.mkdtemp(prefix="ftp-add-file-"))
downloaded = self.download_url(download_url, temp_dir, title=metadata.get("title"))
if downloaded is None:
try:
temp_dir.rmdir()
except Exception:
pass
return None, None, None
try:
if pipe_obj is not None:
pipe_obj.is_temp = True
except Exception:
pass
return downloaded, None, temp_dir
def upload(self, file_path: str, **kwargs: Any) -> str:
local_path = Path(str(file_path or "")).expanduser()
if not local_path.exists() or not local_path.is_file():
raise FileNotFoundError(f"File not found: {local_path}")
remote_dir = self._normalize_remote_path(kwargs.get("remote_path") or kwargs.get("path") or self._base_path, default=self._base_path)
remote_name = posixpath.basename(str(kwargs.get("remote_name") or local_path.name).replace("\\", "/")) or local_path.name
remote_path = self._join_remote_path(remote_dir, remote_name)
ftp = self._connect()
try:
self._ensure_directory(ftp, remote_dir)
with local_path.open("rb") as handle:
ftp.storbinary(f"STOR {remote_path}", handle)
finally:
self._close(ftp)
return self._build_url(remote_path)
def _connect(
self,
*,
host: Optional[str] = None,
port: Optional[int] = None,
username: Optional[str] = None,
password: Optional[str] = None,
tls: Optional[bool] = None,
) -> ftplib.FTP:
use_tls = self._tls if tls is None else bool(tls)
ftp: ftplib.FTP = ftplib.FTP_TLS() if use_tls else ftplib.FTP()
ftp.connect(host or self._host, int(port or self._port), timeout=self._timeout)
ftp.login(username or self._username, password or self._password)
try:
ftp.set_pasv(self._passive)
except Exception:
pass
if use_tls and isinstance(ftp, ftplib.FTP_TLS):
ftp.prot_p()
return ftp
def _close(self, ftp: Optional[ftplib.FTP]) -> None:
if ftp is None:
return
try:
ftp.quit()
except Exception:
try:
ftp.close()
except Exception:
pass
def _normalize_remote_path(self, value: Any, *, default: str) -> str:
text = str(value or "").strip().replace("\\", "/")
if not text:
text = default
elif text.startswith(("ftp://", "ftps://")):
try:
text = unquote(urlparse(text).path or "/")
except Exception:
text = default
elif not text.startswith("/"):
text = posixpath.join(default, text)
normalized = posixpath.normpath(text)
normalized = "/" + normalized.lstrip("/")
return normalized or "/"
def _join_remote_path(self, parent: Any, child: Any) -> str:
left = self._normalize_remote_path(parent, default=self._base_path)
right = str(child or "").strip().replace("\\", "/")
if not right:
return left
return self._normalize_remote_path(posixpath.join(left, right), default="/")
def _build_url(
self,
remote_path: Any,
*,
host: Optional[str] = None,
port: Optional[int] = None,
tls: Optional[bool] = None,
) -> str:
path_text = self._normalize_remote_path(remote_path, default="/")
scheme = "ftps" if (self._tls if tls is None else bool(tls)) else "ftp"
host_text = str(host or self._host).strip()
port_value = int(port or self._port)
port_suffix = f":{port_value}" if port_value and port_value != 21 else ""
return f"{scheme}://{host_text}{port_suffix}{quote(path_text, safe='/-._~!$&\'()*+,;=:@')}"
def _connection_settings_for_url(self, url: str) -> Dict[str, Any]:
parsed = urlparse(str(url or "").strip())
scheme = (parsed.scheme or "ftp").strip().lower()
host = parsed.hostname or self._host
port = parsed.port or self._port
username = parsed.username or self._username
password = parsed.password or self._password
path_text = self._normalize_remote_path(unquote(parsed.path or "/"), default="/")
return {
"tls": scheme == "ftps",
"host": host,
"port": port,
"username": username,
"password": password,
"path": path_text,
}
def _search_directory(
self,
ftp: ftplib.FTP,
start_path: str,
*,
needle: str,
limit: int,
search_depth: int,
type_filter: str,
) -> List[SearchResult]:
results: List[SearchResult] = []
visited: set[str] = set()
def walk(current_path: str, depth_left: int) -> None:
normalized = self._normalize_remote_path(current_path, default=self._base_path)
if normalized in visited or len(results) >= limit:
return
visited.add(normalized)
for entry in self._list_directory(ftp, normalized):
if len(results) >= limit:
return
if self._matches_entry(entry, needle=needle, type_filter=type_filter):
results.append(self._build_result(entry))
if entry.get("is_dir") and depth_left > 0:
walk(str(entry.get("ftp_path") or normalized), depth_left - 1)
walk(start_path, max(0, search_depth))
return results
def _matches_entry(self, entry: Dict[str, Any], *, needle: str, type_filter: str) -> bool:
is_dir = bool(entry.get("is_dir"))
if type_filter in {"dir", "dirs", "folder", "folders"} and not is_dir:
return False
if type_filter in {"file", "files"} and is_dir:
return False
text = str(needle or "").strip().lower()
if not text or text in {"*", "all", "list"}:
return True
haystacks = [
str(entry.get("name") or "").lower(),
str(entry.get("ftp_path") or "").lower(),
]
for token in [part for part in text.split() if part]:
if any(ch in token for ch in "*?[]"):
if not any(fnmatch.fnmatch(haystack, token) for haystack in haystacks):
return False
elif not any(token in haystack for haystack in haystacks):
return False
return True
def _build_result(self, entry: Dict[str, Any]) -> SearchResult:
ftp_path = str(entry.get("ftp_path") or "/")
ftp_url = self._build_url(ftp_path)
is_dir = bool(entry.get("is_dir"))
size_value = entry.get("size")
modified = str(entry.get("modified") or "")
parent = posixpath.dirname(ftp_path.rstrip("/")) or "/"
metadata = {
"provider": "ftp",
"host": self._host,
"ftp_path": ftp_path,
"ftp_url": ftp_url,
"selection_url": ftp_url,
"is_dir": is_dir,
"name": str(entry.get("name") or "").strip(),
}
if size_value is not None:
metadata["size"] = size_value
if modified:
metadata["modified"] = modified
return SearchResult(
table="ftp",
title=str(entry.get("name") or ftp_path),
path=ftp_url,
detail=parent,
annotations=["folder" if is_dir else "file"],
media_kind="folder" if is_dir else "file",
size_bytes=int(size_value) if isinstance(size_value, int) else None,
tag={"ftp", "folder" if is_dir else "file"},
columns=[
("Name", str(entry.get("name") or "")),
("Type", "dir" if is_dir else "file"),
("Directory", parent),
("Size", "" if size_value is None else str(size_value)),
("Modified", modified),
],
selection_args=None if is_dir else ["-url", ftp_url],
selection_action=None if is_dir else ["download-file", "-plugin", "ftp", "-url", ftp_url],
full_metadata=metadata,
)
def _list_directory(self, ftp: ftplib.FTP, remote_path: str) -> List[Dict[str, Any]]:
normalized = self._normalize_remote_path(remote_path, default=self._base_path)
try:
entries: List[Dict[str, Any]] = []
for name, facts in ftp.mlsd(normalized):
name_text = str(name or "").strip()
if not name_text or name_text in {".", ".."}:
continue
entry_type = str((facts or {}).get("type") or "").strip().lower()
if entry_type in {"cdir", "pdir"}:
continue
size_value = None
raw_size = (facts or {}).get("size")
if raw_size not in (None, ""):
try:
size_value = int(raw_size)
except Exception:
size_value = None
entries.append(
{
"name": name_text,
"ftp_path": self._join_remote_path(normalized, name_text),
"is_dir": entry_type == "dir",
"size": size_value,
"modified": _format_timestamp((facts or {}).get("modify")),
}
)
return entries
except Exception:
return self._list_directory_fallback(ftp, normalized)
def _list_directory_fallback(self, ftp: ftplib.FTP, remote_path: str) -> List[Dict[str, Any]]:
try:
listed = ftp.nlst(remote_path)
except Exception:
return []
entries: List[Dict[str, Any]] = []
seen: set[str] = set()
for raw_entry in listed:
entry_text = str(raw_entry or "").strip()
if not entry_text:
continue
entry_path = entry_text if entry_text.startswith("/") else self._join_remote_path(remote_path, entry_text)
name_text = posixpath.basename(entry_path.rstrip("/")) or entry_path.rstrip("/")
if not name_text or name_text in {".", ".."} or name_text in seen:
continue
seen.add(name_text)
is_dir = self._is_directory(ftp, entry_path)
size_value = None
if not is_dir:
try:
size_raw = ftp.size(entry_path)
if size_raw is not None:
size_value = int(size_raw)
except Exception:
size_value = None
entries.append(
{
"name": name_text,
"ftp_path": entry_path,
"is_dir": is_dir,
"size": size_value,
"modified": self._read_modified(ftp, entry_path),
}
)
return entries
def _is_directory(self, ftp: ftplib.FTP, remote_path: str) -> bool:
current = None
try:
current = ftp.pwd()
except Exception:
current = None
try:
ftp.cwd(remote_path)
return True
except Exception:
return False
finally:
if current is not None:
try:
ftp.cwd(current)
except Exception:
pass
def _read_modified(self, ftp: ftplib.FTP, remote_path: str) -> str:
try:
response = ftp.sendcmd(f"MDTM {remote_path}")
except Exception:
return ""
parts = str(response or "").split()
if len(parts) >= 2:
return _format_timestamp(parts[1])
return ""
def _ensure_directory(self, ftp: ftplib.FTP, remote_path: str) -> None:
normalized = self._normalize_remote_path(remote_path, default=self._base_path)
if normalized == "/":
return
partial = ""
for segment in [part for part in normalized.split("/") if part]:
partial = f"{partial}/{segment}"
if self._is_directory(ftp, partial):
continue
try:
ftp.mkd(partial)
except Exception:
if not self._is_directory(ftp, partial):
raise
def _item_metadata(self, item: Any, *, pipe_obj: Any = None) -> Dict[str, Any]:
metadata: Dict[str, Any] = {}
for source in (item, pipe_obj):
if isinstance(source, dict):
for key in ("title", "path", "url"):
if source.get(key) is not None and key not in metadata:
metadata[key] = source.get(key)
nested = source.get("full_metadata") or source.get("metadata")
if isinstance(nested, dict):
metadata.update(nested)
elif source is not None:
for attr in ("title", "path", "url"):
try:
value = getattr(source, attr, None)
except Exception:
value = None
if value is not None and attr not in metadata:
metadata[attr] = value
try:
nested = getattr(source, "full_metadata", None) or getattr(source, "metadata", None)
except Exception:
nested = None
if isinstance(nested, dict):
metadata.update(nested)
ftp_path = metadata.get("ftp_path") or metadata.get("selection_path")
if not ftp_path:
path_value = metadata.get("path") or metadata.get("url") or metadata.get("ftp_url")
path_text = str(path_value or "").strip()
if path_text.startswith(("ftp://", "ftps://")):
ftp_path = self._normalize_remote_path(path_text, default=self._base_path)
if ftp_path:
metadata["ftp_path"] = self._normalize_remote_path(ftp_path, default=self._base_path)
metadata.setdefault("selection_path", metadata["ftp_path"])
if metadata.get("ftp_path") and not metadata.get("ftp_url"):
metadata["ftp_url"] = self._build_url(metadata["ftp_path"])
if metadata.get("ftp_url") and not metadata.get("selection_url"):
metadata["selection_url"] = metadata["ftp_url"]
is_dir = metadata.get("is_dir")
if is_dir is None and metadata.get("media_kind"):
is_dir = str(metadata.get("media_kind") or "").strip().lower() == "folder"
metadata["is_dir"] = bool(is_dir)
return metadata
File diff suppressed because it is too large Load Diff
+938
View File
@@ -0,0 +1,938 @@
from __future__ import annotations
import fnmatch
import posixpath
import shlex
import stat
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote, unquote, urlparse
import paramiko
from scp import SCPClient
from ProviderCore.base import Provider, SearchResult, parse_inline_query_arguments
def _pick_provider_config(config: Any) -> Dict[str, Any]:
if not isinstance(config, dict):
return {}
provider = config.get("provider")
if not isinstance(provider, dict):
return {}
entry = provider.get("scp")
if isinstance(entry, dict):
return entry
return {}
def _coerce_bool(value: Any, default: bool = False) -> bool:
if isinstance(value, bool):
return value
if value is None:
return default
text = str(value).strip().lower()
if not text:
return default
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return default
def _coerce_int(value: Any, default: int) -> int:
try:
return int(value)
except Exception:
return default
def _format_epoch(raw_value: Any) -> str:
try:
stamp = int(raw_value)
except Exception:
return ""
try:
return datetime.fromtimestamp(stamp).strftime("%Y-%m-%d %H:%M")
except Exception:
return str(raw_value or "")
def _safe_filename(name: Any) -> str:
raw = str(name or "").strip()
if not raw:
raw = "download"
cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_", ".", " "} else "_" for ch in raw)
cleaned = cleaned.strip(" ._")
return cleaned or "download"
def _unique_path(path: Path) -> Path:
if not path.exists():
return path
stem = path.stem or "download"
suffix = path.suffix
counter = 1
while True:
candidate = path.with_name(f"{stem}_{counter}{suffix}")
if not candidate.exists():
return candidate
counter += 1
class SCP(Provider):
PLUGIN_NAME = "scp"
URL = ("scp://", "sftp://")
@property
def label(self) -> str:
return "SCP"
@property
def preserve_order(self) -> bool:
return True
@classmethod
def config_schema(cls) -> List[Dict[str, Any]]:
return [
{
"key": "host",
"label": "Host",
"default": "",
"required": True,
"placeholder": "ssh.example.com",
},
{
"key": "port",
"label": "Port",
"type": "integer",
"default": 22,
},
{
"key": "username",
"label": "Username",
"default": "",
"required": True,
"placeholder": "deploy",
},
{
"key": "password",
"label": "Password",
"type": "secret",
"secret": True,
"default": "",
},
{
"key": "key_path",
"label": "SSH Key Path",
"type": "path",
"default": "",
"placeholder": "C:/Users/Admin/.ssh/id_ed25519",
},
{
"key": "base_path",
"label": "Base Path",
"default": "/",
"placeholder": "/srv/files",
},
{
"key": "timeout",
"label": "Timeout Seconds",
"type": "integer",
"default": 20,
},
{
"key": "search_depth",
"label": "Default Search Depth",
"type": "integer",
"default": 1,
},
{
"key": "allow_agent",
"label": "Use SSH Agent",
"type": "boolean",
"default": True,
},
{
"key": "look_for_keys",
"label": "Look For Default Keys",
"type": "boolean",
"default": True,
},
]
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
conf = _pick_provider_config(self.config)
self._host = str(conf.get("host") or "").strip()
self._port = _coerce_int(conf.get("port"), 22)
self._username = str(conf.get("username") or conf.get("user") or "").strip()
self._password = str(conf.get("password") or "").strip()
self._key_path = str(conf.get("key_path") or conf.get("identity_file") or "").strip()
self._timeout = max(1, _coerce_int(conf.get("timeout"), 20))
self._search_depth = max(0, _coerce_int(conf.get("search_depth"), 1))
self._allow_agent = _coerce_bool(conf.get("allow_agent"), True)
self._look_for_keys = _coerce_bool(conf.get("look_for_keys"), True)
self._base_path = self._normalize_remote_path(conf.get("base_path") or "/", default="/")
def validate(self) -> bool:
return bool(self._host and self._username)
def config_helper_text(self) -> str:
return "Test the SSH/SCP connection before searching. You can also generate an RSA key pair from here."
def config_actions(self) -> List[Dict[str, Any]]:
return [
{
"id": "test_connection",
"label": "Test connection",
"variant": "primary",
},
{
"id": "generate_ssh_key",
"label": "Generate SSH key",
"variant": "default",
},
]
def run_config_action(self, action_id: str, **_kwargs: Any) -> Dict[str, Any]:
normalized = str(action_id or "").strip().lower()
if normalized == "test_connection":
return self._run_test_connection()
if normalized == "generate_ssh_key":
return self._generate_ssh_keypair()
return super().run_config_action(action_id, **_kwargs)
def extract_query_arguments(self, query: str) -> Tuple[str, Dict[str, Any]]:
text, inline = parse_inline_query_arguments(query)
filters: Dict[str, Any] = {}
if inline.get("path"):
filters["path"] = inline.get("path")
if inline.get("depth"):
filters["depth"] = max(0, _coerce_int(inline.get("depth"), self._search_depth))
if inline.get("type"):
filters["type"] = str(inline.get("type") or "").strip().lower()
return text, filters
def get_table_title(self, query: str, filters: Optional[Dict[str, Any]] = None) -> str:
active_path = self._normalize_remote_path((filters or {}).get("path") or self._base_path, default=self._base_path)
text = str(query or "").strip()
if not text or text == "*":
return f"SCP: {active_path}"
return f"SCP: {text} @ {active_path}"
def get_table_metadata(self, query: str, filters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
return {
"plugin": self.name,
"host": self._host,
"path": self._normalize_remote_path((filters or {}).get("path") or self._base_path, default=self._base_path),
"query": str(query or "").strip(),
}
def search(
self,
query: str,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> List[SearchResult]:
_ = kwargs
active_filters = dict(filters or {})
start_path = self._normalize_remote_path(active_filters.get("path") or self._base_path, default=self._base_path)
search_depth = max(0, _coerce_int(active_filters.get("depth"), self._search_depth))
type_filter = str(active_filters.get("type") or "any").strip().lower()
needle = str(query or "").strip()
max_results = max(0, int(limit or 0))
if max_results <= 0:
return []
ssh = self._connect_ssh()
sftp = None
try:
try:
sftp = self._open_sftp(ssh)
except Exception as exc:
if not self._is_sftp_negotiation_error(exc):
raise
return self._search_directory_via_ssh(
ssh,
start_path,
needle=needle,
limit=max_results,
search_depth=search_depth,
type_filter=type_filter,
)
return self._search_directory(
sftp,
start_path,
needle=needle,
limit=max_results,
search_depth=search_depth,
type_filter=type_filter,
)
finally:
self._close_client(sftp)
self._close_client(ssh)
def selector(
self,
selected_items: List[Any],
*,
ctx: Any,
stage_is_last: bool = True,
**_kwargs: Any,
) -> bool:
if not stage_is_last:
return False
target_path = ""
target_title = ""
for item in selected_items or []:
metadata = self._item_metadata(item)
if not metadata.get("is_dir"):
continue
target_path = self._normalize_remote_path(metadata.get("scp_path") or metadata.get("selection_path"), default=self._base_path)
target_title = str(metadata.get("title") or metadata.get("name") or "").strip()
if target_path:
break
if not target_path:
return False
ssh = self._connect_ssh()
sftp = None
try:
try:
sftp = self._open_sftp(ssh)
except Exception as exc:
if not self._is_sftp_negotiation_error(exc):
raise
rows = self._search_directory_via_ssh(
ssh,
target_path,
needle="*",
limit=500,
search_depth=0,
type_filter="any",
)
else:
rows = self._search_directory(
sftp,
target_path,
needle="*",
limit=500,
search_depth=0,
type_filter="any",
)
finally:
self._close_client(sftp)
self._close_client(ssh)
try:
from SYS.result_table import Table
from SYS.rich_display import stdout_console
except Exception:
return True
title = target_title or target_path
table = Table(f"SCP: {title}")._perseverance(True)
table.set_table("scp")
try:
table.set_table_metadata({
"provider": "scp",
"host": self._host,
"path": target_path,
"view": "directory",
})
except Exception:
pass
table.set_source_command("search-file", ["-plugin", "scp", f"path:{target_path}", "*"])
payloads: List[Dict[str, Any]] = []
for row in rows:
table.add_result(row)
payloads.append(row.to_dict())
try:
ctx.set_last_result_table(table, payloads, subject={"plugin": "scp", "path": target_path})
ctx.set_current_stage_table(table)
except Exception:
pass
try:
stdout_console().print()
stdout_console().print(table)
except Exception:
pass
return True
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
metadata = getattr(result, "full_metadata", None)
if isinstance(metadata, dict) and metadata.get("is_dir"):
return None
target = str(getattr(result, "path", "") or "").strip()
if not target:
return None
return self.download_url(target, output_dir, title=getattr(result, "title", None))
def download_url(self, url: str, output_dir: Path, **kwargs: Any) -> Optional[Path]:
settings = self._connection_settings_for_url(url)
remote_path = settings["path"]
if not remote_path or remote_path == "/":
return None
filename_hint = str(kwargs.get("title") or "").strip()
parsed_name = posixpath.basename(remote_path.rstrip("/"))
filename = _safe_filename(filename_hint or unquote(parsed_name) or "download")
destination_dir = Path(output_dir)
destination_dir.mkdir(parents=True, exist_ok=True)
destination = _unique_path(destination_dir / filename)
ssh = self._connect_ssh(settings)
scp_client = None
try:
scp_client = self._open_scp(ssh)
scp_client.get(remote_path, local_path=str(destination))
return destination
except Exception:
try:
destination.unlink(missing_ok=True)
except Exception:
pass
return None
finally:
self._close_client(scp_client)
self._close_client(ssh)
def resolve_pipe_result_download(
self,
result: Any,
pipe_obj: Any,
) -> Tuple[Optional[Path], Optional[str], Optional[Path]]:
metadata = self._item_metadata(result, pipe_obj=pipe_obj)
if metadata.get("is_dir"):
return None, None, None
download_url = str(
metadata.get("selection_url")
or metadata.get("scp_url")
or metadata.get("path")
or ""
).strip()
if not download_url.startswith(("scp://", "sftp://")):
return None, None, None
temp_dir = Path(tempfile.mkdtemp(prefix="scp-add-file-"))
downloaded = self.download_url(download_url, temp_dir, title=metadata.get("title"))
if downloaded is None:
try:
temp_dir.rmdir()
except Exception:
pass
return None, None, None
try:
if pipe_obj is not None:
pipe_obj.is_temp = True
except Exception:
pass
return downloaded, None, temp_dir
def upload(self, file_path: str, **kwargs: Any) -> str:
local_path = Path(str(file_path or "")).expanduser()
if not local_path.exists() or not local_path.is_file():
raise FileNotFoundError(f"File not found: {local_path}")
remote_dir = self._normalize_remote_path(kwargs.get("remote_path") or kwargs.get("path") or self._base_path, default=self._base_path)
remote_name = posixpath.basename(str(kwargs.get("remote_name") or local_path.name).replace("\\", "/")) or local_path.name
remote_path = self._join_remote_path(remote_dir, remote_name)
ssh = self._connect_ssh()
sftp = None
scp_client = None
try:
try:
sftp = self._open_sftp(ssh)
except Exception as exc:
if not self._is_sftp_negotiation_error(exc):
raise
self._ensure_directory_via_ssh(ssh, remote_dir)
else:
self._ensure_directory(sftp, remote_dir)
scp_client = self._open_scp(ssh)
scp_client.put(str(local_path), remote_path=remote_path)
finally:
self._close_client(scp_client)
self._close_client(sftp)
self._close_client(ssh)
return self._build_url(remote_path)
def _run_test_connection(self) -> Dict[str, Any]:
if not self._host:
return {"ok": False, "message": "Set 'host' before testing the SCP connection."}
if not self._username:
return {"ok": False, "message": "Set 'username' before testing the SCP connection."}
ssh = None
sftp = None
try:
ssh = self._connect_ssh()
base_path = self._base_path or "/"
transport_detail = "SFTP available"
try:
sftp = self._open_sftp(ssh)
except Exception as exc:
if not self._is_sftp_negotiation_error(exc):
raise
is_dir = self._path_exists_via_ssh(ssh, base_path)
transport_detail = "SFTP unavailable; using SSH command fallback"
else:
try:
attrs = sftp.stat(base_path)
is_dir = stat.S_ISDIR(getattr(attrs, "st_mode", 0))
except Exception:
is_dir = False
detail = f" and confirmed {base_path}" if is_dir else ""
auth_mode = f"key {self._key_path}" if self._key_path else "password/agent auth"
return {
"ok": True,
"message": f"Connected to SCP {self._host}:{self._port} as {self._username} via {auth_mode}. {transport_detail}{detail}.",
}
except Exception as exc:
return {"ok": False, "message": f"SCP connection failed: {exc}"}
finally:
self._close_client(sftp)
self._close_client(ssh)
def _generate_ssh_keypair(self) -> Dict[str, Any]:
target = Path(self._key_path).expanduser() if self._key_path else (Path.home() / ".ssh" / "medeia_scp_rsa")
try:
target.parent.mkdir(parents=True, exist_ok=True)
except Exception as exc:
return {"ok": False, "message": f"Could not create key directory: {exc}"}
public_path = target.with_name(target.name + ".pub")
if target.exists() or public_path.exists():
return {
"ok": False,
"message": f"SSH key already exists at {target}. Remove it or choose a different key_path first.",
}
try:
key = paramiko.RSAKey.generate(bits=4096)
key.write_private_key_file(str(target))
comment = f"{self._username or 'medeia'}@{self._host or 'scp'}"
public_path.write_text(f"{key.get_name()} {key.get_base64()} {comment}\n", encoding="utf-8")
try:
target.chmod(0o600)
except Exception:
pass
return {
"ok": True,
"message": f"Generated SSH key pair at {target}. Save the config to persist key_path.",
"config_updates": {"key_path": str(target)},
}
except Exception as exc:
try:
target.unlink(missing_ok=True)
except Exception:
pass
try:
public_path.unlink(missing_ok=True)
except Exception:
pass
return {"ok": False, "message": f"SSH key generation failed: {exc}"}
def _connect_ssh(self, overrides: Optional[Dict[str, Any]] = None) -> paramiko.SSHClient:
settings = dict(overrides or {})
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
hostname=str(settings.get("host") or self._host),
port=int(settings.get("port") or self._port),
username=str(settings.get("username") or self._username),
password=str(settings.get("password") or self._password) or None,
key_filename=str(settings.get("key_path") or self._key_path) or None,
timeout=self._timeout,
allow_agent=self._allow_agent if "allow_agent" not in settings else bool(settings.get("allow_agent")),
look_for_keys=self._look_for_keys if "look_for_keys" not in settings else bool(settings.get("look_for_keys")),
)
return client
def _open_sftp(self, ssh: Any) -> Any:
return ssh.open_sftp()
def _open_scp(self, ssh: Any) -> Any:
return SCPClient(ssh.get_transport())
def _is_sftp_negotiation_error(self, exc: Exception) -> bool:
text = str(exc or "").strip().lower()
if isinstance(exc, EOFError):
return True
return any(
marker in text
for marker in (
"eof during negotiation",
"open failed",
"channel closed",
"administratively prohibited",
"subsystem request failed",
)
)
def _run_ssh_command(self, ssh: Any, command: str) -> Tuple[int, str, str]:
stdin, stdout, stderr = ssh.exec_command(command, timeout=self._timeout)
try:
stdin.close()
except Exception:
pass
output = stdout.read().decode("utf-8", errors="replace")
error = stderr.read().decode("utf-8", errors="replace")
status = 0
try:
status = int(stdout.channel.recv_exit_status())
except Exception:
status = 0
return status, output, error
def _path_exists_via_ssh(self, ssh: Any, remote_path: str) -> bool:
normalized = self._normalize_remote_path(remote_path, default=self._base_path)
quoted_path = shlex.quote(normalized)
status, _, _ = self._run_ssh_command(ssh, f"test -d {quoted_path}")
return status == 0
def _ensure_directory_via_ssh(self, ssh: Any, remote_path: str) -> None:
normalized = self._normalize_remote_path(remote_path, default=self._base_path)
if normalized == "/":
return
quoted_path = shlex.quote(normalized)
status, _, error = self._run_ssh_command(ssh, f"mkdir -p {quoted_path}")
if status != 0:
raise RuntimeError(error.strip() or f"mkdir -p failed for {normalized}")
def _close_client(self, client: Any) -> None:
if client is None:
return
try:
client.close()
except Exception:
pass
def _normalize_remote_path(self, value: Any, *, default: str) -> str:
text = str(value or "").strip().replace("\\", "/")
if not text:
text = default
elif text.startswith(("scp://", "sftp://")):
try:
text = unquote(urlparse(text).path or "/")
except Exception:
text = default
elif not text.startswith("/"):
text = posixpath.join(default, text)
normalized = posixpath.normpath(text)
normalized = "/" + normalized.lstrip("/")
return normalized or "/"
def _join_remote_path(self, parent: Any, child: Any) -> str:
left = self._normalize_remote_path(parent, default=self._base_path)
right = str(child or "").strip().replace("\\", "/")
if not right:
return left
return self._normalize_remote_path(posixpath.join(left, right), default="/")
def _build_url(
self,
remote_path: Any,
*,
host: Optional[str] = None,
port: Optional[int] = None,
scheme: str = "scp",
) -> str:
path_text = self._normalize_remote_path(remote_path, default="/")
host_text = str(host or self._host).strip()
port_value = int(port or self._port)
port_suffix = f":{port_value}" if port_value and port_value != 22 else ""
return f"{scheme}://{host_text}{port_suffix}{quote(path_text, safe='/-._~!$&\'()*+,;=:@')}"
def _connection_settings_for_url(self, url: str) -> Dict[str, Any]:
parsed = urlparse(str(url or "").strip())
scheme = (parsed.scheme or "scp").strip().lower()
host = parsed.hostname or self._host
port = parsed.port or self._port
username = parsed.username or self._username
password = parsed.password or self._password
path_text = self._normalize_remote_path(unquote(parsed.path or "/"), default="/")
return {
"scheme": scheme,
"host": host,
"port": port,
"username": username,
"password": password,
"key_path": self._key_path,
"allow_agent": self._allow_agent,
"look_for_keys": self._look_for_keys,
"path": path_text,
}
def _search_directory(
self,
sftp: Any,
start_path: str,
*,
needle: str,
limit: int,
search_depth: int,
type_filter: str,
) -> List[SearchResult]:
results: List[SearchResult] = []
visited: set[str] = set()
def walk(current_path: str, depth_left: int) -> None:
normalized = self._normalize_remote_path(current_path, default=self._base_path)
if normalized in visited or len(results) >= limit:
return
visited.add(normalized)
for entry in self._list_directory(sftp, normalized):
if len(results) >= limit:
return
if self._matches_entry(entry, needle=needle, type_filter=type_filter):
results.append(self._build_result(entry))
if entry.get("is_dir") and depth_left > 0:
walk(str(entry.get("scp_path") or normalized), depth_left - 1)
walk(start_path, max(0, search_depth))
return results
def _search_directory_via_ssh(
self,
ssh: Any,
start_path: str,
*,
needle: str,
limit: int,
search_depth: int,
type_filter: str,
) -> List[SearchResult]:
entries = self._list_directory_via_ssh(ssh, start_path, depth=search_depth)
results: List[SearchResult] = []
for entry in entries:
if len(results) >= limit:
break
if self._matches_entry(entry, needle=needle, type_filter=type_filter):
results.append(self._build_result(entry))
return results
def _matches_entry(self, entry: Dict[str, Any], *, needle: str, type_filter: str) -> bool:
is_dir = bool(entry.get("is_dir"))
if type_filter in {"dir", "dirs", "folder", "folders"} and not is_dir:
return False
if type_filter in {"file", "files"} and is_dir:
return False
text = str(needle or "").strip().lower()
if not text or text in {"*", "all", "list"}:
return True
haystacks = [
str(entry.get("name") or "").lower(),
str(entry.get("scp_path") or "").lower(),
]
for token in [part for part in text.split() if part]:
if any(ch in token for ch in "*?[]"):
if not any(fnmatch.fnmatch(haystack, token) for haystack in haystacks):
return False
elif not any(token in haystack for haystack in haystacks):
return False
return True
def _build_result(self, entry: Dict[str, Any]) -> SearchResult:
scp_path = str(entry.get("scp_path") or "/")
scp_url = self._build_url(scp_path)
is_dir = bool(entry.get("is_dir"))
size_value = entry.get("size")
modified = str(entry.get("modified") or "")
parent = posixpath.dirname(scp_path.rstrip("/")) or "/"
metadata = {
"provider": "scp",
"host": self._host,
"scp_path": scp_path,
"scp_url": scp_url,
"selection_url": scp_url,
"is_dir": is_dir,
"name": str(entry.get("name") or "").strip(),
}
if size_value is not None:
metadata["size"] = size_value
if modified:
metadata["modified"] = modified
return SearchResult(
table="scp",
title=str(entry.get("name") or scp_path),
path=scp_url,
detail=parent,
annotations=["folder" if is_dir else "file"],
media_kind="folder" if is_dir else "file",
size_bytes=int(size_value) if isinstance(size_value, int) else None,
tag={"scp", "folder" if is_dir else "file"},
columns=[
("Name", str(entry.get("name") or "")),
("Type", "dir" if is_dir else "file"),
("Directory", parent),
("Size", "" if size_value is None else str(size_value)),
("Modified", modified),
],
selection_args=None if is_dir else ["-url", scp_url],
selection_action=None if is_dir else ["download-file", "-plugin", "scp", "-url", scp_url],
full_metadata=metadata,
)
def _list_directory(self, sftp: Any, remote_path: str) -> List[Dict[str, Any]]:
try:
attrs = sftp.listdir_attr(remote_path)
except Exception:
return []
entries: List[Dict[str, Any]] = []
for attr in attrs:
name_text = str(getattr(attr, "filename", "") or "").strip()
if not name_text or name_text in {".", ".."}:
continue
mode = getattr(attr, "st_mode", 0)
is_dir = stat.S_ISDIR(mode)
size_value = getattr(attr, "st_size", None)
try:
size_int = int(size_value) if size_value is not None else None
except Exception:
size_int = None
entries.append(
{
"name": name_text,
"scp_path": self._join_remote_path(remote_path, name_text),
"is_dir": is_dir,
"size": size_int,
"modified": _format_epoch(getattr(attr, "st_mtime", None)),
}
)
return entries
def _list_directory_via_ssh(self, ssh: Any, remote_path: str, *, depth: int) -> List[Dict[str, Any]]:
normalized = self._normalize_remote_path(remote_path, default=self._base_path)
max_depth = max(1, int(depth) + 1)
quoted_path = shlex.quote(normalized)
command = (
f"find {quoted_path} -mindepth 1 -maxdepth {max_depth} "
f"\\( -type d -o -type f \\) -exec sh -c 'for path do "
f"if [ -d \"$path\" ]; then kind=d; else kind=f; fi; "
f"name=$(basename \"$path\"); "
f"printf \"%s\\0%s\\0%s\\0\" \"$kind\" \"$path\" \"$name\"; "
f"done' sh {{}} +"
)
status, output, error = self._run_ssh_command(ssh, command)
if status != 0:
error_text = error.strip().lower()
if "no such file" in error_text or "cannot access" in error_text:
return []
raise RuntimeError(error.strip() or f"SSH listing failed for {normalized}")
chunks = [part for part in output.split("\0") if part]
entries: List[Dict[str, Any]] = []
for index in range(0, len(chunks), 3):
if index + 2 >= len(chunks):
break
kind = chunks[index]
scp_path = self._normalize_remote_path(chunks[index + 1], default=normalized)
name_text = str(chunks[index + 2] or "").strip()
if not name_text or name_text in {".", ".."}:
continue
entries.append(
{
"name": name_text,
"scp_path": scp_path,
"is_dir": kind == "d",
"size": None,
"modified": "",
}
)
return entries
def _ensure_directory(self, sftp: Any, remote_path: str) -> None:
normalized = self._normalize_remote_path(remote_path, default=self._base_path)
if normalized == "/":
return
partial = ""
for segment in [part for part in normalized.split("/") if part]:
partial = f"{partial}/{segment}"
try:
attrs = sftp.stat(partial)
if stat.S_ISDIR(getattr(attrs, "st_mode", 0)):
continue
except Exception:
pass
try:
sftp.mkdir(partial)
except Exception:
try:
attrs = sftp.stat(partial)
if stat.S_ISDIR(getattr(attrs, "st_mode", 0)):
continue
except Exception:
pass
raise
def _item_metadata(self, item: Any, *, pipe_obj: Any = None) -> Dict[str, Any]:
metadata: Dict[str, Any] = {}
for source in (item, pipe_obj):
if isinstance(source, dict):
for key in ("title", "path", "url"):
if source.get(key) is not None and key not in metadata:
metadata[key] = source.get(key)
nested = source.get("full_metadata") or source.get("metadata")
if isinstance(nested, dict):
metadata.update(nested)
elif source is not None:
for attr in ("title", "path", "url"):
try:
value = getattr(source, attr, None)
except Exception:
value = None
if value is not None and attr not in metadata:
metadata[attr] = value
try:
nested = getattr(source, "full_metadata", None) or getattr(source, "metadata", None)
except Exception:
nested = None
if isinstance(nested, dict):
metadata.update(nested)
scp_path = metadata.get("scp_path") or metadata.get("selection_path")
if not scp_path:
path_value = metadata.get("path") or metadata.get("url") or metadata.get("scp_url")
path_text = str(path_value or "").strip()
if path_text.startswith(("scp://", "sftp://")):
scp_path = self._normalize_remote_path(path_text, default=self._base_path)
if scp_path:
metadata["scp_path"] = self._normalize_remote_path(scp_path, default=self._base_path)
metadata.setdefault("selection_path", metadata["scp_path"])
if metadata.get("scp_path") and not metadata.get("scp_url"):
metadata["scp_url"] = self._build_url(metadata["scp_path"])
if metadata.get("scp_url") and not metadata.get("selection_url"):
metadata["selection_url"] = metadata["scp_url"]
is_dir = metadata.get("is_dir")
if is_dir is None and metadata.get("media_kind"):
is_dir = str(metadata.get("media_kind") or "").strip().lower() == "folder"
metadata["is_dir"] = bool(is_dir)
return metadata
+87 -11
View File
@@ -12,10 +12,18 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from ProviderCore.base import Provider, SearchResult
from SYS.logger import log, debug
from SYS.logger import log, debug, debug_panel
from SYS.models import ProgressBar
_SOULSEEK_NOISE_SUBSTRINGS = (
"unhandled exception on loop",
"Task exception was never retrieved",
"future: <Task finished",
"ConnectionFailedError",
"PeerConnectionError",
"indirect connection failed",
"indirect connection timed out",
"failed to connect",
"search reply ticket does not match any search request",
"failed to receive transfer ticket on file connection",
"aioslsk.exceptions.ConnectionReadError",
@@ -59,10 +67,10 @@ async def _suppress_aioslsk_asyncio_task_noise() -> Any:
if msg == "Task exception was never retrieved":
cls = getattr(exc, "__class__", None)
name = getattr(cls, "__name__", "")
mod = getattr(cls, "__module__", "")
exc_text = str(exc or "").lower()
# Suppress ConnectionFailedError from aioslsk
if name == "ConnectionFailedError" and str(mod).startswith("aioslsk"):
# Suppress expected peer direct-connect failures from aioslsk.
if name == "ConnectionFailedError" or "failed to connect" in exc_text:
return
except Exception:
# If our filter logic fails, fall through to default handling.
@@ -117,6 +125,9 @@ class _LineFilterStream(io.TextIOBase):
self._in_tb = False
self._tb_lines: list[str] = []
self._tb_suppress = False
self._in_task_block = False
self._task_lines: list[str] = []
self._task_suppress = False
def writable(self) -> bool: # pragma: no cover
return True
@@ -137,6 +148,19 @@ class _LineFilterStream(io.TextIOBase):
self._tb_suppress = False
self._in_tb = False
def _flush_task_block(self) -> None:
if not self._task_lines:
return
if not self._task_suppress:
for l in self._task_lines:
try:
self._underlying.write(l + "\n")
except Exception:
pass
self._task_lines = []
self._task_suppress = False
self._in_task_block = False
def write(self, s: str) -> int:
self._buf += str(s)
while "\n" in self._buf:
@@ -145,6 +169,29 @@ class _LineFilterStream(io.TextIOBase):
return len(s)
def _handle_line(self, line: str) -> None:
if not self._in_task_block and self._should_suppress_line(line) and (
line.startswith("Task exception was never retrieved")
or line.startswith("future: <Task finished")
or line.startswith("unhandled exception on loop")
):
self._in_task_block = True
self._task_lines = [line]
self._task_suppress = True
return
if self._in_task_block:
if line.startswith("Traceback (most recent call last):"):
self._in_tb = True
self._tb_lines = [line]
self._tb_suppress = True
return
self._task_lines.append(line)
if self._should_suppress_line(line):
self._task_suppress = True
if line.strip() == "":
self._flush_task_block()
return
# Start capturing tracebacks so we can suppress the whole block if it matches.
if not self._in_tb and line.startswith("Traceback (most recent call last):"):
self._in_tb = True
@@ -159,6 +206,8 @@ class _LineFilterStream(io.TextIOBase):
# End traceback block on blank line.
if line.strip() == "":
self._flush_tb()
if self._in_task_block:
self._flush_task_block()
return
# Non-traceback line
@@ -174,6 +223,8 @@ class _LineFilterStream(io.TextIOBase):
if self._in_tb:
# If the traceback ends without a trailing blank line, decide here.
self._flush_tb()
if self._in_task_block:
self._flush_task_block()
if self._buf:
line = self._buf
self._buf = ""
@@ -422,14 +473,14 @@ class Soulseek(Provider):
try:
search_request = await client.searches.search(query)
await self._collect_results(search_request, timeout=timeout)
return self._flatten_results(search_request)[:limit]
summary = await self._collect_results(search_request, timeout=timeout)
return self._flatten_results(search_request)[:limit], summary
except Exception as exc:
log(
f"[soulseek] Search error: {type(exc).__name__}: {exc}",
file=sys.stderr
)
return []
return [], {}
finally:
# Best-effort: try to cancel/close the search request before stopping
# the client to reduce stray reply spam.
@@ -477,16 +528,24 @@ class Soulseek(Provider):
self,
search_request: Any,
timeout: float = 75.0
) -> None:
end = time.time() + timeout
) -> Dict[str, Any]:
start = time.time()
end = start + timeout
last_count = 0
update_count = 0
while time.time() < end:
current_count = len(getattr(search_request, "results", []))
if current_count > last_count:
debug(f"[soulseek] Got {current_count} result(s)...")
last_count = current_count
update_count += 1
await asyncio.sleep(0.5)
return {
"peer_hits": last_count,
"count_updates": update_count,
"elapsed_seconds": round(max(0.0, time.time() - start), 1),
}
def search(
self,
query: str,
@@ -503,7 +562,7 @@ class Soulseek(Provider):
base_tmp.mkdir(parents=True, exist_ok=True)
try:
flat_results = asyncio.run(
flat_results, search_summary = asyncio.run(
self.perform_search(query,
timeout=9.0,
limit=limit)
@@ -636,6 +695,23 @@ class Soulseek(Provider):
)
)
try:
debug_panel(
"soulseek search",
[
("query", query),
("peer_hits", search_summary.get("peer_hits", 0)),
("file_hits", len(flat_results)),
("audio_hits", len(music_results)),
("results", len(results)),
("poll_updates", search_summary.get("count_updates", 0)),
("elapsed_s", search_summary.get("elapsed_seconds", 0.0)),
],
border_style="magenta",
)
except Exception:
pass
return results
except Exception as exc:
+24 -3
View File
@@ -1286,9 +1286,30 @@ class ytdlp(TableProviderMixin, Provider):
try:
from SYS.result_table_adapters import register_plugin
from SYS.result_table_adapters import get_plugin, register_plugin
from SYS.result_table_api import ResultModel, ColumnSpec, metadata_column, title_column
def _register_table_plugin_once(
name: str,
adapter: Any,
*,
columns: Any,
selection_fn: Any,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
try:
get_plugin(name)
return
except KeyError:
pass
register_plugin(
name,
adapter,
columns=columns,
selection_fn=selection_fn,
metadata=metadata,
)
def _convert_format_result_to_model(sr: Any) -> ResultModel:
d = sr.to_dict() if hasattr(sr, "to_dict") else (sr if isinstance(sr, dict) else {})
title = d.get("title") or f"Format {d.get('format_id', 'unknown')}"
@@ -1361,7 +1382,7 @@ try:
return result_args
return []
register_plugin(
_register_table_plugin_once(
"ytdlp.formatlist",
_adapter,
columns=_columns_factory,
@@ -1421,7 +1442,7 @@ try:
return ["-url", row.path]
return ["-title", row.title or ""]
register_plugin(
_register_table_plugin_once(
"ytdlp.search",
_search_adapter,
columns=_search_columns_factory,