This commit is contained in:
2026-01-04 02:23:50 -08:00
parent 3acf21a673
commit 8545367e28
6 changed files with 2925 additions and 94 deletions

View File

@@ -12,6 +12,7 @@ import sys
import time import time
from typing import Any, Dict, Optional, Set, List, Sequence, Tuple from typing import Any, Dict, Optional, Set, List, Sequence, Tuple
import time
from urllib.parse import urlparse from urllib.parse import urlparse
from SYS.logger import log, debug from SYS.logger import log, debug
@@ -245,6 +246,73 @@ class AllDebridClient:
except Exception as exc: except Exception as exc:
raise AllDebridError(f"Failed to unlock link: {exc}") raise AllDebridError(f"Failed to unlock link: {exc}")
def _link_delayed(self, delayed_id: int) -> Dict[str, Any]:
"""Poll delayed link status."""
try:
resp = self._request("link/delayed", {"id": int(delayed_id)})
if resp.get("status") != "success":
raise AllDebridError("link/delayed returned error status")
data = resp.get("data") or {}
return data if isinstance(data, dict) else {}
except AllDebridError:
raise
except Exception as exc:
raise AllDebridError(f"Failed to poll delayed link: {exc}")
def resolve_unlock_link(
self,
link: str,
*,
poll: bool = True,
max_wait_seconds: int = 30,
poll_interval_seconds: int = 5,
) -> Optional[str]:
"""Unlock a link and handle delayed links by polling link/delayed."""
try:
resp = self._request("link/unlock", {"link": link})
except AllDebridError:
raise
except Exception as exc:
raise AllDebridError(f"Failed to unlock link: {exc}")
if resp.get("status") != "success":
return None
data = resp.get("data") or {}
if not isinstance(data, dict):
return None
# Immediate link ready
for key in ("link", "file"):
val = data.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
delayed_id = data.get("delayed")
if not poll or delayed_id is None:
return None
try:
delayed_int = int(delayed_id)
except Exception:
return None
deadline = time.time() + max_wait_seconds
while time.time() < deadline:
time.sleep(max(1, poll_interval_seconds))
status_data = self._link_delayed(delayed_int)
status = status_data.get("status")
if status == 2:
link_val = status_data.get("link")
if isinstance(link_val, str) and link_val.strip():
return link_val.strip()
return None
if status == 3:
raise AllDebridError("Delayed link generation failed")
return None
def check_host(self, hostname: str) -> Dict[str, Any]: def check_host(self, hostname: str) -> Dict[str, Any]:
"""Check if a host is supported by AllDebrid. """Check if a host is supported by AllDebrid.

2371
API/data/alldebrid.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
@@ -8,11 +9,145 @@ from typing import Any, Dict, Iterable, List, Optional, Callable, Tuple
from urllib.parse import urlparse from urllib.parse import urlparse
from API.HTTP import HTTPClient from API.HTTP import HTTPClient
from API.alldebrid import AllDebridClient, parse_magnet_or_hash, is_magnet_link, is_torrent_file from API.alldebrid import AllDebridClient, parse_magnet_or_hash, is_torrent_file
from ProviderCore.base import Provider, SearchResult from ProviderCore.base import Provider, SearchResult
from ProviderCore.download import sanitize_filename from ProviderCore.download import sanitize_filename
from SYS.download import _download_direct_file from SYS.download import _download_direct_file
from SYS.logger import log from SYS.logger import log
from SYS.models import DownloadError
_HOSTS_CACHE_TTL_SECONDS = 24 * 60 * 60
def _repo_root() -> Path:
try:
return Path(__file__).resolve().parents[1]
except Exception:
return Path(".")
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.
#
# 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"
def _load_cached_domains(category: str) -> List[str]:
"""Load cached domain list from API/data/alldebrid.json.
category: "hosts" | "streams" | "redirectors"
"""
wanted = str(category or "").strip().lower()
if wanted not in {"hosts", "streams", "redirectors"}:
return []
path = _hosts_cache_path()
try:
if not path.exists() or not path.is_file():
return []
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return []
if not isinstance(payload, dict):
return []
data = payload.get("data")
if not isinstance(data, dict):
# Back-compat for older cache shapes.
data = payload
if not isinstance(data, dict):
return []
raw_list = data.get(wanted)
if not isinstance(raw_list, list):
return []
out: List[str] = []
seen: set[str] = set()
for d in raw_list:
try:
dom = str(d or "").strip().lower()
except Exception:
continue
if not dom:
continue
if dom.startswith("http://") or dom.startswith("https://"):
# Accidentally stored as a URL; normalize to hostname.
try:
p = urlparse(dom)
dom = str(p.hostname or "").strip().lower()
except Exception:
continue
if dom.startswith("www."):
dom = dom[4:]
if not dom or dom in seen:
continue
seen.add(dom)
out.append(dom)
return out
def _load_cached_hoster_domains() -> List[str]:
# For URL routing (download-file), we intentionally use only the "hosts" list.
# The "streams" list is extremely broad and would steal URLs from other providers.
return _load_cached_domains("hosts")
def _save_cached_hosts_payload(payload: Dict[str, Any]) -> None:
path = _hosts_cache_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
except Exception:
return
try:
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception:
return
def _cache_is_fresh() -> bool:
path = _hosts_cache_path()
try:
if not path.exists() or not path.is_file():
return False
mtime = float(path.stat().st_mtime)
return (time.time() - mtime) < _HOSTS_CACHE_TTL_SECONDS
except Exception:
return False
def _fetch_hosts_payload_v4_hosts() -> Optional[Dict[str, Any]]:
"""Fetch the public AllDebrid hosts payload.
This intentionally does NOT require an API key.
Endpoint referenced by user: https://api.alldebrid.com/v4/hosts
"""
url = "https://api.alldebrid.com/v4/hosts"
try:
with HTTPClient(timeout=20.0) as client:
resp = client.get(url)
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, dict) else None
except Exception as exc:
log(f"[alldebrid] Failed to fetch hosts list: {exc}", file=sys.stderr)
return None
def refresh_alldebrid_hoster_cache(*, force: bool = False) -> None:
"""Refresh the on-disk cache of host domains (best-effort)."""
if (not force) and _cache_is_fresh():
return
payload = _fetch_hosts_payload_v4_hosts()
if isinstance(payload, dict) and payload:
_save_cached_hosts_payload(payload)
def _get_debrid_api_key(config: Dict[str, Any]) -> Optional[str]: def _get_debrid_api_key(config: Dict[str, Any]) -> Optional[str]:
@@ -177,7 +312,7 @@ def prepare_magnet(
api_key = _get_debrid_api_key(config or {}) api_key = _get_debrid_api_key(config or {})
if not api_key: if not api_key:
try: try:
from ProviderCore.registry import show_provider_config_panel from SYS.rich_display import show_provider_config_panel
show_provider_config_panel("alldebrid", ["api_key"]) show_provider_config_panel("alldebrid", ["api_key"])
except Exception: except Exception:
@@ -193,7 +328,8 @@ def prepare_magnet(
try: try:
magnet_info = client.magnet_add(magnet_spec) magnet_info = client.magnet_add(magnet_spec)
magnet_id = int(magnet_info.get("id", 0)) magnet_id_val = magnet_info.get("id") or 0
magnet_id = int(magnet_id_val)
if magnet_id <= 0: if magnet_id <= 0:
log(f"AllDebrid magnet submission failed: {magnet_info}", file=sys.stderr) log(f"AllDebrid magnet submission failed: {magnet_info}", file=sys.stderr)
return None, None return None, None
@@ -409,6 +545,26 @@ def adjust_output_dir_for_alldebrid(
class AllDebrid(Provider): class AllDebrid(Provider):
# Magnet URIs should be routed through this provider. # Magnet URIs should be routed through this provider.
URL = ("magnet:",) URL = ("magnet:",)
URL_DOMAINS = ()
@classmethod
def url_patterns(cls) -> Tuple[str, ...]:
# Combine static patterns with cached host domains.
patterns = list(super().url_patterns())
try:
cached = _load_cached_hoster_domains()
for d in cached:
dom = str(d or "").strip().lower()
if dom and dom not in patterns:
patterns.append(dom)
log(
f"[alldebrid] url_patterns loaded {len(cached)} cached host domains; total patterns={len(patterns)}",
file=sys.stderr,
)
except Exception:
pass
return tuple(patterns)
"""Search provider for AllDebrid account content. """Search provider for AllDebrid account content.
This provider lists and searches the files/magnets already present in the This provider lists and searches the files/magnets already present in the
@@ -421,7 +577,15 @@ class AllDebrid(Provider):
def validate(self) -> bool: def validate(self) -> bool:
# Consider "available" when configured; actual API connectivity can vary. # Consider "available" when configured; actual API connectivity can vary.
return bool(_get_debrid_api_key(self.config or {})) ok = bool(_get_debrid_api_key(self.config or {}))
if ok:
# Best-effort: refresh cached host domains so future URL routing can
# route supported hosters through this provider.
try:
refresh_alldebrid_hoster_cache(force=False)
except Exception:
pass
return ok
def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]: def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]:
"""Download an AllDebrid SearchResult into output_dir. """Download an AllDebrid SearchResult into output_dir.
@@ -435,10 +599,12 @@ class AllDebrid(Provider):
try: try:
api_key = _get_debrid_api_key(self.config or {}) api_key = _get_debrid_api_key(self.config or {})
if not api_key: if not api_key:
log("[alldebrid] download skipped: missing api_key", file=sys.stderr)
return None return None
target = str(getattr(result, "path", "") or "").strip() target = str(getattr(result, "path", "") or "").strip()
if not target.startswith(("http://", "https://")): if not target.startswith(("http://", "https://")):
log(f"[alldebrid] download skipped: target not http(s): {target}", file=sys.stderr)
return None return None
try: try:
@@ -449,35 +615,59 @@ class AllDebrid(Provider):
log(f"[alldebrid] Failed to init client: {exc}", file=sys.stderr) log(f"[alldebrid] Failed to init client: {exc}", file=sys.stderr)
return None return None
# Quiet mode when download-file is mid-pipeline. log(f"[alldebrid] download routing target={target}", file=sys.stderr)
quiet = (
bool(self.config.get("_quiet_background_output"))
if isinstance(self.config,
dict) else False
)
unlocked_url = target # Prefer provider title as the output filename; later we may override if unlocked URL has a better basename.
try: suggested = sanitize_filename(str(getattr(result, "title", "") or "").strip())
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 suggested_name = suggested if suggested else None
try: # Quiet mode when download-file is mid-pipeline.
from SYS.download import _download_direct_file quiet = bool(self.config.get("_quiet_background_output")) if isinstance(self.config, dict) else False
def _html_guard(path: Path) -> bool:
try:
if path.exists():
size = path.stat().st_size
if size > 0 and size <= 250_000 and path.suffix.lower() not in (".html", ".htm"):
head = path.read_bytes()[:512]
try:
text = head.decode("utf-8", errors="ignore").lower()
except Exception:
text = ""
if "<html" in text or "<!doctype html" in text:
return True
except Exception:
return False
return False
def _download_unlocked(unlocked_url: str, *, allow_html: bool = False) -> Optional[Path]:
# If this is an unlocked debrid link (allow_html=True), stream it directly and skip
# the generic HTML guard to avoid falling back to the public hoster.
if allow_html:
try:
from API.HTTP import HTTPClient
fname = suggested_name or sanitize_filename(Path(urlparse(unlocked_url).path).name)
if not fname:
fname = "download"
if not Path(fname).suffix:
fname = f"{fname}.bin"
dest = Path(output_dir) / fname
dest.parent.mkdir(parents=True, exist_ok=True)
with HTTPClient(timeout=30.0) as client:
with client._request_stream("GET", unlocked_url, follow_redirects=True) as resp:
resp.raise_for_status()
with dest.open("wb") as fh:
for chunk in resp.iter_bytes():
if not chunk:
continue
fh.write(chunk)
return dest if dest.exists() else None
except Exception as exc2:
log(f"[alldebrid] raw stream (unlocked) failed: {exc2}", file=sys.stderr)
return None
# Otherwise, use standard downloader with guardrails.
pipe_progress = None pipe_progress = None
try: try:
if isinstance(self.config, dict): if isinstance(self.config, dict):
@@ -485,47 +675,73 @@ class AllDebrid(Provider):
except Exception: except Exception:
pipe_progress = None 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: try:
if downloaded_path.exists(): dl_res = _download_direct_file(
size = downloaded_path.stat().st_size unlocked_url,
if (size > 0 and size <= 250_000 Path(output_dir),
and downloaded_path.suffix.lower() not in (".html", quiet=quiet,
".htm")): suggested_filename=suggested_name,
head = downloaded_path.read_bytes()[:512] pipeline_progress=pipe_progress,
try: )
text = head.decode("utf-8", errors="ignore").lower() downloaded_path = getattr(dl_res, "path", None)
except Exception: if downloaded_path is None:
text = "" return None
if "<html" in text or "<!doctype html" in text: downloaded_path = Path(str(downloaded_path))
try: except DownloadError as exc:
downloaded_path.unlink() log(
except Exception: f"[alldebrid] _download_direct_file rejected URL ({exc}); no further fallback", file=sys.stderr
pass )
log( return None
"[alldebrid] Download returned HTML page (not file bytes). Try again or check AllDebrid link status.",
file=sys.stderr, try:
) if _html_guard(downloaded_path):
return None log(
"[alldebrid] Download returned HTML page (not file bytes). Try again or check AllDebrid link status.",
file=sys.stderr,
)
return None
except Exception: except Exception:
pass pass
return downloaded_path if downloaded_path.exists() else None return downloaded_path if downloaded_path.exists() else None
unlocked_url = target
try:
unlocked = client.resolve_unlock_link(target, poll=True, max_wait_seconds=45, poll_interval_seconds=5)
if isinstance(unlocked, str) and unlocked.strip().startswith(("http://", "https://")):
unlocked_url = unlocked.strip()
log(f"[alldebrid] unlock -> {unlocked_url}", file=sys.stderr)
except Exception as exc: except Exception as exc:
log(f"[alldebrid] Download failed: {exc}", file=sys.stderr) log(f"[alldebrid] Failed to unlock link: {exc}", file=sys.stderr)
return None
if unlocked_url != target:
# Prefer filename from unlocked URL path.
try:
unlocked_name = sanitize_filename(Path(urlparse(unlocked_url).path).name)
if unlocked_name:
suggested_name = unlocked_name
except Exception:
pass
# When using an unlocked URL different from the original hoster, stream it directly and do NOT fall back to the public URL.
allow_html = unlocked_url != target
log(
f"[alldebrid] downloading from {unlocked_url} (allow_html={allow_html})",
file=sys.stderr,
)
downloaded = _download_unlocked(unlocked_url, allow_html=allow_html)
if downloaded:
log(f"[alldebrid] downloaded -> {downloaded}", file=sys.stderr)
return downloaded
# If unlock failed entirely and we never changed URL, allow a single attempt on the original target.
if unlocked_url == target:
downloaded = _download_unlocked(target, allow_html=False)
if downloaded:
log(f"[alldebrid] downloaded (original target) -> {downloaded}", file=sys.stderr)
return downloaded
return None
except Exception: except Exception:
return None return None
@@ -620,9 +836,12 @@ class AllDebrid(Provider):
if magnet_id_val is None: if magnet_id_val is None:
magnet_id_val = kwargs.get("magnet_id") magnet_id_val = kwargs.get("magnet_id")
if magnet_id_val is None:
return []
try: try:
magnet_id = int(magnet_id_val) magnet_id = int(magnet_id_val)
except Exception: except (TypeError, ValueError):
return [] return []
magnet_status: Dict[str, magnet_status: Dict[str,
@@ -769,9 +988,12 @@ class AllDebrid(Provider):
if not isinstance(magnet, dict): if not isinstance(magnet, dict):
continue continue
magnet_id_val = magnet.get("id")
if magnet_id_val is None:
continue
try: try:
magnet_id = int(magnet.get("id")) magnet_id = int(magnet_id_val)
except Exception: except (TypeError, ValueError):
continue continue
magnet_name = str( magnet_name = str(

View File

@@ -224,11 +224,19 @@ def match_provider_name_for_url(url: str) -> Optional[str]:
# #
# This keeps direct downloads and item pages routed to `internetarchive`, while # This keeps direct downloads and item pages routed to `internetarchive`, while
# preserving OpenLibrary's scripted borrow pipeline for loan/reader URLs. # preserving OpenLibrary's scripted borrow pipeline for loan/reader URLs.
if host: def _norm_host(h: str) -> str:
if host == "openlibrary.org" or host.endswith(".openlibrary.org"): h_norm = str(h or "").strip().lower()
if h_norm.startswith("www."):
h_norm = h_norm[4:]
return h_norm
host_norm = _norm_host(host)
if host_norm:
if host_norm == "openlibrary.org" or host_norm.endswith(".openlibrary.org"):
return "openlibrary" if "openlibrary" in _PROVIDERS else None return "openlibrary" if "openlibrary" in _PROVIDERS else None
if host == "archive.org" or host.endswith(".archive.org"): if host_norm == "archive.org" or host_norm.endswith(".archive.org"):
low_path = str(path or "").lower() low_path = str(path or "").lower()
is_borrowish = ( is_borrowish = (
low_path.startswith("/borrow/") or low_path.startswith("/stream/") low_path.startswith("/borrow/") or low_path.startswith("/stream/")
@@ -243,16 +251,20 @@ def match_provider_name_for_url(url: str) -> Optional[str]:
if not domains: if not domains:
continue continue
for d in domains: for d in domains:
dom = str(d or "").strip().lower() dom_raw = str(d or "").strip()
dom = dom_raw.lower()
if not dom: if not dom:
continue continue
if raw_url_lower.startswith(dom): # Scheme-like patterns (magnet:, http://example) still use prefix match.
return name if dom.startswith("magnet:") or dom.startswith("http://") or dom.startswith("https://"):
for d in domains: if raw_url_lower.startswith(dom):
dom = str(d or "").strip().lower() return name
if not dom or not host:
continue continue
if host == dom or host.endswith("." + dom):
dom_norm = _norm_host(dom)
if not dom_norm or not host_norm:
continue
if host_norm == dom_norm or host_norm.endswith("." + dom_norm):
return name return name
return None return None

View File

@@ -2,9 +2,13 @@ from __future__ import annotations
import re import re
import sys import sys
import tempfile
import shutil
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import quote
import httpx import httpx
from SYS.logger import debug, log from SYS.logger import debug, log
@@ -1099,6 +1103,94 @@ class HydrusNetwork(Store):
debug(f"{self._log_prefix()} get_file: url={browser_url}") debug(f"{self._log_prefix()} get_file: url={browser_url}")
return browser_url return browser_url
def download_to_temp(
self,
file_hash: str,
*,
temp_root: Optional[Path] = None,
) -> Optional[Path]:
"""Download a Hydrus file to a temporary path for downstream uploads."""
try:
client = self._client
if client is None:
return None
h = str(file_hash or "").strip().lower()
if len(h) != 64 or not all(ch in "0123456789abcdef" for ch in h):
return None
created_tmp = False
base_tmp = Path(temp_root) if temp_root is not None else Path(
tempfile.mkdtemp(prefix="hydrus-file-")
)
if temp_root is None:
created_tmp = True
base_tmp.mkdir(parents=True, exist_ok=True)
def _safe_filename(raw: str) -> str:
cleaned = re.sub(r"[\\/:*?\"<>|]", "_", str(raw or "")).strip()
if not cleaned:
return h
cleaned = cleaned.strip(". ") or h
return cleaned
# Prefer ext/title from metadata when available.
fname = h
ext_val = ""
try:
meta = self.get_metadata(h) or {}
if isinstance(meta, dict):
title_val = str(meta.get("title") or "").strip()
if title_val:
fname = _safe_filename(title_val)
ext_val = str(meta.get("ext") or "").strip().lstrip(".")
except Exception:
pass
if not fname:
fname = h
if ext_val and not fname.lower().endswith(f".{ext_val.lower()}"):
fname = f"{fname}.{ext_val}"
try:
file_url = client.file_url(h)
except Exception:
file_url = f"{self.URL.rstrip('/')}/get_files/file?hash={quote(h)}"
dest_path = base_tmp / fname
with httpx.stream(
"GET",
file_url,
headers={"Hydrus-Client-API-Access-Key": self.API},
follow_redirects=True,
timeout=60.0,
verify=False,
) as resp:
resp.raise_for_status()
with dest_path.open("wb") as fh:
for chunk in resp.iter_bytes():
if chunk:
fh.write(chunk)
if dest_path.exists():
return dest_path
if created_tmp:
try:
shutil.rmtree(base_tmp, ignore_errors=True)
except Exception:
pass
return None
except Exception as exc:
log(f"{self._log_prefix()} download_to_temp failed: {exc}", file=sys.stderr)
try:
if temp_root is None and "base_tmp" in locals():
shutil.rmtree(base_tmp, ignore_errors=True) # type: ignore[arg-type]
except Exception:
pass
return None
def delete_file(self, file_identifier: str, **kwargs: Any) -> bool: def delete_file(self, file_identifier: str, **kwargs: Any) -> bool:
"""Delete a file from Hydrus, then clear the deletion record. """Delete a file from Hydrus, then clear the deletion record.

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from copy import deepcopy from copy import deepcopy
import sys import sys
import shutil import shutil
import tempfile
import re import re
from SYS import models from SYS import models
@@ -501,7 +502,7 @@ class Add_File(Cmdlet):
temp_dir_to_cleanup: Optional[Path] = None temp_dir_to_cleanup: Optional[Path] = None
delete_after_item = delete_after delete_after_item = delete_after
try: try:
media_path, file_hash = self._resolve_source( media_path, file_hash, temp_dir_to_cleanup = self._resolve_source(
item, path_arg, pipe_obj, config item, path_arg, pipe_obj, config
) )
debug( debug(
@@ -901,6 +902,38 @@ class Add_File(Cmdlet):
except Exception: except Exception:
continue continue
@staticmethod
def _maybe_download_backend_file(
backend: Any,
file_hash: str,
pipe_obj: models.PipeObject,
) -> Tuple[Optional[Path], Optional[Path]]:
"""Best-effort fetch of a backend file when get_file returns a URL.
Returns (downloaded_path, temp_dir_to_cleanup).
"""
downloader = getattr(backend, "download_to_temp", None)
if not callable(downloader):
return None, None
tmp_dir: Optional[Path] = None
try:
tmp_dir = Path(tempfile.mkdtemp(prefix="add-file-src-"))
downloaded = downloader(str(file_hash), temp_root=tmp_dir)
if isinstance(downloaded, Path) and downloaded.exists():
pipe_obj.is_temp = True
return downloaded, tmp_dir
except Exception:
pass
if tmp_dir is not None:
try:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
return None, None
@staticmethod @staticmethod
def _resolve_source( def _resolve_source(
result: Any, result: Any,
@@ -909,10 +942,11 @@ class Add_File(Cmdlet):
config: Dict[str, config: Dict[str,
Any], Any],
) -> Tuple[Optional[Path], ) -> Tuple[Optional[Path],
Optional[str]]: Optional[str],
Optional[Path]]:
"""Resolve the source file path from args or pipeline result. """Resolve the source file path from args or pipeline result.
Returns (media_path, file_hash). Returns (media_path, file_hash, temp_dir_to_cleanup).
""" """
# PRIORITY 1a: Try hash+path from directory scan result (has 'path' and 'hash' keys) # PRIORITY 1a: Try hash+path from directory scan result (has 'path' and 'hash' keys)
if isinstance(result, dict): if isinstance(result, dict):
@@ -931,7 +965,7 @@ class Add_File(Cmdlet):
f"[add-file] Using path+hash from directory scan: {media_path}" f"[add-file] Using path+hash from directory scan: {media_path}"
) )
pipe_obj.path = str(media_path) pipe_obj.path = str(media_path)
return media_path, str(result_hash) return media_path, str(result_hash), None
except Exception as exc: except Exception as exc:
debug(f"[add-file] Failed to use directory scan result: {exc}") debug(f"[add-file] Failed to use directory scan result: {exc}")
@@ -950,7 +984,17 @@ class Add_File(Cmdlet):
media_path = backend.get_file(result_hash) media_path = backend.get_file(result_hash)
if isinstance(media_path, Path) and media_path.exists(): if isinstance(media_path, Path) and media_path.exists():
pipe_obj.path = str(media_path) pipe_obj.path = str(media_path)
return media_path, str(result_hash) return media_path, str(result_hash), None
if isinstance(media_path, str) and media_path.strip():
downloaded, tmp_dir = Add_File._maybe_download_backend_file(
backend,
str(result_hash),
pipe_obj,
)
if isinstance(downloaded, Path) and downloaded.exists():
pipe_obj.path = str(downloaded)
return downloaded, str(result_hash), tmp_dir
except Exception as exc: except Exception as exc:
debug(f"[add-file] Failed to retrieve via hash+store: {exc}") debug(f"[add-file] Failed to retrieve via hash+store: {exc}")
@@ -959,7 +1003,7 @@ class Add_File(Cmdlet):
media_path = Path(path_arg) media_path = Path(path_arg)
pipe_obj.path = str(media_path) pipe_obj.path = str(media_path)
debug(f"[add-file] Using explicit path argument: {media_path}") debug(f"[add-file] Using explicit path argument: {media_path}")
return media_path, None return media_path, None, None
# PRIORITY 3: Try from pipe_obj.path (check file first before URL) # PRIORITY 3: Try from pipe_obj.path (check file first before URL)
pipe_path = getattr(pipe_obj, "path", None) pipe_path = getattr(pipe_obj, "path", None)
@@ -976,8 +1020,8 @@ class Add_File(Cmdlet):
"add-file ingests local files only. Use download-file first.", "add-file ingests local files only. Use download-file first.",
file=sys.stderr, file=sys.stderr,
) )
return None, None return None, None, None
return Path(pipe_path_str), None return Path(pipe_path_str), None, None
# Try from result (if it's a string path or URL) # Try from result (if it's a string path or URL)
if isinstance(result, str): if isinstance(result, str):
@@ -993,10 +1037,10 @@ class Add_File(Cmdlet):
"add-file ingests local files only. Use download-file first.", "add-file ingests local files only. Use download-file first.",
file=sys.stderr, file=sys.stderr,
) )
return None, None return None, None, None
media_path = Path(result) media_path = Path(result)
pipe_obj.path = str(media_path) pipe_obj.path = str(media_path)
return media_path, None return media_path, None, None
# Try from result if it's a list (pipeline emits multiple results) # Try from result if it's a list (pipeline emits multiple results)
if isinstance(result, list) and result: if isinstance(result, list) and result:
@@ -1014,10 +1058,10 @@ class Add_File(Cmdlet):
"add-file ingests local files only. Use download-file first.", "add-file ingests local files only. Use download-file first.",
file=sys.stderr, file=sys.stderr,
) )
return None, None return None, None, None
media_path = Path(first_item) media_path = Path(first_item)
pipe_obj.path = str(media_path) pipe_obj.path = str(media_path)
return media_path, None return media_path, None, None
# If the first item is a dict, interpret it as a PipeObject-style result # If the first item is a dict, interpret it as a PipeObject-style result
if isinstance(first_item, dict): if isinstance(first_item, dict):
@@ -1037,9 +1081,9 @@ class Add_File(Cmdlet):
try: try:
media_path = Path(path_candidate) media_path = Path(path_candidate)
pipe_obj.path = str(media_path) pipe_obj.path = str(media_path)
return media_path, first_item.get("hash") return media_path, first_item.get("hash"), None
except Exception: except Exception:
return None, first_item.get("hash") return None, first_item.get("hash"), None
# If first item is a PipeObject object # If first item is a PipeObject object
try: try:
@@ -1052,7 +1096,7 @@ class Add_File(Cmdlet):
debug(f"Resolved path from PipeObject: {path_candidate}") debug(f"Resolved path from PipeObject: {path_candidate}")
media_path = Path(path_candidate) media_path = Path(path_candidate)
pipe_obj.path = str(media_path) pipe_obj.path = str(media_path)
return media_path, getattr(first_item, "hash", None) return media_path, getattr(first_item, "hash", None), None
except Exception: except Exception:
pass pass
@@ -1060,7 +1104,7 @@ class Add_File(Cmdlet):
f"No resolution path matched. pipe_obj.path={pipe_path}, result type={type(result).__name__}" f"No resolution path matched. pipe_obj.path={pipe_path}, result type={type(result).__name__}"
) )
log("File path could not be resolved") log("File path could not be resolved")
return None, None return None, None, None
@staticmethod @staticmethod
def _scan_directory_for_files(directory: Path) -> List[Dict[str, Any]]: def _scan_directory_for_files(directory: Path) -> List[Dict[str, Any]]:
@@ -1778,6 +1822,12 @@ class Add_File(Cmdlet):
store = Store(config) store = Store(config)
backend = store[backend_name] backend = store[backend_name]
hydrus_like_backend = False
try:
hydrus_like_backend = str(type(backend).__name__ or "").lower().startswith("hydrus")
except Exception:
hydrus_like_backend = False
# Prepare metadata from pipe_obj and sidecars # Prepare metadata from pipe_obj and sidecars
tags, url, title, f_hash = Add_File._prepare_metadata( tags, url, title, f_hash = Add_File._prepare_metadata(
result, media_path, pipe_obj, config result, media_path, pipe_obj, config
@@ -1870,6 +1920,11 @@ class Add_File(Cmdlet):
log(f"[add-file] FlorenceVision tagging error: {exc}", file=sys.stderr) log(f"[add-file] FlorenceVision tagging error: {exc}", file=sys.stderr)
return 1 return 1
upload_tags = tags
if hydrus_like_backend and upload_tags:
upload_tags = []
debug("[add-file] Deferring tag application until after Hydrus upload")
debug( debug(
f"[add-file] Storing into backend '{backend_name}' path='{media_path}' title='{title}'" f"[add-file] Storing into backend '{backend_name}' path='{media_path}' title='{title}'"
) )
@@ -1879,7 +1934,7 @@ class Add_File(Cmdlet):
file_identifier = backend.add_file( file_identifier = backend.add_file(
media_path, media_path,
title=title, title=title,
tag=tags, tag=upload_tags,
url=[] if (defer_url_association and url) else url, url=[] if (defer_url_association and url) else url,
) )
debug( debug(
@@ -1921,6 +1976,17 @@ class Add_File(Cmdlet):
(f_hash or file_identifier or "unknown") (f_hash or file_identifier or "unknown")
) )
if hydrus_like_backend and tags:
try:
adder = getattr(backend, "add_tag", None)
if callable(adder):
debug(
f"[add-file] Applying {len(tags)} tag(s) post-upload to Hydrus"
)
adder(resolved_hash, list(tags))
except Exception as exc:
log(f"[add-file] Hydrus post-upload tagging failed: {exc}", file=sys.stderr)
# If we have url(s), ensure they get associated with the destination file. # If we have url(s), ensure they get associated with the destination file.
# This mirrors `add-url` behavior but avoids emitting extra pipeline noise. # This mirrors `add-url` behavior but avoids emitting extra pipeline noise.
if url: if url: