update refactoring and add new features
This commit is contained in:
+115
@@ -0,0 +1,115 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# Medeia-Macina Environment Variables
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to .env and adjust values as needed.
|
||||||
|
#
|
||||||
|
# All variables are optional unless noted otherwise. The application auto-detects
|
||||||
|
# sensible defaults for most settings.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Core / Debug
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Enable debug mode (verbose logging, diagnostic output, rich tracebacks).
|
||||||
|
# Set to "1" to enable.
|
||||||
|
# Default: disabled (unless the database config key `debug` is "1"/"true"/"yes"/"on")
|
||||||
|
MM_DEBUG=0
|
||||||
|
|
||||||
|
# Enable raw HTTP request/response body logging in debug output. When set to
|
||||||
|
# "0", "false", "no", or "off", raw bodies are suppressed even while MM_DEBUG
|
||||||
|
# is active. Any other value (or unset) follows MM_DEBUG.
|
||||||
|
MM_HTTP_RAW=0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Repository / Paths
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Application root directory. Searched for medios.db, CLI.py, and config.conf.
|
||||||
|
# Precedence: MM_REPO > MM_ROOT > REPO > auto-detection from CWD.
|
||||||
|
MM_REPO=
|
||||||
|
|
||||||
|
# Alternative root directory name (checked if MM_REPO is not set).
|
||||||
|
MM_ROOT=
|
||||||
|
|
||||||
|
# Legacy / generic repository path (checked after MM_REPO and MM_ROOT).
|
||||||
|
REPO=
|
||||||
|
|
||||||
|
# Semicolon-separated list of external plugin directories. Plugins placed in
|
||||||
|
# these directories are discovered and registered at startup.
|
||||||
|
# Example: C:\extra-plugins;C:\more-plugins
|
||||||
|
MM_PLUGIN_PATH=
|
||||||
|
|
||||||
|
# Legacy alias for MM_PLUGIN_PATH (checked second).
|
||||||
|
MEDEIA_PLUGIN_PATH=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Networking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Custom SSL/TLS CA certificate bundle path (PEM format).
|
||||||
|
# Overrides system-wide trust stores for all TLS connections made by the
|
||||||
|
# application. Useful behind corporate MITM proxies or custom CAs.
|
||||||
|
SSL_CERT_FILE=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MPV Integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Override the default MPV IPC socket / named-pipe path. Used by the CLI,
|
||||||
|
# the pipeline helper, and the lyrics overlay to communicate with mpv.
|
||||||
|
# Default (Windows): \\.\pipe\mpv-medios-macina
|
||||||
|
# Default (macOS/Linux): /tmp/mpv-medios-macina.sock
|
||||||
|
MEDEIA_MPV_IPC=
|
||||||
|
|
||||||
|
# Legacy alias for MEDEIA_MPV_IPC (checked second).
|
||||||
|
MPV_IPC_SERVER=
|
||||||
|
|
||||||
|
# Minimum log level for mpv's internal message stream forwarded to the helper
|
||||||
|
# log. Valid values: "fatal", "error", "warn" (default), "info", "debug", "v",
|
||||||
|
# "trace".
|
||||||
|
MEDEIA_MPV_HELPER_MPVLOG=warn
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dependencies / Updates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Automatically update Python dependencies on client startup.
|
||||||
|
# Set to "1" to force `pip install --upgrade` on the requirements file.
|
||||||
|
MM_UPDATE_DEPS=0
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Playwright / Browser Automation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Path to the ffmpeg executable. Used by Playwright for video recording and
|
||||||
|
# processing. Resolution order:
|
||||||
|
# 1. Plugin config key `plugin.playwright.ffmpeg_path`
|
||||||
|
# 2. Environment variable `FFMPEG_PATH`
|
||||||
|
# 3. Environment variable `PLAYWRIGHT_FFMPEG_PATH` (legacy)
|
||||||
|
# 4. Project-bundled ffmpeg (MPV/ffmpeg/bin/)
|
||||||
|
# 5. System PATH lookup (`which ffmpeg`)
|
||||||
|
FFMPEG_PATH=
|
||||||
|
|
||||||
|
# Legacy Playwright-specific ffmpeg path. Prefer `FFMPEG_PATH` instead.
|
||||||
|
PLAYWRIGHT_FFMPEG_PATH=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internet Archive
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Internet Archive S3-like API access key. Used for uploads. Can also be
|
||||||
|
# configured in the plugin section of the config (key: `access_key`).
|
||||||
|
IA_ACCESS_KEY=
|
||||||
|
|
||||||
|
# Internet Archive S3-like API secret key. Used for uploads. Can also be
|
||||||
|
# configured in the plugin section of the config (key: `secret_key`).
|
||||||
|
IA_SECRET_KEY=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Soulseek
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Enable verbose aioslsk library output (normally suppressed).
|
||||||
|
# Set to "1" to see debug-level noise from the Soulseek networking layer.
|
||||||
|
DOWNLOAD_SOULSEEK_VERBOSE=0
|
||||||
|
# Note: The old name `DOWNLOW_SOULSEEK_VERBOSE` (typo) is deprecated.
|
||||||
+3
-16
@@ -82,8 +82,6 @@ target/
|
|||||||
# IPython
|
# IPython
|
||||||
profile_default/
|
profile_default/
|
||||||
ipython_config.py
|
ipython_config.py
|
||||||
config.conf
|
|
||||||
config.d/
|
|
||||||
# pyenv
|
# pyenv
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
@@ -162,13 +160,6 @@ dmypy.json
|
|||||||
# Cython debug symbols
|
# Cython debug symbols
|
||||||
cython_debug/
|
cython_debug/
|
||||||
|
|
||||||
# PyCharm
|
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
|
|
||||||
# Ruff stuff:
|
# Ruff stuff:
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
@@ -205,7 +196,6 @@ luac.out
|
|||||||
|
|
||||||
# Shared objects (inc. Windows DLLs)
|
# Shared objects (inc. Windows DLLs)
|
||||||
*.dll
|
*.dll
|
||||||
*.so
|
|
||||||
*.so.*
|
*.so.*
|
||||||
*.dylib
|
*.dylib
|
||||||
|
|
||||||
@@ -218,12 +208,11 @@ luac.out
|
|||||||
*.hex
|
*.hex
|
||||||
|
|
||||||
|
|
||||||
config.conf
|
|
||||||
config.d/
|
|
||||||
MPV/ffmpeg/*
|
MPV/ffmpeg/*
|
||||||
|
|
||||||
|
config_backups/
|
||||||
|
|
||||||
Log/
|
Log/
|
||||||
Log/medeia_macina/telegram.session
|
|
||||||
mpv_logs_with_db.txt
|
mpv_logs_with_db.txt
|
||||||
*.session
|
*.session
|
||||||
example.py
|
example.py
|
||||||
@@ -232,21 +221,19 @@ MPV/portable_config/watch_later*
|
|||||||
hydrusnetwork
|
hydrusnetwork
|
||||||
.style.yapf
|
.style.yapf
|
||||||
.yapfignore
|
.yapfignore
|
||||||
tests/
|
|
||||||
scripts/mm.ps1
|
scripts/mm.ps1
|
||||||
scripts/mm
|
scripts/mm
|
||||||
..style.yapf
|
..style.yapf
|
||||||
.yapfignore
|
.yapfignore
|
||||||
tmp_*
|
tmp_*
|
||||||
*.secret
|
*.secret
|
||||||
authtoken.secret
|
|
||||||
|
|
||||||
mypy.
|
mypy.
|
||||||
.idea
|
.idea
|
||||||
medios.db
|
medios.db
|
||||||
medios*
|
medios*
|
||||||
mypy.ini
|
mypy.ini
|
||||||
\logs\*
|
logs/*
|
||||||
logs*
|
logs*
|
||||||
logs.db
|
logs.db
|
||||||
logs.db*
|
logs.db*
|
||||||
|
|||||||
+2
-6
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
import atexit
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from .HTTP import HTTPClient
|
from .HTTP import HTTPClient
|
||||||
@@ -19,6 +20,7 @@ class API:
|
|||||||
self.timeout = float(timeout)
|
self.timeout = float(timeout)
|
||||||
self._http_client: Optional[HTTPClient] = None
|
self._http_client: Optional[HTTPClient] = None
|
||||||
self._http_client_lock = threading.Lock()
|
self._http_client_lock = threading.Lock()
|
||||||
|
atexit.register(self.close)
|
||||||
|
|
||||||
def _get_http_client(self) -> HTTPClient:
|
def _get_http_client(self) -> HTTPClient:
|
||||||
"""Return a reusable opened HTTP client for this API instance."""
|
"""Return a reusable opened HTTP client for this API instance."""
|
||||||
@@ -49,12 +51,6 @@ class API:
|
|||||||
pass
|
pass
|
||||||
self._http_client = None
|
self._http_client = None
|
||||||
|
|
||||||
def __del__(self) -> None:
|
|
||||||
try:
|
|
||||||
self.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _get_json(
|
def _get_json(
|
||||||
self,
|
self,
|
||||||
path: str,
|
path: str,
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ def run_cmdlet(
|
|||||||
) -> CmdletRunResult:
|
) -> CmdletRunResult:
|
||||||
"""Run a single cmdlet programmatically and return structured results.
|
"""Run a single cmdlet programmatically and return structured results.
|
||||||
|
|
||||||
This is intended for TUI/webapp consumers that want cmdlet behavior without
|
This is intended for programmatic consumers that want cmdlet behavior without
|
||||||
going through the interactive CLI loop.
|
going through the interactive CLI loop.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|||||||
@@ -827,9 +827,14 @@ class CmdletCompleter(Completer):
|
|||||||
return None
|
return None
|
||||||
raw_plugin = CmdletCompleter._flag_value(stage_tokens, "-plugin", "--plugin")
|
raw_plugin = CmdletCompleter._flag_value(stage_tokens, "-plugin", "--plugin")
|
||||||
if raw_plugin:
|
if raw_plugin:
|
||||||
# Strip quotes if present, then normalize to lowercase
|
|
||||||
stripped = CmdletCompleter._strip_quotes(str(raw_plugin or ""))
|
stripped = CmdletCompleter._strip_quotes(str(raw_plugin or ""))
|
||||||
return stripped.strip().lower() if stripped else None
|
if not stripped:
|
||||||
|
return None
|
||||||
|
plugin_name = stripped.strip().lower()
|
||||||
|
if "," in plugin_name:
|
||||||
|
parts = [p.strip() for p in plugin_name.split(",") if p.strip()]
|
||||||
|
return parts[0] if parts else None
|
||||||
|
return plugin_name
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1058,11 +1063,20 @@ class CmdletCompleter(Completer):
|
|||||||
logical: idx for idx, logical in enumerate(file_stage_order)
|
logical: idx for idx, logical in enumerate(file_stage_order)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
action_logicals = {logical for logical, _ in self._FILE_STAGE_ACTION_CMDLETS}
|
||||||
|
chosen_actions: Set[str] = set()
|
||||||
|
for token in stage_tokens:
|
||||||
|
logical = str(token).lstrip("-").strip().lower()
|
||||||
|
if logical in action_logicals:
|
||||||
|
chosen_actions.add(logical)
|
||||||
|
|
||||||
filtered: List[Tuple[int, int, str]] = []
|
filtered: List[Tuple[int, int, str]] = []
|
||||||
for index, arg in enumerate(arg_names):
|
for index, arg in enumerate(arg_names):
|
||||||
logical = str(arg or "").lstrip("-").strip().lower()
|
logical = str(arg or "").lstrip("-").strip().lower()
|
||||||
if file_stage_order is not None and logical not in file_stage_rank:
|
if file_stage_order is not None and logical not in file_stage_rank:
|
||||||
continue
|
continue
|
||||||
|
if chosen_actions and logical in action_logicals and logical not in chosen_actions:
|
||||||
|
continue
|
||||||
if logical == "open":
|
if logical == "open":
|
||||||
continue
|
continue
|
||||||
if logical == "instance":
|
if logical == "instance":
|
||||||
@@ -3085,6 +3099,10 @@ Come to love it when others take what you share, as there is no greater joy
|
|||||||
if "|" in tokens or (tokens and tokens[0].startswith("@")):
|
if "|" in tokens or (tokens and tokens[0].startswith("@")):
|
||||||
self._pipeline_executor.execute_tokens(tokens)
|
self._pipeline_executor.execute_tokens(tokens)
|
||||||
else:
|
else:
|
||||||
|
try:
|
||||||
|
ctx.clear_pending_pipeline_tail()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
cmd_name = tokens[0].replace("_", "-").lower()
|
cmd_name = tokens[0].replace("_", "-").lower()
|
||||||
is_help = any(
|
is_help = any(
|
||||||
arg in {"-help",
|
arg in {"-help",
|
||||||
|
|||||||
+34
-27
@@ -330,36 +330,43 @@ class PluginRegistry:
|
|||||||
for plugin_dir in _iter_external_plugin_dirs():
|
for plugin_dir in _iter_external_plugin_dirs():
|
||||||
if self._is_builtin_package_dir(plugin_dir):
|
if self._is_builtin_package_dir(plugin_dir):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
plugin_dir_str = str(plugin_dir)
|
||||||
|
path_added = False
|
||||||
|
if plugin_dir_str and plugin_dir_str not in sys.path:
|
||||||
|
sys.path.insert(0, plugin_dir_str)
|
||||||
|
path_added = True
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plugin_dir_str = str(plugin_dir)
|
for module_name, module_path, is_package in _iter_external_plugin_entries(plugin_dir):
|
||||||
if plugin_dir_str and plugin_dir_str not in sys.path:
|
if module_name in self._external_modules:
|
||||||
sys.path.insert(0, plugin_dir_str)
|
continue
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
for module_name, module_path, is_package in _iter_external_plugin_entries(plugin_dir):
|
try:
|
||||||
if module_name in self._external_modules:
|
if is_package:
|
||||||
continue
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
module_name,
|
||||||
|
str(module_path),
|
||||||
|
submodule_search_locations=[str(module_path.parent)],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, str(module_path))
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ImportError("missing module spec loader")
|
||||||
|
|
||||||
try:
|
module = importlib.util.module_from_spec(spec)
|
||||||
if is_package:
|
sys.modules[module_name] = module
|
||||||
spec = importlib.util.spec_from_file_location(
|
spec.loader.exec_module(module)
|
||||||
module_name,
|
self._external_modules.add(module_name)
|
||||||
str(module_path),
|
self._register_module(module)
|
||||||
submodule_search_locations=[str(module_path.parent)],
|
except Exception as exc:
|
||||||
)
|
log(f"[plugin] Failed to load external plugin {module_path}: {exc}", file=sys.stderr)
|
||||||
else:
|
finally:
|
||||||
spec = importlib.util.spec_from_file_location(module_name, str(module_path))
|
if path_added:
|
||||||
if spec is None or spec.loader is None:
|
try:
|
||||||
raise ImportError("missing module spec loader")
|
sys.path.remove(plugin_dir_str)
|
||||||
|
except ValueError:
|
||||||
module = importlib.util.module_from_spec(spec)
|
pass
|
||||||
sys.modules[module_name] = module
|
|
||||||
spec.loader.exec_module(module)
|
|
||||||
self._external_modules.add(module_name)
|
|
||||||
self._register_module(module)
|
|
||||||
except Exception as exc:
|
|
||||||
log(f"[plugin] Failed to load external plugin {module_path}: {exc}", file=sys.stderr)
|
|
||||||
|
|
||||||
def discover(self) -> None:
|
def discover(self) -> None:
|
||||||
"""Import and register plugins from the package."""
|
"""Import and register plugins from the package."""
|
||||||
|
|||||||
+8
-4
@@ -26,12 +26,16 @@ SCRIPT_DIR = Path(__file__).resolve().parent
|
|||||||
_SAVE_LOCK_DIRNAME = ".medios_save_lock"
|
_SAVE_LOCK_DIRNAME = ".medios_save_lock"
|
||||||
_SAVE_LOCK_TIMEOUT = 30.0 # seconds to wait for save lock
|
_SAVE_LOCK_TIMEOUT = 30.0 # seconds to wait for save lock
|
||||||
_SAVE_LOCK_STALE_SECONDS = 3600 # consider lock stale after 1 hour
|
_SAVE_LOCK_STALE_SECONDS = 3600 # consider lock stale after 1 hour
|
||||||
|
_SAVE_LOCK_POLL_INTERVAL = 0.1 # seconds between lock acquisition attempts
|
||||||
|
|
||||||
|
_WAL_CHECKPOINT_TIMEOUT = 5.0 # seconds for WAL checkpoint connection
|
||||||
|
|
||||||
_CONFIG_CACHE: Dict[str, Any] = {}
|
_CONFIG_CACHE: Dict[str, Any] = {}
|
||||||
_LAST_SAVED_CONFIG: Dict[str, Any] = {}
|
_LAST_SAVED_CONFIG: Dict[str, Any] = {}
|
||||||
_CONFIG_SUMMARY_PENDING = False
|
_CONFIG_SUMMARY_PENDING = False
|
||||||
_CONFIG_SAVE_MAX_RETRIES = 5
|
_CONFIG_SAVE_MAX_RETRIES = 5
|
||||||
_CONFIG_SAVE_RETRY_DELAY = 0.15
|
_CONFIG_SAVE_RETRY_DELAY = 0.15
|
||||||
|
_CONFIG_SAVE_VERIFY_RETRIES = 3
|
||||||
_CONFIG_MISSING = object()
|
_CONFIG_MISSING = object()
|
||||||
_PATH_ALIAS_TOKEN_RE = re.compile(r"^\$(?:\((?P<braced>[^)]+)\)|(?P<plain>[A-Za-z0-9_.-]+))$")
|
_PATH_ALIAS_TOKEN_RE = re.compile(r"^\$(?:\((?P<braced>[^)]+)\)|(?P<plain>[A-Za-z0-9_.-]+))$")
|
||||||
|
|
||||||
@@ -890,7 +894,7 @@ def _acquire_save_lock(timeout: float = _SAVE_LOCK_TIMEOUT):
|
|||||||
logger.exception("Failed to inspect save lock directory %s: %s", lock_dir, exc)
|
logger.exception("Failed to inspect save lock directory %s: %s", lock_dir, exc)
|
||||||
if time.time() - start > timeout:
|
if time.time() - start > timeout:
|
||||||
raise ConfigSaveConflict("Save lock busy; could not acquire in time")
|
raise ConfigSaveConflict("Save lock busy; could not acquire in time")
|
||||||
time.sleep(0.1)
|
time.sleep(_SAVE_LOCK_POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
def _release_save_lock(lock_dir: Path) -> None:
|
def _release_save_lock(lock_dir: Path) -> None:
|
||||||
@@ -1030,10 +1034,10 @@ def save_config(config: Dict[str, Any]) -> int:
|
|||||||
# we don't contend with our main connection lock or active transactions.
|
# we don't contend with our main connection lock or active transactions.
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
with sqlite3.connect(str(db.db_path), timeout=5.0) as _con:
|
with sqlite3.connect(str(db.db_path), timeout=_WAL_CHECKPOINT_TIMEOUT) as _con:
|
||||||
_con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
_con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
except Exception:
|
except Exception:
|
||||||
with sqlite3.connect(str(db.db_path), timeout=5.0) as _con:
|
with sqlite3.connect(str(db.db_path), timeout=_WAL_CHECKPOINT_TIMEOUT) as _con:
|
||||||
_con.execute("PRAGMA wal_checkpoint")
|
_con.execute("PRAGMA wal_checkpoint")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"Warning: WAL checkpoint failed: {exc}")
|
log(f"Warning: WAL checkpoint failed: {exc}")
|
||||||
@@ -1084,7 +1088,7 @@ def save(config: Dict[str, Any]) -> int:
|
|||||||
return save_config(config)
|
return save_config(config)
|
||||||
|
|
||||||
|
|
||||||
def save_config_and_verify(config: Dict[str, Any], retries: int = 3, delay: float = 0.15) -> int:
|
def save_config_and_verify(config: Dict[str, Any], retries: int = _CONFIG_SAVE_VERIFY_RETRIES, delay: float = _CONFIG_SAVE_RETRY_DELAY) -> int:
|
||||||
"""Save configuration and verify crucial keys persisted to disk.
|
"""Save configuration and verify crucial keys persisted to disk.
|
||||||
|
|
||||||
This helper performs a best-effort verification loop that reloads the
|
This helper performs a best-effort verification loop that reloads the
|
||||||
|
|||||||
+58
-17
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import atexit
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import json
|
import json
|
||||||
import ast
|
import ast
|
||||||
@@ -18,6 +19,8 @@ logger = logging.getLogger(__name__)
|
|||||||
# DB execute retry settings (for transient 'database is locked' errors)
|
# DB execute retry settings (for transient 'database is locked' errors)
|
||||||
_DB_EXEC_RETRY_MAX = 5
|
_DB_EXEC_RETRY_MAX = 5
|
||||||
_DB_EXEC_RETRY_BASE_DELAY = 0.05
|
_DB_EXEC_RETRY_BASE_DELAY = 0.05
|
||||||
|
_DB_CONNECT_TIMEOUT = 30.0
|
||||||
|
_DB_BUSY_TIMEOUT_MS = 30000
|
||||||
|
|
||||||
# The database is located in the project root (prefer explicit repo hints).
|
# The database is located in the project root (prefer explicit repo hints).
|
||||||
def _resolve_root_dir() -> Path:
|
def _resolve_root_dir() -> Path:
|
||||||
@@ -72,7 +75,7 @@ class Database:
|
|||||||
self.conn = sqlite3.connect(
|
self.conn = sqlite3.connect(
|
||||||
str(self.db_path),
|
str(self.db_path),
|
||||||
check_same_thread=False,
|
check_same_thread=False,
|
||||||
timeout=30.0 # Increase timeout to 30s to avoid locking issues
|
timeout=_DB_CONNECT_TIMEOUT
|
||||||
)
|
)
|
||||||
self.conn.row_factory = sqlite3.Row
|
self.conn.row_factory = sqlite3.Row
|
||||||
# Reentrant lock to allow nested DB calls within the same thread (e.g., transaction ->
|
# Reentrant lock to allow nested DB calls within the same thread (e.g., transaction ->
|
||||||
@@ -82,7 +85,7 @@ class Database:
|
|||||||
# Use WAL mode for better concurrency (allows multiple readers + 1 writer)
|
# Use WAL mode for better concurrency (allows multiple readers + 1 writer)
|
||||||
# Set a busy timeout so SQLite waits for short locks rather than immediately failing
|
# Set a busy timeout so SQLite waits for short locks rather than immediately failing
|
||||||
try:
|
try:
|
||||||
self.conn.execute("PRAGMA busy_timeout = 30000")
|
self.conn.execute(f"PRAGMA busy_timeout = {_DB_BUSY_TIMEOUT_MS}")
|
||||||
self.conn.execute("PRAGMA journal_mode=WAL")
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||||
self.conn.execute("PRAGMA synchronous=NORMAL")
|
self.conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
except sqlite3.Error:
|
except sqlite3.Error:
|
||||||
@@ -282,23 +285,27 @@ class Database:
|
|||||||
# Singleton instance
|
# Singleton instance
|
||||||
db = Database()
|
db = Database()
|
||||||
|
|
||||||
_LOG_QUEUE: Queue[tuple[str, str, str]] = Queue()
|
_LOG_QUEUE: Queue = Queue()
|
||||||
_LOG_THREAD_STARTED = False
|
_LOG_THREAD_STARTED = False
|
||||||
_LOG_THREAD_LOCK = threading.Lock()
|
_LOG_THREAD_LOCK = threading.Lock()
|
||||||
_LOG_WRITE_COUNT = 0
|
_LOG_WRITE_COUNT = 0
|
||||||
_LOG_PRUNE_INTERVAL = 500 # prune every N successful writes
|
_LOG_PRUNE_INTERVAL = 500 # prune every N successful writes
|
||||||
_LOG_MAX_AGE_DAYS = 7 # keep logs for this many days
|
_LOG_MAX_AGE_DAYS = 7 # keep logs for this many days
|
||||||
|
_LOG_WRITE_RETRY_MAX = 3
|
||||||
|
_LOG_WRITE_RETRY_BASE_DELAY = 0.05
|
||||||
|
_LOG_SENTINEL = object()
|
||||||
|
_LOG_THREAD_REF: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
|
||||||
def _ensure_log_db_schema() -> None:
|
def _ensure_log_db_schema() -> None:
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(
|
conn = sqlite3.connect(
|
||||||
str(LOG_DB_PATH),
|
str(LOG_DB_PATH),
|
||||||
timeout=30.0,
|
timeout=_DB_CONNECT_TIMEOUT,
|
||||||
check_same_thread=False,
|
check_same_thread=False,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
conn.execute("PRAGMA busy_timeout = 30000")
|
conn.execute(f"PRAGMA busy_timeout = {_DB_BUSY_TIMEOUT_MS}")
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA synchronous=NORMAL")
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -328,15 +335,20 @@ def _log_worker_loop() -> None:
|
|||||||
"""
|
"""
|
||||||
global _LOG_WRITE_COUNT
|
global _LOG_WRITE_COUNT
|
||||||
while True:
|
while True:
|
||||||
level, module, message = _LOG_QUEUE.get()
|
item = _LOG_QUEUE.get()
|
||||||
|
if item is _LOG_SENTINEL:
|
||||||
|
break
|
||||||
|
level, module, message = item
|
||||||
try:
|
try:
|
||||||
attempts = 0
|
attempts = 0
|
||||||
written = False
|
written = False
|
||||||
while attempts < 3 and not written:
|
while attempts < _LOG_WRITE_RETRY_MAX and not written:
|
||||||
|
conn = None
|
||||||
|
cur = None
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(str(LOG_DB_PATH), timeout=30.0)
|
conn = sqlite3.connect(str(LOG_DB_PATH), timeout=_DB_CONNECT_TIMEOUT)
|
||||||
try:
|
try:
|
||||||
conn.execute("PRAGMA busy_timeout = 30000")
|
conn.execute(f"PRAGMA busy_timeout = {_DB_BUSY_TIMEOUT_MS}")
|
||||||
conn.execute("PRAGMA journal_mode=WAL")
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
conn.execute("PRAGMA synchronous=NORMAL")
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
except sqlite3.Error:
|
except sqlite3.Error:
|
||||||
@@ -352,13 +364,11 @@ def _log_worker_loop() -> None:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
written = True
|
written = True
|
||||||
except sqlite3.OperationalError as exc:
|
except sqlite3.OperationalError as exc:
|
||||||
attempts += 1
|
attempts += 1
|
||||||
if 'locked' in str(exc).lower():
|
if 'locked' in str(exc).lower():
|
||||||
time.sleep(0.05 * attempts)
|
time.sleep(_LOG_WRITE_RETRY_BASE_DELAY * attempts)
|
||||||
continue
|
continue
|
||||||
# Non-lock operational errors: abort attempts
|
# Non-lock operational errors: abort attempts
|
||||||
log(f"Warning: Failed to write log entry (operational): {exc}")
|
log(f"Warning: Failed to write log entry (operational): {exc}")
|
||||||
@@ -366,6 +376,17 @@ def _log_worker_loop() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"Warning: Failed to write log entry: {exc}")
|
log(f"Warning: Failed to write log entry: {exc}")
|
||||||
break
|
break
|
||||||
|
finally:
|
||||||
|
if cur is not None:
|
||||||
|
try:
|
||||||
|
cur.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if conn is not None:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if not written:
|
if not written:
|
||||||
# Fallback to a file-based log so we never lose the message silently
|
# Fallback to a file-based log so we never lose the message silently
|
||||||
try:
|
try:
|
||||||
@@ -399,7 +420,7 @@ def _log_worker_loop() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_log_thread() -> None:
|
def _ensure_log_thread() -> None:
|
||||||
global _LOG_THREAD_STARTED
|
global _LOG_THREAD_STARTED, _LOG_THREAD_REF
|
||||||
if _LOG_THREAD_STARTED:
|
if _LOG_THREAD_STARTED:
|
||||||
return
|
return
|
||||||
with _LOG_THREAD_LOCK:
|
with _LOG_THREAD_LOCK:
|
||||||
@@ -411,8 +432,24 @@ def _ensure_log_thread() -> None:
|
|||||||
daemon=True
|
daemon=True
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
_LOG_THREAD_REF = thread
|
||||||
_LOG_THREAD_STARTED = True
|
_LOG_THREAD_STARTED = True
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_log_thread() -> None:
|
||||||
|
global _LOG_THREAD_REF
|
||||||
|
_LOG_QUEUE.put(_LOG_SENTINEL)
|
||||||
|
thread = _LOG_THREAD_REF
|
||||||
|
if thread is not None:
|
||||||
|
try:
|
||||||
|
thread.join(timeout=3.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_LOG_THREAD_REF = None
|
||||||
|
|
||||||
|
|
||||||
|
atexit.register(_shutdown_log_thread)
|
||||||
|
|
||||||
def get_db() -> Database:
|
def get_db() -> Database:
|
||||||
return db
|
return db
|
||||||
|
|
||||||
@@ -533,7 +570,11 @@ def get_config_all() -> Dict[str, Any]:
|
|||||||
|
|
||||||
# Worker Management Methods for medios.db
|
# Worker Management Methods for medios.db
|
||||||
|
|
||||||
def _worker_db_connect(timeout: float = 0.75) -> sqlite3.Connection:
|
_WORKER_DB_DEFAULT_TIMEOUT = 0.75
|
||||||
|
_WORKER_DB_DEFAULT_RETRIES = 1
|
||||||
|
_WORKER_DB_RETRY_BASE_DELAY = 0.05
|
||||||
|
|
||||||
|
def _worker_db_connect(timeout: float = _WORKER_DB_DEFAULT_TIMEOUT) -> sqlite3.Connection:
|
||||||
conn = sqlite3.connect(
|
conn = sqlite3.connect(
|
||||||
str(DB_PATH),
|
str(DB_PATH),
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
@@ -555,8 +596,8 @@ def _worker_db_execute(
|
|||||||
params: tuple = (),
|
params: tuple = (),
|
||||||
*,
|
*,
|
||||||
fetch: Optional[str] = None,
|
fetch: Optional[str] = None,
|
||||||
timeout: float = 0.75,
|
timeout: float = _WORKER_DB_DEFAULT_TIMEOUT,
|
||||||
retries: int = 1,
|
retries: int = _WORKER_DB_DEFAULT_RETRIES,
|
||||||
) -> Any:
|
) -> Any:
|
||||||
attempts = 0
|
attempts = 0
|
||||||
while True:
|
while True:
|
||||||
@@ -580,7 +621,7 @@ def _worker_db_execute(
|
|||||||
msg = str(exc).lower()
|
msg = str(exc).lower()
|
||||||
if "locked" in msg and attempts < retries:
|
if "locked" in msg and attempts < retries:
|
||||||
attempts += 1
|
attempts += 1
|
||||||
time.sleep(0.05 * attempts)
|
time.sleep(_WORKER_DB_RETRY_BASE_DELAY * attempts)
|
||||||
continue
|
continue
|
||||||
raise
|
raise
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
+12
-5
@@ -1,9 +1,11 @@
|
|||||||
"""Simple HTTP file server for serving files in web mode."""
|
"""Simple HTTP file server for serving files in web mode."""
|
||||||
|
|
||||||
|
import atexit
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
import socket
|
import socket
|
||||||
import logging
|
import logging
|
||||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import mimetypes
|
import mimetypes
|
||||||
@@ -11,9 +13,12 @@ import urllib.parse
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_DNS_FALLBACK_HOST = os.environ.get("MM_DNS_SERVER", "8.8.8.8")
|
||||||
|
_DNS_FALLBACK_PORT = 80
|
||||||
|
|
||||||
# Global server instance
|
# Global server instance
|
||||||
_file_server: Optional[HTTPServer] = None
|
_file_server: Optional[ThreadingHTTPServer] = None
|
||||||
_server_thread: Optional[threading.Thread] = None
|
_file_server_thread: Optional[threading.Thread] = None
|
||||||
_server_port: int = 8001
|
_server_port: int = 8001
|
||||||
|
|
||||||
|
|
||||||
@@ -84,7 +89,7 @@ def get_local_ip() -> Optional[str]:
|
|||||||
try:
|
try:
|
||||||
# Connect to a remote server to determine local IP
|
# Connect to a remote server to determine local IP
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
s.connect(("8.8.8.8", 80))
|
s.connect((_DNS_FALLBACK_HOST, _DNS_FALLBACK_PORT))
|
||||||
ip = s.getsockname()[0]
|
ip = s.getsockname()[0]
|
||||||
s.close()
|
s.close()
|
||||||
return ip
|
return ip
|
||||||
@@ -116,7 +121,7 @@ def start_file_server(port: int = 8001) -> Optional[str]:
|
|||||||
|
|
||||||
# Create server
|
# Create server
|
||||||
server_address = ("", port)
|
server_address = ("", port)
|
||||||
_file_server = HTTPServer(server_address, FileServerHandler)
|
_file_server = ThreadingHTTPServer(server_address, FileServerHandler)
|
||||||
|
|
||||||
# Start in daemon thread
|
# Start in daemon thread
|
||||||
_server_thread = threading.Thread(
|
_server_thread = threading.Thread(
|
||||||
@@ -125,6 +130,8 @@ def start_file_server(port: int = 8001) -> Optional[str]:
|
|||||||
)
|
)
|
||||||
_server_thread.start()
|
_server_thread.start()
|
||||||
|
|
||||||
|
atexit.register(stop_file_server)
|
||||||
|
|
||||||
logger.info(f"File server started on port {port}")
|
logger.info(f"File server started on port {port}")
|
||||||
|
|
||||||
# Get local IP
|
# Get local IP
|
||||||
|
|||||||
+5
-90
@@ -523,8 +523,8 @@ def _read_sidecar_metadata(
|
|||||||
lower = line.lower()
|
lower = line.lower()
|
||||||
if lower.startswith("hash:"):
|
if lower.startswith("hash:"):
|
||||||
hash_value = line.split(":", 1)[1].strip() if ":" in line else ""
|
hash_value = line.split(":", 1)[1].strip() if ":" in line else ""
|
||||||
elif lower.startswith("url:") or lower.startswith("url:"):
|
elif lower.startswith("url:"):
|
||||||
# Parse url (handle legacy 'url:' format)
|
# Parse url
|
||||||
url_part = line.split(":", 1)[1].strip() if ":" in line else ""
|
url_part = line.split(":", 1)[1].strip() if ":" in line else ""
|
||||||
if url_part:
|
if url_part:
|
||||||
for url_segment in url_part.split(","):
|
for url_segment in url_part.split(","):
|
||||||
@@ -638,44 +638,7 @@ def write_tags(
|
|||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
debug(f"Failed to add tags to database: {e}", file=sys.stderr)
|
debug(f"Failed to add tags to database: {e}", file=sys.stderr)
|
||||||
# Fall through to sidecar creation as fallback
|
return
|
||||||
|
|
||||||
# Create sidecar path
|
|
||||||
try:
|
|
||||||
sidecar = media_path.parent / (media_path.name + ".tag")
|
|
||||||
except Exception:
|
|
||||||
sidecar = media_path.with_name(media_path.name + ".tag")
|
|
||||||
|
|
||||||
# Handle edge case: empty/invalid base name
|
|
||||||
try:
|
|
||||||
if not sidecar.stem or sidecar.name in {".tag",
|
|
||||||
"-.tag",
|
|
||||||
"_.tag"}:
|
|
||||||
fallback_base = (
|
|
||||||
media_path.stem
|
|
||||||
or _sanitize_title_for_filename(extract_title(tag_list) or "")
|
|
||||||
or "untitled"
|
|
||||||
)
|
|
||||||
sidecar = media_path.parent / f"{fallback_base}.tag"
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to determine fallback .tag sidecar base for %s", media_path)
|
|
||||||
|
|
||||||
# Write via consolidated function
|
|
||||||
try:
|
|
||||||
lines: List[str] = []
|
|
||||||
lines.extend(str(tag).strip().lower() for tag in tag_list if str(tag).strip())
|
|
||||||
|
|
||||||
if lines:
|
|
||||||
sidecar.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
||||||
if emit_debug:
|
|
||||||
debug(f"Tags: {sidecar}")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
sidecar.unlink()
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
except OSError as exc:
|
|
||||||
debug(f"Failed to write tag sidecar {sidecar}: {exc}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def write_metadata(
|
def write_metadata(
|
||||||
@@ -729,45 +692,7 @@ def write_metadata(
|
|||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
debug(f"Failed to add metadata to database: {e}", file=sys.stderr)
|
debug(f"Failed to add metadata to database: {e}", file=sys.stderr)
|
||||||
# Fall through to sidecar creation as fallback
|
return
|
||||||
|
|
||||||
# Create sidecar path
|
|
||||||
try:
|
|
||||||
sidecar = media_path.parent / (media_path.name + ".metadata")
|
|
||||||
except Exception:
|
|
||||||
sidecar = media_path.with_name(media_path.name + ".metadata")
|
|
||||||
|
|
||||||
try:
|
|
||||||
lines = []
|
|
||||||
|
|
||||||
# Add hash if available
|
|
||||||
if hash_value:
|
|
||||||
lines.append(f"hash:{hash_value}")
|
|
||||||
|
|
||||||
# Add known url
|
|
||||||
for url in url_list:
|
|
||||||
if str(url).strip():
|
|
||||||
clean = str(url).strip()
|
|
||||||
lines.append(f"url:{clean}")
|
|
||||||
|
|
||||||
# Add relationships
|
|
||||||
for rel in rel_list:
|
|
||||||
if str(rel).strip():
|
|
||||||
lines.append(f"relationship:{str(rel).strip()}")
|
|
||||||
|
|
||||||
# Write metadata file
|
|
||||||
if lines:
|
|
||||||
sidecar.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
||||||
if emit_debug:
|
|
||||||
debug(f"Wrote metadata to {sidecar}")
|
|
||||||
else:
|
|
||||||
# Remove if no content
|
|
||||||
try:
|
|
||||||
sidecar.unlink()
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
except OSError as exc:
|
|
||||||
debug(f"Failed to write metadata sidecar {sidecar}: {exc}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_title(tags: Iterable[str]) -> Optional[str]:
|
def extract_title(tags: Iterable[str]) -> Optional[str]:
|
||||||
@@ -2864,17 +2789,7 @@ def build_ffmpeg_command(
|
|||||||
"192k",
|
"192k",
|
||||||
])
|
])
|
||||||
cmd.extend(["-f", "opus"])
|
cmd.extend(["-f", "opus"])
|
||||||
elif fmt == "audio":
|
if fmt not in ("mp4", "webm", "mp3", "flac", "wav", "aac", "m4a", "ogg", "opus", "copy"):
|
||||||
# Legacy format name for mp3
|
|
||||||
cmd.extend([
|
|
||||||
"-vn",
|
|
||||||
"-c:a",
|
|
||||||
"libmp3lame",
|
|
||||||
"-b:a",
|
|
||||||
"192k",
|
|
||||||
])
|
|
||||||
cmd.extend(["-f", "mp3"])
|
|
||||||
elif fmt != "copy":
|
|
||||||
raise ValueError(f"Unsupported format: {fmt}")
|
raise ValueError(f"Unsupported format: {fmt}")
|
||||||
|
|
||||||
cmd.append(str(output_path))
|
cmd.append(str(output_path))
|
||||||
|
|||||||
+12
-3314
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+41
-158
@@ -45,22 +45,6 @@ def _rich():
|
|||||||
return _rich_mod
|
return _rich_mod
|
||||||
|
|
||||||
|
|
||||||
# Optional Textual imports - lazily loaded to avoid pulling in ~300ms of textual
|
|
||||||
# at import time when the TUI is not being used.
|
|
||||||
import importlib.util as _importlib_util
|
|
||||||
TEXTUAL_AVAILABLE: bool = _importlib_util.find_spec("textual") is not None
|
|
||||||
|
|
||||||
# Tree is populated lazily on first call to build_metadata_tree().
|
|
||||||
_textual_Tree: Any = None
|
|
||||||
|
|
||||||
|
|
||||||
def _get_textual_Tree() -> Any:
|
|
||||||
global _textual_Tree
|
|
||||||
if _textual_Tree is None:
|
|
||||||
from textual.widgets import Tree as _Tree
|
|
||||||
_textual_Tree = _Tree
|
|
||||||
return _textual_Tree
|
|
||||||
|
|
||||||
|
|
||||||
# Import ResultModel from the API for typing; avoid runtime redefinition issues
|
# Import ResultModel from the API for typing; avoid runtime redefinition issues
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -69,6 +53,8 @@ if TYPE_CHECKING:
|
|||||||
else:
|
else:
|
||||||
ResultModel = None # type: ignore[assignment]
|
ResultModel = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Reuse the existing format_bytes helper under a clearer alias
|
# Reuse the existing format_bytes helper under a clearer alias
|
||||||
from SYS.utils import format_bytes as format_mb
|
from SYS.utils import format_bytes as format_mb
|
||||||
|
|
||||||
@@ -587,30 +573,6 @@ class InputOption:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TUIResultCard:
|
|
||||||
"""Represents a result as a UI card with title, metadata, and actions.
|
|
||||||
|
|
||||||
Used in hub-ui and TUI contexts to render individual search results
|
|
||||||
as grouped components with visual structure.
|
|
||||||
"""
|
|
||||||
|
|
||||||
title: str
|
|
||||||
subtitle: Optional[str] = None
|
|
||||||
metadata: Optional[Dict[str, str]] = None
|
|
||||||
media_kind: Optional[str] = None
|
|
||||||
tag: Optional[List[str]] = None
|
|
||||||
file_hash: Optional[str] = None
|
|
||||||
file_size: Optional[str] = None
|
|
||||||
duration: Optional[str] = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
"""Initialize default values."""
|
|
||||||
if self.metadata is None:
|
|
||||||
self.metadata = {}
|
|
||||||
if self.tag is None:
|
|
||||||
self.tag = []
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Column:
|
class Column:
|
||||||
@@ -2113,122 +2075,6 @@ class Table:
|
|||||||
return self.rows[idx]
|
return self.rows[idx]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# TUI-specific formatting methods
|
|
||||||
|
|
||||||
def to_datatable_rows(self, source: str = "unknown") -> List[List[str]]:
|
|
||||||
"""Convert results to rows suitable for Textual DataTable widget.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source: Source type for formatting context (openlibrary, soulseek, etc.)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of row value lists
|
|
||||||
"""
|
|
||||||
rows = []
|
|
||||||
for result in self.rows:
|
|
||||||
row_values = self._format_datatable_row(result, source)
|
|
||||||
rows.append(row_values)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
def _format_datatable_row(self,
|
|
||||||
row: Row,
|
|
||||||
source: str = "unknown") -> List[str]:
|
|
||||||
"""Format a ResultRow for DataTable display.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
row: ResultRow to format
|
|
||||||
source: Source type
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of column values as strings
|
|
||||||
"""
|
|
||||||
# Extract values from row columns
|
|
||||||
values = [col.value for col in row.columns]
|
|
||||||
|
|
||||||
# Truncate to reasonable lengths for table display
|
|
||||||
return [v[:60] if len(v) > 60 else v for v in values]
|
|
||||||
|
|
||||||
def to_result_cards(self) -> List[TUIResultCard]:
|
|
||||||
"""Convert all rows to TUIResultCard objects for card-based UI display.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of TUIResultCard objects
|
|
||||||
"""
|
|
||||||
cards = []
|
|
||||||
for row in self.rows:
|
|
||||||
card = self._row_to_card(row)
|
|
||||||
cards.append(card)
|
|
||||||
return cards
|
|
||||||
|
|
||||||
def _row_to_card(self, row: Row) -> TUIResultCard:
|
|
||||||
"""Convert a ResultRow to a TUIResultCard.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
row: ResultRow to convert
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TUIResultCard with extracted metadata
|
|
||||||
"""
|
|
||||||
# Build metadata dict from row columns
|
|
||||||
metadata = {}
|
|
||||||
title = ""
|
|
||||||
|
|
||||||
for col in row.columns:
|
|
||||||
if col.name.lower() == "title":
|
|
||||||
title = col.value
|
|
||||||
metadata[col.name] = col.value
|
|
||||||
|
|
||||||
# Extract tag values if present
|
|
||||||
tag = []
|
|
||||||
if "Tag" in metadata:
|
|
||||||
tag_val = metadata["Tag"]
|
|
||||||
if tag_val:
|
|
||||||
tag = [t.strip() for t in tag_val.split(",")][:5]
|
|
||||||
|
|
||||||
# Try to find useful metadata fields
|
|
||||||
subtitle = metadata.get("Artist", metadata.get("Author", ""))
|
|
||||||
media_kind = metadata.get("Type", metadata.get("Media Kind", ""))
|
|
||||||
file_size = metadata.get("Size", "")
|
|
||||||
duration = metadata.get("Duration", "")
|
|
||||||
file_hash = metadata.get("Hash", "")
|
|
||||||
|
|
||||||
return TUIResultCard(
|
|
||||||
title=title or "Unknown",
|
|
||||||
subtitle=subtitle,
|
|
||||||
metadata=metadata,
|
|
||||||
media_kind=media_kind,
|
|
||||||
tag=tag,
|
|
||||||
file_hash=file_hash or None,
|
|
||||||
file_size=file_size or None,
|
|
||||||
duration=duration or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
def build_metadata_tree(self, tree_widget: "Tree") -> None:
|
|
||||||
"""Populate a Textual Tree widget with result metadata hierarchy.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tree_widget: Textual Tree widget to populate
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ImportError: If Textual not available
|
|
||||||
"""
|
|
||||||
if not TEXTUAL_AVAILABLE:
|
|
||||||
raise ImportError("Textual not available for tree building")
|
|
||||||
|
|
||||||
tree_widget.reset(self.title or "Results")
|
|
||||||
root = tree_widget.root
|
|
||||||
|
|
||||||
# Add each row as a top-level node
|
|
||||||
for i, row in enumerate(self.rows, 1):
|
|
||||||
row_node = root.add(f"[bold]Result {i}[/bold]")
|
|
||||||
|
|
||||||
# Add columns as children
|
|
||||||
for col in row.columns:
|
|
||||||
value_str = col.value
|
|
||||||
if len(value_str) > 100:
|
|
||||||
value_str = value_str[:97] + "..."
|
|
||||||
row_node.add_leaf(f"[cyan]{col.name}[/cyan]: {value_str}")
|
|
||||||
|
|
||||||
|
|
||||||
def _format_size(size: Any, integer_only: bool = False) -> str:
|
def _format_size(size: Any, integer_only: bool = False) -> str:
|
||||||
"""Format file size as human-readable string.
|
"""Format file size as human-readable string.
|
||||||
@@ -2577,9 +2423,46 @@ class ItemDetailView(Table):
|
|||||||
return f"{label[:max_len - 3]}..."
|
return f"{label[:max_len - 3]}..."
|
||||||
return label
|
return label
|
||||||
|
|
||||||
|
def _looks_like_local_path(value: Any) -> bool:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
p = Path(text)
|
||||||
|
if p.exists():
|
||||||
|
return True
|
||||||
|
return text.startswith("/") or text.startswith("\\\\") or (len(text) >= 2 and text[1] == ":" and text[2:3] in ("\\", "/"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _short_path_label(path_value: str, *, max_len: int = 68) -> str:
|
||||||
|
text = str(path_value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if len(text) <= max_len:
|
||||||
|
return text
|
||||||
|
parts = text.replace("\\", "/").rstrip("/").split("/")
|
||||||
|
if len(parts) >= 3:
|
||||||
|
head = parts[0]
|
||||||
|
tail = parts[-1]
|
||||||
|
mid = "..."
|
||||||
|
label = f"{head}/{mid}/{tail}"
|
||||||
|
if len(label) > max_len:
|
||||||
|
return f"{text[:max_len - 3]}..."
|
||||||
|
return label
|
||||||
|
return f"{text[:max_len - 3]}..."
|
||||||
|
|
||||||
def _render_detail_value(key: str, value: Any) -> Any:
|
def _render_detail_value(key: str, value: Any) -> Any:
|
||||||
# Keep terminal links compact while preserving full click target.
|
key_lower = str(key or "").strip().lower()
|
||||||
if str(key or "").strip().lower() in {"path", "url"} and _looks_like_http_url(value):
|
if key_lower == "path" and _looks_like_local_path(value):
|
||||||
|
full_path = str(value).strip()
|
||||||
|
try:
|
||||||
|
file_uri = Path(full_path).as_uri()
|
||||||
|
except Exception:
|
||||||
|
return str(value)
|
||||||
|
label = _short_path_label(full_path)
|
||||||
|
return _rich().Text(label, style=f"underline cyan link {file_uri}")
|
||||||
|
if key_lower in {"path", "url"} and _looks_like_http_url(value):
|
||||||
full_url = str(value).strip()
|
full_url = str(value).strip()
|
||||||
label = _short_link_label(full_url)
|
label = _short_link_label(full_url)
|
||||||
return _rich().Text(label, style=f"underline cyan link {full_url}")
|
return _rich().Text(label, style=f"underline cyan link {full_url}")
|
||||||
|
|||||||
+1
-1
@@ -109,7 +109,7 @@ def show_plugin_config_panel(
|
|||||||
group = Group(
|
group = Group(
|
||||||
Text("The following plugins are not configured and cannot be used:\n"),
|
Text("The following plugins are not configured and cannot be used:\n"),
|
||||||
table,
|
table,
|
||||||
Text.from_markup("\nTo configure them, run the command with [bold cyan].config[/bold cyan] or use the [bold green]TUI[/bold green] config menu.")
|
Text.from_markup("\nTo configure them, use the [bold cyan].config[/bold cyan] command in the REPL.")
|
||||||
)
|
)
|
||||||
|
|
||||||
panel = Panel(
|
panel = Panel(
|
||||||
|
|||||||
@@ -260,6 +260,11 @@ class WorkerLoggingHandler(logging.StreamHandler):
|
|||||||
super().close()
|
super().close()
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_STDOUT_FLUSH_BYTES = 4096
|
||||||
|
_DEFAULT_STDOUT_FLUSH_INTERVAL = 0.75
|
||||||
|
_REFRESH_THREAD_JOIN_TIMEOUT = 5
|
||||||
|
|
||||||
|
|
||||||
class WorkerManager:
|
class WorkerManager:
|
||||||
"""Manages persistent worker tasks using the central medios.db."""
|
"""Manages persistent worker tasks using the central medios.db."""
|
||||||
|
|
||||||
@@ -284,8 +289,8 @@ class WorkerManager:
|
|||||||
self._stdout_buffer_sizes: Dict[Tuple[str, str], int] = {}
|
self._stdout_buffer_sizes: Dict[Tuple[str, str], int] = {}
|
||||||
self._stdout_buffer_steps: Dict[Tuple[str, str], Optional[str]] = {}
|
self._stdout_buffer_steps: Dict[Tuple[str, str], Optional[str]] = {}
|
||||||
self._stdout_last_flush: Dict[Tuple[str, str], float] = {}
|
self._stdout_last_flush: Dict[Tuple[str, str], float] = {}
|
||||||
self._stdout_flush_bytes = 4096
|
self._stdout_flush_bytes = _DEFAULT_STDOUT_FLUSH_BYTES
|
||||||
self._stdout_flush_interval = 0.75
|
self._stdout_flush_interval = _DEFAULT_STDOUT_FLUSH_INTERVAL
|
||||||
|
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
@@ -707,7 +712,7 @@ class WorkerManager:
|
|||||||
self._stop_refresh = True
|
self._stop_refresh = True
|
||||||
self._refresh_stop_event.set()
|
self._refresh_stop_event.set()
|
||||||
if self.refresh_thread:
|
if self.refresh_thread:
|
||||||
self.refresh_thread.join(timeout=5)
|
self.refresh_thread.join(timeout=_REFRESH_THREAD_JOIN_TIMEOUT)
|
||||||
self.refresh_thread = None
|
self.refresh_thread = None
|
||||||
|
|
||||||
def _start_refresh_if_needed(self) -> None:
|
def _start_refresh_if_needed(self) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
"""UI/display helpers for Rich output, saved-output panels, and result persistence.
|
||||||
|
|
||||||
|
Contains utilities for:
|
||||||
|
- Printing to stderr safely without disrupting Rich Live display
|
||||||
|
- Rendering saved output panels when ``-path`` is used
|
||||||
|
- Persisting items for ``@N`` selection across command boundaries
|
||||||
|
- Formatting byte sizes for display
|
||||||
|
- Moving emitted temp/PATH artifacts to user-specified destinations
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from contextlib import AbstractContextManager, nullcontext
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
|
||||||
|
from SYS.logger import log
|
||||||
|
from SYS.result_publication import publish_result_table
|
||||||
|
from SYS.result_table import Table
|
||||||
|
from SYS.rich_display import stderr_console as get_stderr_console
|
||||||
|
from ._pipeobject_utils import get_field, get_pipe_object_path
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"_print_live_safe_stderr",
|
||||||
|
"_print_saved_output_panel",
|
||||||
|
"_apply_saved_path_update",
|
||||||
|
"_unique_destination_path",
|
||||||
|
"_extract_flag_value",
|
||||||
|
"apply_output_path_from_pipeobjects",
|
||||||
|
"display_and_persist_items",
|
||||||
|
"fmt_bytes",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_bytes(n: Optional[int]) -> str:
|
||||||
|
"""Format bytes as human-readable with 1 decimal place (MB/GB).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
n: Number of bytes, or None
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted string like "1.5 MB" or "2.0 GB", or "unknown"
|
||||||
|
"""
|
||||||
|
if n is None or n < 0:
|
||||||
|
return "unknown"
|
||||||
|
gb = n / (1024.0 * 1024.0 * 1024.0)
|
||||||
|
if gb >= 1.0:
|
||||||
|
return f"{gb:.1f} GB"
|
||||||
|
mb = n / (1024.0 * 1024.0)
|
||||||
|
return f"{mb:.1f} MB"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_flag_value(args: Sequence[str], *flags: str) -> Optional[str]:
|
||||||
|
"""Return the value for the first matching flag in args.
|
||||||
|
|
||||||
|
This is intentionally lightweight (no cmdlet spec required) so callers in CLI/pipeline
|
||||||
|
can share the same behavior.
|
||||||
|
"""
|
||||||
|
if not args:
|
||||||
|
return None
|
||||||
|
want = {str(f).strip().lower()
|
||||||
|
for f in flags if str(f).strip()}
|
||||||
|
if not want:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
tokens = [str(a) for a in args]
|
||||||
|
except Exception:
|
||||||
|
tokens = list(args) # type: ignore[list-item]
|
||||||
|
for i, tok in enumerate(tokens):
|
||||||
|
low = str(tok).strip().lower()
|
||||||
|
if low in want:
|
||||||
|
if i + 1 >= len(tokens):
|
||||||
|
return None
|
||||||
|
nxt = str(tokens[i + 1])
|
||||||
|
if not nxt.strip():
|
||||||
|
return None
|
||||||
|
if nxt.startswith("-"):
|
||||||
|
return None
|
||||||
|
return nxt
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_destination_path(dest: Path) -> Path:
|
||||||
|
"""Generate a non-colliding destination path by appending " (N)"."""
|
||||||
|
try:
|
||||||
|
if not dest.exists():
|
||||||
|
return dest
|
||||||
|
except Exception:
|
||||||
|
return dest
|
||||||
|
|
||||||
|
parent = dest.parent
|
||||||
|
stem = dest.stem
|
||||||
|
suffix = dest.suffix
|
||||||
|
for i in range(1, 10_000):
|
||||||
|
candidate = parent / f"{stem} ({i}){suffix}"
|
||||||
|
try:
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
except Exception:
|
||||||
|
return candidate
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def _print_live_safe_stderr(message: str) -> None:
|
||||||
|
"""Print to stderr without breaking Rich Live progress output."""
|
||||||
|
try:
|
||||||
|
from SYS.rich_display import stderr_console # type: ignore
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
cm: AbstractContextManager[Any] | None = None
|
||||||
|
try:
|
||||||
|
from SYS import pipeline as _pipeline_ctx # type: ignore
|
||||||
|
|
||||||
|
suspend = getattr(_pipeline_ctx, "suspend_live_progress", None)
|
||||||
|
candidate = suspend() if callable(suspend) else None
|
||||||
|
if isinstance(candidate, AbstractContextManager):
|
||||||
|
cm = candidate
|
||||||
|
elif candidate is not None and hasattr(candidate, "__enter__") and hasattr(candidate, "__exit__"):
|
||||||
|
cm = candidate # type: ignore[arg-type]
|
||||||
|
except Exception:
|
||||||
|
cm = None
|
||||||
|
|
||||||
|
if cm is None:
|
||||||
|
cm = nullcontext()
|
||||||
|
|
||||||
|
try:
|
||||||
|
console = stderr_console()
|
||||||
|
print_func = getattr(console, "print", None)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not callable(print_func):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with cm:
|
||||||
|
print_func(str(message))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _print_saved_output_panel(item: Any, final_path: Path) -> None:
|
||||||
|
"""When -path is used, print a Rich panel summarizing the saved output.
|
||||||
|
|
||||||
|
Shows: Title, Location, Hash.
|
||||||
|
Best-effort: reads existing fields first to avoid recomputing hashes.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from rich.panel import Panel # type: ignore
|
||||||
|
from rich.table import Table as RichTable # type: ignore
|
||||||
|
from SYS.rich_display import stderr_console # type: ignore
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
cm: AbstractContextManager[Any] | None = None
|
||||||
|
try:
|
||||||
|
from SYS import pipeline as _pipeline_ctx # type: ignore
|
||||||
|
|
||||||
|
suspend = getattr(_pipeline_ctx, "suspend_live_progress", None)
|
||||||
|
cm_candidate = suspend() if callable(suspend) else None
|
||||||
|
if isinstance(cm_candidate, AbstractContextManager):
|
||||||
|
cm = cm_candidate
|
||||||
|
elif cm_candidate is not None and hasattr(cm_candidate, "__enter__") and hasattr(cm_candidate, "__exit__"):
|
||||||
|
cm = cm_candidate # type: ignore[arg-type]
|
||||||
|
except Exception:
|
||||||
|
cm = None
|
||||||
|
|
||||||
|
if cm is None:
|
||||||
|
cm = nullcontext()
|
||||||
|
|
||||||
|
try:
|
||||||
|
location = str(final_path)
|
||||||
|
except Exception:
|
||||||
|
location = ""
|
||||||
|
|
||||||
|
title = ""
|
||||||
|
try:
|
||||||
|
title = str(get_field(item, "title") or get_field(item, "name") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
title = ""
|
||||||
|
if not title:
|
||||||
|
try:
|
||||||
|
title = str(final_path.stem or final_path.name)
|
||||||
|
except Exception:
|
||||||
|
title = ""
|
||||||
|
|
||||||
|
file_hash = ""
|
||||||
|
try:
|
||||||
|
file_hash = str(get_field(item, "hash") or get_field(item, "sha256") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
file_hash = ""
|
||||||
|
if not file_hash:
|
||||||
|
try:
|
||||||
|
from SYS.utils import sha256_file # type: ignore
|
||||||
|
|
||||||
|
file_hash = str(sha256_file(final_path) or "").strip()
|
||||||
|
except Exception:
|
||||||
|
file_hash = ""
|
||||||
|
|
||||||
|
grid = RichTable.grid(padding=(0, 1))
|
||||||
|
grid.add_column(justify="right", style="bold")
|
||||||
|
grid.add_column()
|
||||||
|
grid.add_row("Title", title or "(unknown)")
|
||||||
|
grid.add_row("Location", location or "(unknown)")
|
||||||
|
grid.add_row("Hash", file_hash or "(unknown)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
console = stderr_console()
|
||||||
|
print_func = getattr(console, "print", None)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not callable(print_func):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with cm:
|
||||||
|
print_func(Panel(grid, title="Saved", expand=False))
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_saved_path_update(item: Any, *, old_path: str, new_path: str) -> None:
|
||||||
|
"""Update a PipeObject-like item after its backing file has moved."""
|
||||||
|
old_str = str(old_path)
|
||||||
|
new_str = str(new_path)
|
||||||
|
if isinstance(item, dict):
|
||||||
|
try:
|
||||||
|
if str(item.get("path") or "") == old_str:
|
||||||
|
item["path"] = new_str
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if str(item.get("target") or "") == old_str:
|
||||||
|
item["target"] = new_str
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
extra = item.get("extra")
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
if str(extra.get("target") or "") == old_str:
|
||||||
|
extra["target"] = new_str
|
||||||
|
if str(extra.get("path") or "") == old_str:
|
||||||
|
extra["path"] = new_str
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if getattr(item, "path", None) == old_str:
|
||||||
|
setattr(item, "path", new_str)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
extra = getattr(item, "extra", None)
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
if str(extra.get("target") or "") == old_str:
|
||||||
|
extra["target"] = new_str
|
||||||
|
if str(extra.get("path") or "") == old_str:
|
||||||
|
extra["path"] = new_str
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def apply_output_path_from_pipeobjects(
|
||||||
|
*,
|
||||||
|
cmd_name: str,
|
||||||
|
args: Sequence[str],
|
||||||
|
emits: Sequence[Any],
|
||||||
|
) -> List[Any]:
|
||||||
|
"""If the user supplied `-path`, move emitted temp/PATH files there.
|
||||||
|
|
||||||
|
This enables a dynamic pattern:
|
||||||
|
- Any cmdlet can include `SharedArgs.PATH`.
|
||||||
|
- If it emits a file-backed PipeObject (`path` exists on disk) and the item is
|
||||||
|
a temp/PATH artifact, then `-path <dest>` will save it to that location.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Only affects items whose `action` matches the current cmdlet.
|
||||||
|
- Only affects items that look like local artifacts (`is_temp` True or `store` == PATH).
|
||||||
|
- Updates the emitted object's `path` (and `target` when it points at the same file).
|
||||||
|
"""
|
||||||
|
dest_raw = _extract_flag_value(args, "-path", "--path")
|
||||||
|
if not dest_raw:
|
||||||
|
return list(emits or [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
dest_str = str(dest_raw).strip()
|
||||||
|
if "://" in dest_str:
|
||||||
|
_print_live_safe_stderr(
|
||||||
|
f"Ignoring -path value that looks like a URL: {dest_str}"
|
||||||
|
)
|
||||||
|
return list(emits or [])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cmd_norm = str(cmd_name or "").replace("_", "-").strip().lower()
|
||||||
|
if not cmd_norm:
|
||||||
|
return list(emits or [])
|
||||||
|
|
||||||
|
try:
|
||||||
|
dest_hint_dir = str(dest_raw).endswith(("/", "\\"))
|
||||||
|
except Exception:
|
||||||
|
dest_hint_dir = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
dest_path = Path(str(dest_raw)).expanduser()
|
||||||
|
except Exception:
|
||||||
|
return list(emits or [])
|
||||||
|
|
||||||
|
items = list(emits or [])
|
||||||
|
artifact_indices: List[int] = []
|
||||||
|
artifact_paths: List[Path] = []
|
||||||
|
for idx, item in enumerate(items):
|
||||||
|
action = str(get_field(item, "action", "") or "").strip().lower()
|
||||||
|
if not action.startswith("cmdlet:"):
|
||||||
|
continue
|
||||||
|
action_name = action.split(":", 1)[-1].strip().lower()
|
||||||
|
if action_name != cmd_norm:
|
||||||
|
continue
|
||||||
|
|
||||||
|
store = str(get_field(item, "store", "") or "").strip().lower()
|
||||||
|
is_temp = bool(get_field(item, "is_temp", False))
|
||||||
|
if not (is_temp or store == "path"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
src_str = get_pipe_object_path(item)
|
||||||
|
if not src_str:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
src = Path(str(src_str)).expanduser()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if not src.exists() or not src.is_file():
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
artifact_indices.append(idx)
|
||||||
|
artifact_paths.append(src)
|
||||||
|
|
||||||
|
if not artifact_indices:
|
||||||
|
return items
|
||||||
|
|
||||||
|
if len(artifact_indices) > 1:
|
||||||
|
if dest_path.suffix:
|
||||||
|
dest_dir = dest_path.parent
|
||||||
|
else:
|
||||||
|
dest_dir = dest_path
|
||||||
|
try:
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception as exc:
|
||||||
|
_print_live_safe_stderr(
|
||||||
|
f"Failed to create destination directory: {dest_dir} ({exc})"
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
for idx, src in zip(artifact_indices, artifact_paths):
|
||||||
|
final = dest_dir / src.name
|
||||||
|
final = _unique_destination_path(final)
|
||||||
|
try:
|
||||||
|
if src.resolve() == final.resolve():
|
||||||
|
_apply_saved_path_update(
|
||||||
|
items[idx],
|
||||||
|
old_path=str(src),
|
||||||
|
new_path=str(final),
|
||||||
|
)
|
||||||
|
_print_saved_output_panel(items[idx], final)
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
shutil.move(str(src), str(final))
|
||||||
|
except Exception as exc:
|
||||||
|
_print_live_safe_stderr(f"Failed to save output to {final}: {exc}")
|
||||||
|
continue
|
||||||
|
_apply_saved_path_update(items[idx], old_path=str(src), new_path=str(final))
|
||||||
|
_print_saved_output_panel(items[idx], final)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
src = artifact_paths[0]
|
||||||
|
idx = artifact_indices[0]
|
||||||
|
final: Path
|
||||||
|
try:
|
||||||
|
if dest_hint_dir or (dest_path.exists() and dest_path.is_dir()):
|
||||||
|
final = dest_path / src.name
|
||||||
|
else:
|
||||||
|
final = dest_path
|
||||||
|
except Exception:
|
||||||
|
final = dest_path
|
||||||
|
|
||||||
|
try:
|
||||||
|
final.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception as exc:
|
||||||
|
_print_live_safe_stderr(
|
||||||
|
f"Failed to create destination directory: {final.parent} ({exc})"
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
final = _unique_destination_path(final)
|
||||||
|
try:
|
||||||
|
if src.resolve() != final.resolve():
|
||||||
|
shutil.move(str(src), str(final))
|
||||||
|
except Exception as exc:
|
||||||
|
_print_live_safe_stderr(f"Failed to save output to {final}: {exc}")
|
||||||
|
return items
|
||||||
|
|
||||||
|
_apply_saved_path_update(items[idx], old_path=str(src), new_path=str(final))
|
||||||
|
_print_saved_output_panel(items[idx], final)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def display_and_persist_items(
|
||||||
|
items: List[Any],
|
||||||
|
title: str = "Result",
|
||||||
|
subject: Optional[Any] = None,
|
||||||
|
display_type: str = "item",
|
||||||
|
table: Optional[Table] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Display detail panels and persist items for @N selection.
|
||||||
|
|
||||||
|
This helper function:
|
||||||
|
1. Renders individual detail panels for each item
|
||||||
|
2. Creates a result table with all items (or uses provided table)
|
||||||
|
3. Persists the table so @N selection works across command boundaries
|
||||||
|
|
||||||
|
Args:
|
||||||
|
items: List of items/dicts to display and make selectable
|
||||||
|
title: Title for the result table (default: "Result")
|
||||||
|
subject: Optional subject to associate with the results (usually first item or list)
|
||||||
|
display_type: Type of display - "item" (default), "tag", or "custom" (no panels)
|
||||||
|
table: Optional pre-built Table object (if None, creates new Table with title)
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from SYS import pipeline as pipeline_context
|
||||||
|
|
||||||
|
if table is None:
|
||||||
|
table = Table(title=title)
|
||||||
|
|
||||||
|
if display_type != "custom":
|
||||||
|
try:
|
||||||
|
from SYS.rich_display import render_item_details_panel
|
||||||
|
|
||||||
|
panel_prefix = "Tag" if display_type == "tag" else "Item"
|
||||||
|
|
||||||
|
for idx, item in enumerate(items, 1):
|
||||||
|
try:
|
||||||
|
render_item_details_panel(item, title=f"#{idx} {panel_prefix} Details")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not getattr(table, "_items_added", False):
|
||||||
|
for item in items:
|
||||||
|
table.add_result(item)
|
||||||
|
|
||||||
|
setattr(table, "_rendered_by_cmdlet", True)
|
||||||
|
|
||||||
|
publish_result_table(pipeline_context, table, items, subject=subject)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,686 @@
|
|||||||
|
"""PipeObject utilities for chainable cmdlet and multi-action pipelines.
|
||||||
|
|
||||||
|
Contains helpers for creating, normalizing, mutating, and extracting data from
|
||||||
|
PipeObject instances and result dicts. Also includes the canonical
|
||||||
|
``merge_sequences`` dedup utility used across cmdlets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterable as IterableABC
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from SYS import models
|
||||||
|
from SYS.item_accessors import get_field as _item_accessor_get_field
|
||||||
|
from SYS.payload_builders import build_file_result_payload
|
||||||
|
from ._store_utils import resolve_hash_for_cmdlet
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_field",
|
||||||
|
"merge_sequences",
|
||||||
|
"normalize_result_input",
|
||||||
|
"normalize_result_items",
|
||||||
|
"value_has_content",
|
||||||
|
"filter_results_by_temp",
|
||||||
|
"create_pipe_object_result",
|
||||||
|
"mark_as_temp",
|
||||||
|
"set_parent_hash",
|
||||||
|
"get_pipe_object_path",
|
||||||
|
"get_pipe_object_hash",
|
||||||
|
"coerce_to_pipe_object",
|
||||||
|
"resolve_item_store_hash",
|
||||||
|
"propagate_metadata",
|
||||||
|
"extract_relationships",
|
||||||
|
"extract_duration",
|
||||||
|
"pipeline_item_local_path",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_field(obj: Any, field: str, default: Optional[Any] = None) -> Any:
|
||||||
|
"""Extract a field from either a dict or object with fallback default.
|
||||||
|
|
||||||
|
Handles both dict.get(field) and getattr(obj, field) access patterns.
|
||||||
|
Also handles lists by accessing the first element.
|
||||||
|
For PipeObjects, checks the extra field as well.
|
||||||
|
Used throughout cmdlet to uniformly access fields from mixed types.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj: Dict, object, or list to extract from
|
||||||
|
field: Field name to retrieve
|
||||||
|
default: Value to return if field not found (default: None)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Field value if found, otherwise the default value
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
get_field(result, "hash") # From dict or object
|
||||||
|
get_field(result, "table", "unknown") # With default
|
||||||
|
"""
|
||||||
|
return _item_accessor_get_field(obj, field, default)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_sequences(*sources: Optional[Iterable[Any]],
|
||||||
|
case_sensitive: bool = True) -> list[str]:
|
||||||
|
"""Merge iterable sources while preserving order and removing duplicates."""
|
||||||
|
seen: set[str] = set()
|
||||||
|
merged: list[str] = []
|
||||||
|
for source in sources:
|
||||||
|
if not source:
|
||||||
|
continue
|
||||||
|
if isinstance(source, str) or not isinstance(source, IterableABC):
|
||||||
|
iterable = [source]
|
||||||
|
else:
|
||||||
|
iterable = source
|
||||||
|
for value in iterable:
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
key = text if case_sensitive else text.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
merged.append(text)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_result_input(result: Any) -> List[Dict[str, Any]]:
|
||||||
|
"""Normalize input result to a list of dicts.
|
||||||
|
|
||||||
|
Handles:
|
||||||
|
- None -> []
|
||||||
|
- Dict -> [dict]
|
||||||
|
- List of dicts -> list as-is
|
||||||
|
- PipeObject -> [dict]
|
||||||
|
- List of PipeObjects -> list of dicts
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result: Result from piped input
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of result dicts (may be empty)
|
||||||
|
"""
|
||||||
|
if result is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return [result]
|
||||||
|
|
||||||
|
if isinstance(result, list):
|
||||||
|
output = []
|
||||||
|
for item in result:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
output.append(item)
|
||||||
|
elif hasattr(item, "to_dict"):
|
||||||
|
output.append(item.to_dict())
|
||||||
|
else:
|
||||||
|
output.append(item)
|
||||||
|
return output
|
||||||
|
|
||||||
|
if hasattr(result, "to_dict"):
|
||||||
|
return [result.to_dict()]
|
||||||
|
|
||||||
|
if isinstance(result, dict):
|
||||||
|
return [result]
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_result_items(
|
||||||
|
result: Any,
|
||||||
|
*,
|
||||||
|
include_falsey_single: bool = False,
|
||||||
|
) -> List[Any]:
|
||||||
|
"""Normalize piped input to a raw item list without converting item types."""
|
||||||
|
if isinstance(result, list):
|
||||||
|
return list(result)
|
||||||
|
if result is None:
|
||||||
|
return []
|
||||||
|
if include_falsey_single or result:
|
||||||
|
return [result]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def value_has_content(value: Any) -> bool:
|
||||||
|
"""Return True when a value should be treated as present for payload building."""
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if isinstance(value, str):
|
||||||
|
return bool(value.strip())
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return len(value) > 0
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def filter_results_by_temp(results: List[Any], include_temp: bool = False) -> List[Any]:
|
||||||
|
"""Filter results by temporary status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: List of result dicts or PipeObjects
|
||||||
|
include_temp: If True, keep temp files; if False, exclude them
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered list
|
||||||
|
"""
|
||||||
|
if include_temp:
|
||||||
|
return results
|
||||||
|
|
||||||
|
filtered = []
|
||||||
|
for result in results:
|
||||||
|
is_temp = False
|
||||||
|
|
||||||
|
if hasattr(result, "is_temp"):
|
||||||
|
is_temp = result.is_temp
|
||||||
|
elif isinstance(result, dict):
|
||||||
|
is_temp = result.get("is_temp", False)
|
||||||
|
|
||||||
|
if not is_temp:
|
||||||
|
filtered.append(result)
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def create_pipe_object_result(
|
||||||
|
source: str,
|
||||||
|
identifier: str,
|
||||||
|
file_path: str,
|
||||||
|
cmdlet_name: str,
|
||||||
|
title: Optional[str] = None,
|
||||||
|
hash_value: Optional[str] = None,
|
||||||
|
is_temp: bool = False,
|
||||||
|
parent_hash: Optional[str] = None,
|
||||||
|
tag: Optional[List[str]] = None,
|
||||||
|
**extra: Any,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Create a PipeObject-compatible result dict for pipeline chaining.
|
||||||
|
|
||||||
|
This is a helper to emit results in the standard format that downstream
|
||||||
|
cmdlet can process (filter, tag, cleanup, etc.).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: Source system (e.g., 'local', 'hydrus', 'download')
|
||||||
|
identifier: Unique ID from source
|
||||||
|
file_path: Path to the file
|
||||||
|
cmdlet_name: Name of the cmdlet that created this (e.g., 'download-data', 'screen-shot')
|
||||||
|
title: Human-readable title
|
||||||
|
hash_value: SHA-256 hash of file (for integrity)
|
||||||
|
is_temp: If True, this is a temporary/intermediate artifact
|
||||||
|
parent_hash: Hash of the parent file in the chain (for provenance)
|
||||||
|
tag: List of tag values to apply
|
||||||
|
**extra: Additional fields
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with all PipeObject fields for emission
|
||||||
|
"""
|
||||||
|
store_override = extra.pop("store", None)
|
||||||
|
|
||||||
|
result = build_file_result_payload(
|
||||||
|
title=title,
|
||||||
|
path=file_path,
|
||||||
|
hash_value=hash_value,
|
||||||
|
store=store_override if store_override is not None else source,
|
||||||
|
tag=tag,
|
||||||
|
source=source,
|
||||||
|
id=identifier,
|
||||||
|
action=f"cmdlet:{cmdlet_name}",
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_temp:
|
||||||
|
result["is_temp"] = True
|
||||||
|
if parent_hash:
|
||||||
|
result["parent_hash"] = parent_hash
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def mark_as_temp(pipe_object: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Mark a PipeObject dict as temporary (intermediate artifact).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pipe_object: Result dict from cmdlet emission
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified dict with is_temp=True
|
||||||
|
"""
|
||||||
|
pipe_object["is_temp"] = True
|
||||||
|
return pipe_object
|
||||||
|
|
||||||
|
|
||||||
|
def set_parent_hash(pipe_object: Dict[str, Any], parent_hash: str) -> Dict[str, Any]:
|
||||||
|
"""Set the parent_hash for provenance tracking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pipe_object: Result dict
|
||||||
|
parent_hash: Parent file's hash
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Modified dict with parent_hash set to the hash
|
||||||
|
"""
|
||||||
|
pipe_object["parent_hash"] = parent_hash
|
||||||
|
return pipe_object
|
||||||
|
|
||||||
|
|
||||||
|
def get_pipe_object_path(pipe_object: Any) -> Optional[str]:
|
||||||
|
"""Extract file path from PipeObject, dict, or pipeline-friendly object."""
|
||||||
|
if pipe_object is None:
|
||||||
|
return None
|
||||||
|
for attr in ("path", "target"):
|
||||||
|
if hasattr(pipe_object, attr):
|
||||||
|
value = getattr(pipe_object, attr)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
if isinstance(pipe_object, dict):
|
||||||
|
for key in ("path", "target"):
|
||||||
|
value = pipe_object.get(key)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_pipe_object_hash(pipe_object: Any) -> Optional[str]:
|
||||||
|
"""Extract file hash from PipeObject, dict, or pipeline-friendly object."""
|
||||||
|
if pipe_object is None:
|
||||||
|
return None
|
||||||
|
for attr in ("hash",):
|
||||||
|
if hasattr(pipe_object, attr):
|
||||||
|
value = getattr(pipe_object, attr)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
if isinstance(pipe_object, dict):
|
||||||
|
for key in ("hash",):
|
||||||
|
value = pipe_object.get(key)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_item_store_hash(
|
||||||
|
item: Any,
|
||||||
|
*,
|
||||||
|
override_store: Optional[str] = None,
|
||||||
|
override_hash: Optional[str] = None,
|
||||||
|
hash_field: str = "hash",
|
||||||
|
store_field: str = "store",
|
||||||
|
path_fields: Sequence[str] = ("path", "target"),
|
||||||
|
) -> Tuple[str, Optional[str]]:
|
||||||
|
"""Resolve store name and normalized hash from a result item."""
|
||||||
|
store_name = str(override_store or get_field(item, store_field) or "").strip()
|
||||||
|
raw_hash = get_field(item, hash_field)
|
||||||
|
|
||||||
|
raw_path = None
|
||||||
|
for field_name in path_fields:
|
||||||
|
candidate = get_field(item, field_name)
|
||||||
|
if candidate:
|
||||||
|
raw_path = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
resolved_hash = resolve_hash_for_cmdlet(
|
||||||
|
str(raw_hash) if raw_hash else None,
|
||||||
|
str(raw_path) if raw_path else None,
|
||||||
|
str(override_hash) if override_hash else None,
|
||||||
|
)
|
||||||
|
return store_name, resolved_hash
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_to_pipe_object(
|
||||||
|
value: Any,
|
||||||
|
default_path: Optional[str] = None,
|
||||||
|
) -> models.PipeObject:
|
||||||
|
"""Normalize any incoming result to a PipeObject for single-source-of-truth state.
|
||||||
|
|
||||||
|
Uses hash+store canonical pattern.
|
||||||
|
"""
|
||||||
|
if isinstance(value, models.PipeObject):
|
||||||
|
return value
|
||||||
|
|
||||||
|
known_keys = {
|
||||||
|
"hash",
|
||||||
|
"store",
|
||||||
|
"tag",
|
||||||
|
"title",
|
||||||
|
"url",
|
||||||
|
"source_url",
|
||||||
|
"duration",
|
||||||
|
"metadata",
|
||||||
|
"warnings",
|
||||||
|
"path",
|
||||||
|
"relationships",
|
||||||
|
"is_temp",
|
||||||
|
"action",
|
||||||
|
"parent_hash",
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasattr(value, "to_dict"):
|
||||||
|
value = value.to_dict()
|
||||||
|
elif not isinstance(value, dict):
|
||||||
|
try:
|
||||||
|
obj_map: Dict[str, Any] = {}
|
||||||
|
for k in (
|
||||||
|
"hash",
|
||||||
|
"store",
|
||||||
|
"provider",
|
||||||
|
"prov",
|
||||||
|
"tag",
|
||||||
|
"title",
|
||||||
|
"url",
|
||||||
|
"source_url",
|
||||||
|
"duration",
|
||||||
|
"duration_seconds",
|
||||||
|
"metadata",
|
||||||
|
"full_metadata",
|
||||||
|
"warnings",
|
||||||
|
"path",
|
||||||
|
"target",
|
||||||
|
"relationships",
|
||||||
|
"is_temp",
|
||||||
|
"action",
|
||||||
|
"parent_hash",
|
||||||
|
"extra",
|
||||||
|
"media_kind",
|
||||||
|
):
|
||||||
|
if hasattr(value, k):
|
||||||
|
obj_map[k] = getattr(value, k)
|
||||||
|
if obj_map:
|
||||||
|
value = obj_map
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
hash_val = value.get("hash")
|
||||||
|
store_val = value.get("store") or "PATH"
|
||||||
|
if not store_val or store_val == "PATH":
|
||||||
|
try:
|
||||||
|
extra_store = value.get("extra", {}).get("store")
|
||||||
|
except Exception:
|
||||||
|
extra_store = None
|
||||||
|
if extra_store:
|
||||||
|
store_val = extra_store
|
||||||
|
|
||||||
|
if not hash_val:
|
||||||
|
path_val = value.get("path")
|
||||||
|
if path_val:
|
||||||
|
try:
|
||||||
|
from SYS.utils import sha256_file
|
||||||
|
|
||||||
|
hash_val = sha256_file(Path(path_val))
|
||||||
|
except Exception:
|
||||||
|
hash_val = "unknown"
|
||||||
|
else:
|
||||||
|
hash_val = "unknown"
|
||||||
|
|
||||||
|
title_val = value.get("title")
|
||||||
|
if not title_val:
|
||||||
|
path_val = value.get("path")
|
||||||
|
if path_val:
|
||||||
|
try:
|
||||||
|
title_val = Path(path_val).stem
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
extra = {
|
||||||
|
k: v
|
||||||
|
for k, v in value.items() if k not in known_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
from SYS.metadata import normalize_urls
|
||||||
|
|
||||||
|
url_list = normalize_urls(value.get("url"))
|
||||||
|
url_val = url_list[0] if url_list else None
|
||||||
|
if len(url_list) > 1:
|
||||||
|
extra["url"] = url_list
|
||||||
|
|
||||||
|
rels = value.get("relationships") or {}
|
||||||
|
|
||||||
|
tag_val: list[str] = []
|
||||||
|
if "tag" in value:
|
||||||
|
raw_tag = value["tag"]
|
||||||
|
if isinstance(raw_tag, list):
|
||||||
|
tag_val = [str(t) for t in raw_tag if t is not None]
|
||||||
|
elif isinstance(raw_tag, str):
|
||||||
|
tag_val = [raw_tag]
|
||||||
|
|
||||||
|
path_val = value.get("path")
|
||||||
|
if not path_val and "target" in value:
|
||||||
|
target = value["target"]
|
||||||
|
if target and not (isinstance(target, str) and (target.startswith("http://")
|
||||||
|
or target.startswith("https://"))):
|
||||||
|
path_val = target
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(path_val, str) and (path_val.startswith("http://")
|
||||||
|
or path_val.startswith("https://")):
|
||||||
|
if not url_val:
|
||||||
|
url_val = path_val
|
||||||
|
path_val = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if "media_kind" in value:
|
||||||
|
extra["media_kind"] = value["media_kind"]
|
||||||
|
|
||||||
|
pipe_obj = models.PipeObject(
|
||||||
|
hash=hash_val,
|
||||||
|
store=store_val,
|
||||||
|
plugin=str(
|
||||||
|
value.get("plugin")
|
||||||
|
or value.get("prov")
|
||||||
|
or value.get("source")
|
||||||
|
or extra.get("plugin")
|
||||||
|
or extra.get("source")
|
||||||
|
or ""
|
||||||
|
).strip() or None,
|
||||||
|
tag=tag_val,
|
||||||
|
title=title_val,
|
||||||
|
url=url_val,
|
||||||
|
source_url=value.get("source_url"),
|
||||||
|
duration=value.get("duration") or value.get("duration_seconds"),
|
||||||
|
metadata=value.get("metadata") or value.get("full_metadata") or {},
|
||||||
|
warnings=list(value.get("warnings") or []),
|
||||||
|
path=path_val,
|
||||||
|
relationships=rels,
|
||||||
|
is_temp=bool(value.get("is_temp", False)),
|
||||||
|
action=value.get("action"),
|
||||||
|
parent_hash=value.get("parent_hash"),
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
return pipe_obj
|
||||||
|
|
||||||
|
hash_val = "unknown"
|
||||||
|
path_val = default_path or getattr(value, "path", None)
|
||||||
|
url_val: Optional[str] = None
|
||||||
|
title_val = None
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
s = value.strip()
|
||||||
|
if s.lower().startswith(("http://", "https://")):
|
||||||
|
url_val = s
|
||||||
|
path_val = None
|
||||||
|
else:
|
||||||
|
path_val = s
|
||||||
|
|
||||||
|
if path_val and path_val != "unknown":
|
||||||
|
try:
|
||||||
|
from SYS.utils import sha256_file
|
||||||
|
|
||||||
|
path_obj = Path(path_val)
|
||||||
|
hash_val = sha256_file(path_obj)
|
||||||
|
title_val = path_obj.stem
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
store_val = "URL" if url_val else "PATH"
|
||||||
|
|
||||||
|
pipe_obj = models.PipeObject(
|
||||||
|
hash=hash_val,
|
||||||
|
store=store_val,
|
||||||
|
plugin=None,
|
||||||
|
path=str(path_val) if path_val and path_val != "unknown" else None,
|
||||||
|
title=title_val,
|
||||||
|
url=url_val,
|
||||||
|
source_url=url_val,
|
||||||
|
tag=[],
|
||||||
|
extra={},
|
||||||
|
)
|
||||||
|
|
||||||
|
return pipe_obj
|
||||||
|
|
||||||
|
|
||||||
|
def propagate_metadata(
|
||||||
|
previous_items: Sequence[Any],
|
||||||
|
new_items: Sequence[Any],
|
||||||
|
) -> List[Any]:
|
||||||
|
"""Merge metadata/tags from previous pipeline stage into new items.
|
||||||
|
|
||||||
|
Implements "sticky metadata": items generated by a transformation (download, convert)
|
||||||
|
should inherit rich info (lyrics, art, tags) from their source.
|
||||||
|
|
||||||
|
Strategies:
|
||||||
|
A. Hash Match: If inputs/outputs share a hash, they are the same item.
|
||||||
|
B. Index Match: If lists are same length, assume 1:1 mapping (heuristic).
|
||||||
|
C. Explicit Parent: If output has `parent_hash`, link to input with that hash.
|
||||||
|
"""
|
||||||
|
if not previous_items or not new_items:
|
||||||
|
return list(new_items)
|
||||||
|
|
||||||
|
try:
|
||||||
|
prev_normalized = [coerce_to_pipe_object(p) for p in previous_items]
|
||||||
|
except Exception:
|
||||||
|
return list(new_items)
|
||||||
|
|
||||||
|
prev_by_hash: Dict[str, models.PipeObject] = {}
|
||||||
|
for p_obj in prev_normalized:
|
||||||
|
if p_obj.hash and p_obj.hash != "unknown":
|
||||||
|
prev_by_hash[p_obj.hash] = p_obj
|
||||||
|
|
||||||
|
normalized: List[Any] = []
|
||||||
|
|
||||||
|
is_same_length = len(new_items) == len(prev_normalized)
|
||||||
|
|
||||||
|
for i, item in enumerate(new_items):
|
||||||
|
if isinstance(item, dict) and item.get("_skip_metadata_propagation"):
|
||||||
|
normalized.append(item)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = coerce_to_pipe_object(item)
|
||||||
|
except Exception:
|
||||||
|
normalized.append(item)
|
||||||
|
continue
|
||||||
|
|
||||||
|
parent: Optional[models.PipeObject] = None
|
||||||
|
|
||||||
|
if obj.hash in prev_by_hash:
|
||||||
|
parent = prev_by_hash[obj.hash]
|
||||||
|
|
||||||
|
if not parent and is_same_length:
|
||||||
|
parent = prev_normalized[i]
|
||||||
|
|
||||||
|
if not parent and obj.parent_hash and obj.parent_hash in prev_by_hash:
|
||||||
|
parent = prev_by_hash[obj.parent_hash]
|
||||||
|
|
||||||
|
if parent:
|
||||||
|
if parent.tag:
|
||||||
|
if not obj.tag:
|
||||||
|
obj.tag = list(parent.tag)
|
||||||
|
else:
|
||||||
|
curr_tags = {str(t).lower() for t in obj.tag}
|
||||||
|
for pt in parent.tag:
|
||||||
|
if str(pt).lower() not in curr_tags:
|
||||||
|
obj.tag.append(pt)
|
||||||
|
|
||||||
|
if parent.metadata:
|
||||||
|
if not obj.metadata:
|
||||||
|
obj.metadata = parent.metadata.copy()
|
||||||
|
else:
|
||||||
|
for mk, mv in parent.metadata.items():
|
||||||
|
if mk not in obj.metadata:
|
||||||
|
obj.metadata[mk] = mv
|
||||||
|
|
||||||
|
if parent.source_url and not obj.source_url:
|
||||||
|
obj.source_url = parent.source_url
|
||||||
|
elif parent.url and not obj.source_url and not obj.url:
|
||||||
|
obj.source_url = parent.url
|
||||||
|
|
||||||
|
if parent.relationships:
|
||||||
|
if not obj.relationships:
|
||||||
|
obj.relationships = parent.relationships.copy()
|
||||||
|
else:
|
||||||
|
for rk, rv in parent.relationships.items():
|
||||||
|
if rk not in obj.relationships:
|
||||||
|
obj.relationships[rk] = rv
|
||||||
|
|
||||||
|
if parent.extra:
|
||||||
|
if not obj.extra:
|
||||||
|
obj.extra = parent.extra.copy()
|
||||||
|
else:
|
||||||
|
for ek, ev in parent.extra.items():
|
||||||
|
if ek not in obj.extra:
|
||||||
|
obj.extra[ek] = ev
|
||||||
|
elif ek == "notes" and isinstance(ev, dict) and isinstance(obj.extra[ek], dict):
|
||||||
|
for nk, nv in ev.items():
|
||||||
|
if nk not in obj.extra[ek]:
|
||||||
|
obj.extra[ek][nk] = nv
|
||||||
|
|
||||||
|
normalized.append(obj)
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def pipeline_item_local_path(item: Any) -> Optional[str]:
|
||||||
|
"""Extract local file path from a pipeline item.
|
||||||
|
|
||||||
|
Supports both dataclass objects with .path attribute and dicts.
|
||||||
|
Returns None for HTTP/HTTPS url.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item: Pipeline item (PipelineItem dataclass, dict, or other)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Local file path string, or None if item is not a local file
|
||||||
|
"""
|
||||||
|
path_value: Optional[str] = None
|
||||||
|
if hasattr(item, "path"):
|
||||||
|
path_value = getattr(item, "path", None)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
raw = item.get("path") or item.get("url")
|
||||||
|
path_value = str(raw) if raw is not None else None
|
||||||
|
if not isinstance(path_value, str):
|
||||||
|
return None
|
||||||
|
text = path_value.strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if text.lower().startswith(("http://", "https://")):
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def extract_relationships(result: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
if isinstance(result, models.PipeObject):
|
||||||
|
relationships = result.get_relationships()
|
||||||
|
return relationships or None
|
||||||
|
if isinstance(result, dict):
|
||||||
|
relationships = result.get("relationships")
|
||||||
|
if isinstance(relationships, dict) and relationships:
|
||||||
|
return relationships
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_duration(result: Any) -> Optional[float]:
|
||||||
|
duration = None
|
||||||
|
if isinstance(result, models.PipeObject):
|
||||||
|
duration = result.duration
|
||||||
|
elif isinstance(result, dict):
|
||||||
|
duration = result.get("duration")
|
||||||
|
if duration is None:
|
||||||
|
metadata = result.get("metadata")
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
duration = metadata.get("duration")
|
||||||
|
if duration is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(duration)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Store backend resolution and preflight validation helpers.
|
||||||
|
|
||||||
|
Resolves store backends for cmdlets, preferring targeted instances before
|
||||||
|
falling back to the backend registry. Used by store-related cmdlets to avoid
|
||||||
|
repeated registry setup boilerplate.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_store_backend",
|
||||||
|
"get_preferred_store_backend",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_store_backend(
|
||||||
|
config: Optional[Dict[str, Any]],
|
||||||
|
store_name: Optional[str],
|
||||||
|
*,
|
||||||
|
store_registry: Any = None,
|
||||||
|
suppress_debug: bool = False,
|
||||||
|
) -> Tuple[Optional[Any], Any, Optional[Exception]]:
|
||||||
|
"""Resolve a store backend, optionally reusing an existing registry.
|
||||||
|
|
||||||
|
Returns a tuple of ``(backend, store_registry, exc)`` so callers can keep
|
||||||
|
their command-specific error messages while avoiding repeated registry setup
|
||||||
|
and ``store[name]`` boilerplate.
|
||||||
|
"""
|
||||||
|
registry = store_registry
|
||||||
|
if registry is None:
|
||||||
|
try:
|
||||||
|
from PluginCore.backend_registry import BackendRegistry
|
||||||
|
|
||||||
|
registry = BackendRegistry(config or {}, suppress_debug=suppress_debug)
|
||||||
|
except Exception as exc:
|
||||||
|
return None, None, exc
|
||||||
|
|
||||||
|
backend_name = str(store_name or "").strip()
|
||||||
|
if not backend_name:
|
||||||
|
return None, registry, KeyError("Missing store name")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return registry[backend_name], registry, None
|
||||||
|
except Exception as exc:
|
||||||
|
return None, registry, exc
|
||||||
|
|
||||||
|
|
||||||
|
def get_preferred_store_backend(
|
||||||
|
config: Optional[Dict[str, Any]],
|
||||||
|
store_name: Optional[str],
|
||||||
|
*,
|
||||||
|
store_registry: Any = None,
|
||||||
|
suppress_debug: bool = True,
|
||||||
|
) -> Tuple[Optional[Any], Any, Optional[Exception]]:
|
||||||
|
"""Prefer a targeted backend instance before falling back to registry lookup."""
|
||||||
|
direct_exc: Optional[Exception] = None
|
||||||
|
try:
|
||||||
|
from PluginCore.backend_registry import get_backend_instance
|
||||||
|
|
||||||
|
backend = get_backend_instance(
|
||||||
|
config or {},
|
||||||
|
str(store_name or ""),
|
||||||
|
suppress_debug=suppress_debug,
|
||||||
|
)
|
||||||
|
if backend is not None:
|
||||||
|
return backend, store_registry, None
|
||||||
|
except Exception as exc:
|
||||||
|
direct_exc = exc
|
||||||
|
|
||||||
|
backend, registry, lookup_exc = get_store_backend(
|
||||||
|
config,
|
||||||
|
store_name,
|
||||||
|
store_registry=store_registry,
|
||||||
|
suppress_debug=suppress_debug,
|
||||||
|
)
|
||||||
|
if backend is not None:
|
||||||
|
return backend, registry, None
|
||||||
|
return None, registry, direct_exc or lookup_exc
|
||||||
+108
-3593
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,452 @@
|
|||||||
|
"""Store and storage operation utilities.
|
||||||
|
|
||||||
|
Contains hash resolution, store batch dispatching, hash query parsing, and
|
||||||
|
Hydrus metadata fetching helpers used by store-related cmdlets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from SYS.logger import log
|
||||||
|
from ._preflight import get_store_backend
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"resolve_hash_for_cmdlet",
|
||||||
|
"parse_hash_query",
|
||||||
|
"parse_single_hash_query",
|
||||||
|
"require_hash_query",
|
||||||
|
"require_single_hash_query",
|
||||||
|
"get_hash_for_operation",
|
||||||
|
"fetch_hydrus_metadata",
|
||||||
|
"coalesce_hash_value_pairs",
|
||||||
|
"run_store_hash_value_batches",
|
||||||
|
"run_store_note_batches",
|
||||||
|
"collect_store_hash_value_batch",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_hash_for_cmdlet(
|
||||||
|
raw_hash: Optional[str],
|
||||||
|
raw_path: Optional[str],
|
||||||
|
override_hash: Optional[str],
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Resolve a file hash for note/tag/file cmdlets.
|
||||||
|
|
||||||
|
Shared implementation used by add-note, delete-note, get-note, and similar
|
||||||
|
cmdlets that need to identify a file by its SHA-256 hash.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. ``override_hash`` — explicit hash provided via *-query* (highest priority)
|
||||||
|
2. ``raw_hash`` — positional hash argument
|
||||||
|
3. ``raw_path`` stem — if the filename stem is a 64-char hex string it is
|
||||||
|
treated directly as the hash (Hydrus-style naming convention)
|
||||||
|
4. SHA-256 computed from the file at ``raw_path``
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_hash: Hash string from positional argument.
|
||||||
|
raw_path: Filesystem path to the file (may be None).
|
||||||
|
override_hash: Hash extracted from *-query* (takes precedence).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalised 64-char lowercase hex hash, or ``None`` if unresolvable.
|
||||||
|
"""
|
||||||
|
from ._tag_utils import normalize_hash
|
||||||
|
|
||||||
|
resolved = normalize_hash(override_hash) if override_hash else normalize_hash(raw_hash)
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
if raw_path:
|
||||||
|
try:
|
||||||
|
p = Path(str(raw_path))
|
||||||
|
stem = p.stem
|
||||||
|
if len(stem) == 64 and all(c in "0123456789abcdef" for c in stem.lower()):
|
||||||
|
return stem.lower()
|
||||||
|
if p.exists() and p.is_file():
|
||||||
|
from SYS.utils import sha256_file as _sha256_file
|
||||||
|
return _sha256_file(p)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_hash_query(query: Optional[str]) -> List[str]:
|
||||||
|
"""Parse a unified query string for `hash:` into normalized SHA256 hashes.
|
||||||
|
|
||||||
|
Supported examples:
|
||||||
|
- hash:<h1>
|
||||||
|
- hash:<h1>,<h2>,<h3>
|
||||||
|
- Hash: <h1> <h2> <h3>
|
||||||
|
- hash:{<h1>, <h2>}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of unique normalized 64-hex SHA256 hashes.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from ._tag_utils import normalize_hash
|
||||||
|
|
||||||
|
q = str(query or "").strip()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
|
||||||
|
m = re.match(r"^hash(?:es)?\s*:\s*(.+)$", q, flags=re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
return []
|
||||||
|
|
||||||
|
rest = (m.group(1) or "").strip()
|
||||||
|
if rest.startswith("{") and rest.endswith("}"):
|
||||||
|
rest = rest[1:-1].strip()
|
||||||
|
if rest.startswith("[") and rest.endswith("]"):
|
||||||
|
rest = rest[1:-1].strip()
|
||||||
|
|
||||||
|
raw_parts = [p.strip() for p in re.split(r"[\s,]+", rest) if p.strip()]
|
||||||
|
out: List[str] = []
|
||||||
|
for part in raw_parts:
|
||||||
|
h = normalize_hash(part)
|
||||||
|
if not h:
|
||||||
|
continue
|
||||||
|
if h not in out:
|
||||||
|
out.append(h)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def parse_single_hash_query(query: Optional[str]) -> Optional[str]:
|
||||||
|
"""Parse `hash:` query and require exactly one hash."""
|
||||||
|
hashes = parse_hash_query(query)
|
||||||
|
if len(hashes) != 1:
|
||||||
|
return None
|
||||||
|
return hashes[0]
|
||||||
|
|
||||||
|
|
||||||
|
def require_hash_query(
|
||||||
|
query: Optional[str],
|
||||||
|
error_message: str,
|
||||||
|
*,
|
||||||
|
log_file: Any = None,
|
||||||
|
) -> Tuple[List[str], bool]:
|
||||||
|
"""Parse a multi-hash query and log a caller-provided error on invalid input."""
|
||||||
|
hashes = parse_hash_query(query)
|
||||||
|
if query and not hashes:
|
||||||
|
kwargs = {"file": log_file} if log_file is not None else {}
|
||||||
|
log(error_message, **kwargs)
|
||||||
|
return [], False
|
||||||
|
return hashes, True
|
||||||
|
|
||||||
|
|
||||||
|
def require_single_hash_query(
|
||||||
|
query: Optional[str],
|
||||||
|
error_message: str,
|
||||||
|
*,
|
||||||
|
log_file: Any = None,
|
||||||
|
) -> Tuple[Optional[str], bool]:
|
||||||
|
"""Parse a single-hash query and log a caller-provided error on invalid input."""
|
||||||
|
query_hash = parse_single_hash_query(query)
|
||||||
|
if query and not query_hash:
|
||||||
|
kwargs = {"file": log_file} if log_file is not None else {}
|
||||||
|
log(error_message, **kwargs)
|
||||||
|
return None, False
|
||||||
|
return query_hash, True
|
||||||
|
|
||||||
|
|
||||||
|
def get_hash_for_operation(
|
||||||
|
override_hash: Optional[str],
|
||||||
|
result: Any,
|
||||||
|
field_name: str = "hash",
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Get normalized hash from override or result object, consolidating common pattern.
|
||||||
|
|
||||||
|
Eliminates repeated pattern: normalize_hash(override) if override else normalize_hash(get_field(result, ...))
|
||||||
|
|
||||||
|
Args:
|
||||||
|
override_hash: Hash passed as command argument (takes precedence)
|
||||||
|
result: Object containing hash field (fallback)
|
||||||
|
field_name: Name of hash field in result object (default: "hash")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Normalized hash string, or None if neither override nor result provides valid hash
|
||||||
|
"""
|
||||||
|
from ._tag_utils import normalize_hash
|
||||||
|
from ._pipeobject_utils import get_field
|
||||||
|
|
||||||
|
if override_hash:
|
||||||
|
return normalize_hash(override_hash)
|
||||||
|
hash_value = (
|
||||||
|
get_field(result,
|
||||||
|
field_name) or getattr(result,
|
||||||
|
field_name,
|
||||||
|
None) or getattr(result,
|
||||||
|
"hash",
|
||||||
|
None)
|
||||||
|
)
|
||||||
|
return normalize_hash(hash_value)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_hydrus_metadata(
|
||||||
|
config: Any,
|
||||||
|
hash_hex: str,
|
||||||
|
*,
|
||||||
|
store_name: Optional[str] = None,
|
||||||
|
hydrus_client: Any = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> tuple[Optional[Dict[str, Any]], Optional[int]]:
|
||||||
|
"""Fetch metadata from Hydrus for a given hash, consolidating common fetch pattern.
|
||||||
|
|
||||||
|
Eliminates repeated boilerplate: client initialization, error handling, metadata extraction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: Configuration object used to resolve the Hydrus plugin/store
|
||||||
|
hash_hex: File hash to fetch metadata for
|
||||||
|
store_name: Optional Hydrus store name. When provided, do not fall back to a global/default Hydrus client.
|
||||||
|
hydrus_client: Optional explicit Hydrus client. When provided, takes precedence.
|
||||||
|
**kwargs: Additional arguments to pass to client.fetch_file_metadata()
|
||||||
|
Common: include_service_keys_to_tags, include_notes, include_file_url, include_duration, etc.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (metadata_dict, error_code)
|
||||||
|
- metadata_dict: Dict from Hydrus (first item in metadata list) or None if unavailable
|
||||||
|
- error_code: 0 on success, 1 on any error (suitable for returning from cmdlet execute())
|
||||||
|
"""
|
||||||
|
client = hydrus_client
|
||||||
|
hydrus_provider = None
|
||||||
|
try:
|
||||||
|
from PluginCore.registry import get_plugin
|
||||||
|
|
||||||
|
hydrus_provider = get_plugin("hydrusnetwork", config)
|
||||||
|
except Exception:
|
||||||
|
hydrus_provider = None
|
||||||
|
|
||||||
|
if client is None:
|
||||||
|
if hydrus_provider is not None:
|
||||||
|
try:
|
||||||
|
client = hydrus_provider.get_client(
|
||||||
|
store_name=store_name if store_name else None,
|
||||||
|
allow_default=not bool(store_name),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
if store_name:
|
||||||
|
log(f"Hydrus client unavailable for store '{store_name}': {exc}")
|
||||||
|
else:
|
||||||
|
log(f"Hydrus client unavailable: {exc}")
|
||||||
|
client = None
|
||||||
|
if client is None and store_name:
|
||||||
|
log(f"Hydrus client unavailable for store '{store_name}'")
|
||||||
|
return None, 1
|
||||||
|
if client is None and hydrus_provider is None:
|
||||||
|
log("Hydrus provider unavailable")
|
||||||
|
return None, 1
|
||||||
|
|
||||||
|
if hydrus_provider is not None:
|
||||||
|
try:
|
||||||
|
metadata = hydrus_provider.fetch_metadata(
|
||||||
|
hash_hex,
|
||||||
|
store_name=store_name if store_name else None,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"Hydrus metadata fetch failed: {exc}")
|
||||||
|
return None, 1
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
return metadata, 0
|
||||||
|
if client is None:
|
||||||
|
if store_name:
|
||||||
|
log(f"Hydrus client unavailable for store '{store_name}'")
|
||||||
|
else:
|
||||||
|
log("Hydrus metadata unavailable")
|
||||||
|
return None, 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = client.fetch_file_metadata(hashes=[hash_hex], **kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"Hydrus metadata fetch failed: {exc}")
|
||||||
|
return None, 1
|
||||||
|
|
||||||
|
items = payload.get("metadata") if isinstance(payload, dict) else None
|
||||||
|
meta = items[0] if (
|
||||||
|
isinstance(items, list) and items and isinstance(items[0], dict)
|
||||||
|
) else None
|
||||||
|
|
||||||
|
return meta, 0
|
||||||
|
|
||||||
|
|
||||||
|
def coalesce_hash_value_pairs(
|
||||||
|
pairs: Sequence[Tuple[str, Sequence[str]]],
|
||||||
|
) -> List[Tuple[str, List[str]]]:
|
||||||
|
"""Merge duplicate hash/value pairs while preserving first-seen value order."""
|
||||||
|
merged: Dict[str, List[str]] = {}
|
||||||
|
for hash_value, values in pairs:
|
||||||
|
normalized_hash = str(hash_value or "").strip()
|
||||||
|
if not normalized_hash:
|
||||||
|
continue
|
||||||
|
bucket = merged.setdefault(normalized_hash, [])
|
||||||
|
seen = set(bucket)
|
||||||
|
for value in values or []:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text or text in seen:
|
||||||
|
continue
|
||||||
|
seen.add(text)
|
||||||
|
bucket.append(text)
|
||||||
|
return [(hash_value, items) for hash_value, items in merged.items() if items]
|
||||||
|
|
||||||
|
|
||||||
|
def run_store_hash_value_batches(
|
||||||
|
config: Optional[Dict[str, Any]],
|
||||||
|
batch: Dict[str, List[Tuple[str, Sequence[str]]]],
|
||||||
|
*,
|
||||||
|
bulk_method_name: str,
|
||||||
|
single_method_name: str,
|
||||||
|
store_registry: Any = None,
|
||||||
|
suppress_debug: bool = False,
|
||||||
|
pass_config_to_bulk: bool = True,
|
||||||
|
pass_config_to_single: bool = True,
|
||||||
|
) -> Tuple[Any, List[Tuple[str, int, int]]]:
|
||||||
|
"""Dispatch grouped hash/value batches across stores.
|
||||||
|
|
||||||
|
Returns ``(store_registry, stats)`` where ``stats`` contains
|
||||||
|
``(store_name, item_count, value_count)`` for each dispatched store.
|
||||||
|
Missing stores are skipped so callers can preserve existing warning behavior.
|
||||||
|
"""
|
||||||
|
registry = store_registry
|
||||||
|
stats: List[Tuple[str, int, int]] = []
|
||||||
|
for store_name, pairs in batch.items():
|
||||||
|
backend, registry, _exc = get_store_backend(
|
||||||
|
config,
|
||||||
|
store_name,
|
||||||
|
store_registry=registry,
|
||||||
|
suppress_debug=suppress_debug,
|
||||||
|
)
|
||||||
|
if backend is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
bulk_pairs = coalesce_hash_value_pairs(pairs)
|
||||||
|
if not bulk_pairs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
bulk_fn = getattr(backend, bulk_method_name, None)
|
||||||
|
if callable(bulk_fn):
|
||||||
|
if pass_config_to_bulk:
|
||||||
|
bulk_fn(bulk_pairs, config=config)
|
||||||
|
else:
|
||||||
|
bulk_fn(bulk_pairs)
|
||||||
|
else:
|
||||||
|
single_fn = getattr(backend, single_method_name)
|
||||||
|
for hash_value, values in bulk_pairs:
|
||||||
|
if pass_config_to_single:
|
||||||
|
single_fn(hash_value, values, config=config)
|
||||||
|
else:
|
||||||
|
single_fn(hash_value, values)
|
||||||
|
|
||||||
|
stats.append(
|
||||||
|
(
|
||||||
|
store_name,
|
||||||
|
len(bulk_pairs),
|
||||||
|
sum(len(values or []) for _hash_value, values in bulk_pairs),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return registry, stats
|
||||||
|
|
||||||
|
|
||||||
|
def run_store_note_batches(
|
||||||
|
config: Optional[Dict[str, Any]],
|
||||||
|
batch: Dict[str, List[Tuple[str, str, str]]],
|
||||||
|
*,
|
||||||
|
store_registry: Any = None,
|
||||||
|
suppress_debug: bool = False,
|
||||||
|
on_store_error: Optional[Callable[[str, Exception], None]] = None,
|
||||||
|
on_unsupported_store: Optional[Callable[[str], None]] = None,
|
||||||
|
on_item_error: Optional[Callable[[str, str, str, Exception], None]] = None,
|
||||||
|
) -> Tuple[Any, int]:
|
||||||
|
"""Dispatch grouped note writes across stores while preserving item-level errors."""
|
||||||
|
registry = store_registry
|
||||||
|
success_count = 0
|
||||||
|
for store_name, items in batch.items():
|
||||||
|
backend, registry, exc = get_store_backend(
|
||||||
|
config,
|
||||||
|
store_name,
|
||||||
|
store_registry=registry,
|
||||||
|
suppress_debug=suppress_debug,
|
||||||
|
)
|
||||||
|
if backend is None:
|
||||||
|
if on_store_error is not None and exc is not None:
|
||||||
|
on_store_error(store_name, exc)
|
||||||
|
continue
|
||||||
|
supports_note_capability = getattr(backend, "supports_note_association", None)
|
||||||
|
if supports_note_capability is None:
|
||||||
|
supports_note_capability = hasattr(backend, "set_note")
|
||||||
|
if not bool(supports_note_capability):
|
||||||
|
if on_unsupported_store is not None:
|
||||||
|
on_unsupported_store(store_name)
|
||||||
|
continue
|
||||||
|
if not hasattr(backend, "set_note"):
|
||||||
|
if on_unsupported_store is not None:
|
||||||
|
on_unsupported_store(store_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for hash_value, note_name, note_text in items:
|
||||||
|
try:
|
||||||
|
if backend.set_note(hash_value, note_name, note_text, config=config):
|
||||||
|
success_count += 1
|
||||||
|
except Exception as item_exc:
|
||||||
|
if on_item_error is not None:
|
||||||
|
on_item_error(store_name, hash_value, note_name, item_exc)
|
||||||
|
|
||||||
|
return registry, success_count
|
||||||
|
|
||||||
|
|
||||||
|
def collect_store_hash_value_batch(
|
||||||
|
items: Sequence[Any],
|
||||||
|
*,
|
||||||
|
store_registry: Any,
|
||||||
|
value_resolver: Callable[[Any], Optional[Sequence[str]]],
|
||||||
|
override_hash: Optional[str] = None,
|
||||||
|
override_store: Optional[str] = None,
|
||||||
|
on_warning: Optional[Callable[[str], None]] = None,
|
||||||
|
) -> Tuple[Dict[str, List[Tuple[str, List[str]]]], List[Any]]:
|
||||||
|
"""Collect validated store/hash/value batches while preserving passthrough items."""
|
||||||
|
from ._tag_utils import normalize_hash
|
||||||
|
from ._pipeobject_utils import get_field as _get_field
|
||||||
|
|
||||||
|
batch: Dict[str, List[Tuple[str, List[str]]]] = {}
|
||||||
|
pass_through: List[Any] = []
|
||||||
|
|
||||||
|
for item in items:
|
||||||
|
pass_through.append(item)
|
||||||
|
|
||||||
|
raw_hash = override_hash or _get_field(item, "hash")
|
||||||
|
raw_store = override_store or _get_field(item, "store")
|
||||||
|
if not raw_hash or not raw_store:
|
||||||
|
if on_warning is not None:
|
||||||
|
on_warning("Item missing hash/store; skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized = normalize_hash(raw_hash)
|
||||||
|
if not normalized:
|
||||||
|
if on_warning is not None:
|
||||||
|
on_warning("Item has invalid hash; skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
store_text = str(raw_store).strip()
|
||||||
|
if not store_text:
|
||||||
|
if on_warning is not None:
|
||||||
|
on_warning("Item has empty store; skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_available = bool(store_registry.is_available(store_text))
|
||||||
|
except Exception:
|
||||||
|
is_available = False
|
||||||
|
if not is_available:
|
||||||
|
if on_warning is not None:
|
||||||
|
on_warning(f"Store '{store_text}' not configured; skipping")
|
||||||
|
continue
|
||||||
|
|
||||||
|
values = [str(value).strip() for value in (value_resolver(item) or []) if str(value).strip()]
|
||||||
|
if not values:
|
||||||
|
continue
|
||||||
|
|
||||||
|
batch.setdefault(store_text, []).append((normalized, values))
|
||||||
|
|
||||||
|
return batch, pass_through
|
||||||
|
|
||||||
@@ -0,0 +1,954 @@
|
|||||||
|
"""Tag-related utilities: parsing, normalization, template rendering, and extraction.
|
||||||
|
|
||||||
|
Covers the full tag lifecycle:
|
||||||
|
- Parsing raw tag arguments from command-line tokens
|
||||||
|
- Normalizing hash strings for tag identity
|
||||||
|
- Expanding tag group references from JSON config files
|
||||||
|
- Rendering tag value templates with ``#(placeholder)`` and ``<transform(...)>`` syntax
|
||||||
|
- Extracting tags, titles, and URLs from result objects (PipeObject/dict)
|
||||||
|
- Manipulating tag lists (namespace collapse, preferred titles, relationships)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from SYS import models
|
||||||
|
from SYS.logger import log
|
||||||
|
from ._pipeobject_utils import merge_sequences
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"set_tag_groups_path",
|
||||||
|
"normalize_hash",
|
||||||
|
"looks_like_hash",
|
||||||
|
"parse_tag_arguments",
|
||||||
|
"render_tag_value_templates",
|
||||||
|
"build_tag_value_lookup",
|
||||||
|
"_add_tag_values_to_lookup",
|
||||||
|
"expand_tag_groups",
|
||||||
|
"first_title_tag",
|
||||||
|
"apply_preferred_title",
|
||||||
|
"collapse_namespace_tags",
|
||||||
|
"collect_relationship_labels",
|
||||||
|
"extract_tag_from_result",
|
||||||
|
"extract_title_from_result",
|
||||||
|
"extract_url_from_result",
|
||||||
|
]
|
||||||
|
|
||||||
|
TAG_GROUPS_PATH: Optional[Path] = None
|
||||||
|
|
||||||
|
_TAG_GROUPS_CACHE: Optional[Dict[str, List[str]]] = None
|
||||||
|
_TAG_GROUPS_MTIME: Optional[float] = None
|
||||||
|
|
||||||
|
_TAG_VALUE_TEMPLATE_RE = re.compile(r"#\(([^)]+)\)")
|
||||||
|
_TAG_VALUE_FUNCTION_RE = re.compile(r"<([a-zA-Z_][a-zA-Z0-9_-]*)\((.*?)\)>")
|
||||||
|
|
||||||
|
|
||||||
|
def set_tag_groups_path(path: Path) -> None:
|
||||||
|
"""Set the path to the tag groups JSON file."""
|
||||||
|
global TAG_GROUPS_PATH
|
||||||
|
TAG_GROUPS_PATH = path
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=4096)
|
||||||
|
def _normalize_hash_cached(hash_hex: str) -> Optional[str]:
|
||||||
|
text = hash_hex.strip().lower()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if len(text) != 64:
|
||||||
|
return None
|
||||||
|
if not all(ch in "0123456789abcdef" for ch in text):
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_hash(hash_hex: Optional[str]) -> Optional[str]:
|
||||||
|
"""Normalize a hash string to lowercase, or return None if invalid.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hash_hex: String that should be a hex hash
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Lowercase hash string, or None if input is not a string or is empty
|
||||||
|
"""
|
||||||
|
if not isinstance(hash_hex, str):
|
||||||
|
return None
|
||||||
|
return _normalize_hash_cached(hash_hex)
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_hash(candidate: Optional[str]) -> bool:
|
||||||
|
"""Check if a string looks like a SHA256 hash (64 hex chars).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
candidate: String to test
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the string is 64 lowercase hex characters
|
||||||
|
"""
|
||||||
|
if not isinstance(candidate, str):
|
||||||
|
return False
|
||||||
|
text = candidate.strip().lower()
|
||||||
|
return len(text) == 64 and all(ch in "0123456789abcdef" for ch in text)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tag_value_template_name(value: Any) -> str:
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
text = re.sub(r"\s+", " ", text).strip()
|
||||||
|
except Exception:
|
||||||
|
text = " ".join(text.split())
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _tag_value_template_keys(value: Any) -> list[str]:
|
||||||
|
normalized = _normalize_tag_value_template_name(value)
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
|
||||||
|
keys = [normalized]
|
||||||
|
|
||||||
|
trimmed_hash = re.sub(r"\s*#+\s*$", "", normalized).strip()
|
||||||
|
if trimmed_hash and trimmed_hash not in keys:
|
||||||
|
keys.append(trimmed_hash)
|
||||||
|
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def _add_tag_values_to_lookup(lookup: Dict[str, List[str]], tag_text: Any) -> None:
|
||||||
|
text = str(tag_text or "").strip()
|
||||||
|
if not text or ":" not in text:
|
||||||
|
return
|
||||||
|
if _TAG_VALUE_TEMPLATE_RE.search(text) or _TAG_VALUE_FUNCTION_RE.search(text):
|
||||||
|
return
|
||||||
|
|
||||||
|
namespace, value = text.split(":", 1)
|
||||||
|
value_text = str(value or "").strip()
|
||||||
|
if not value_text:
|
||||||
|
return
|
||||||
|
|
||||||
|
for key in _tag_value_template_keys(namespace):
|
||||||
|
values = lookup.setdefault(key, [])
|
||||||
|
if value_text not in values:
|
||||||
|
values.append(value_text)
|
||||||
|
|
||||||
|
|
||||||
|
def build_tag_value_lookup(
|
||||||
|
tags: Optional[Iterable[Any]],
|
||||||
|
*,
|
||||||
|
result: Any = None,
|
||||||
|
) -> Dict[str, List[str]]:
|
||||||
|
"""Build a placeholder lookup from existing tags and lightweight result fields.
|
||||||
|
|
||||||
|
Placeholder lookups use ``#(namespace)`` syntax. Namespace matching is
|
||||||
|
case-insensitive and trims repeated whitespace. A trailing ``#`` in the
|
||||||
|
placeholder is ignored so inputs like ``#(track #)`` can resolve ``track:9``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
lookup: Dict[str, List[str]] = {}
|
||||||
|
for tag in tags or []:
|
||||||
|
_add_tag_values_to_lookup(lookup, tag)
|
||||||
|
|
||||||
|
title_text = extract_title_from_result(result)
|
||||||
|
if title_text:
|
||||||
|
_add_tag_values_to_lookup(lookup, f"title:{title_text}")
|
||||||
|
|
||||||
|
return lookup
|
||||||
|
|
||||||
|
|
||||||
|
def _split_tag_value_function_args(value: Any) -> list[str]:
|
||||||
|
text = str(value or "")
|
||||||
|
args: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
depth = 0
|
||||||
|
quote: Optional[str] = None
|
||||||
|
escape = False
|
||||||
|
|
||||||
|
for ch in text:
|
||||||
|
if escape:
|
||||||
|
current.append(ch)
|
||||||
|
escape = False
|
||||||
|
continue
|
||||||
|
if ch == "\\":
|
||||||
|
current.append(ch)
|
||||||
|
escape = True
|
||||||
|
continue
|
||||||
|
if quote:
|
||||||
|
current.append(ch)
|
||||||
|
if ch == quote:
|
||||||
|
quote = None
|
||||||
|
continue
|
||||||
|
if ch in {"'", '"'}:
|
||||||
|
current.append(ch)
|
||||||
|
quote = ch
|
||||||
|
continue
|
||||||
|
if ch in {"(", "[", "{"}:
|
||||||
|
depth += 1
|
||||||
|
current.append(ch)
|
||||||
|
continue
|
||||||
|
if ch in {")", "]", "}"}:
|
||||||
|
depth = max(0, depth - 1)
|
||||||
|
current.append(ch)
|
||||||
|
continue
|
||||||
|
if ch == "," and depth == 0:
|
||||||
|
args.append("".join(current).strip())
|
||||||
|
current = []
|
||||||
|
continue
|
||||||
|
current.append(ch)
|
||||||
|
|
||||||
|
tail = "".join(current).strip()
|
||||||
|
if tail or args:
|
||||||
|
args.append(tail)
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_tag_value_function_arg(value: Any) -> str:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
|
||||||
|
return text[1:-1]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _padding_width_from_spec(value: Any) -> Optional[int]:
|
||||||
|
spec = _strip_tag_value_function_arg(value)
|
||||||
|
if not spec:
|
||||||
|
return None
|
||||||
|
if re.fullmatch(r"0+", spec):
|
||||||
|
return len(spec)
|
||||||
|
if spec.isdigit():
|
||||||
|
try:
|
||||||
|
width = int(spec)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return width if width > 0 else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_tag_value_placeholders(
|
||||||
|
value: Any,
|
||||||
|
lookup: Dict[str, List[str]],
|
||||||
|
*,
|
||||||
|
preserve_unresolved: bool,
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
text = str(value or "")
|
||||||
|
unresolved = False
|
||||||
|
|
||||||
|
def _replace(match: re.Match[str]) -> str:
|
||||||
|
nonlocal unresolved
|
||||||
|
keys = _tag_value_template_keys(match.group(1) or "")
|
||||||
|
values: List[str] = []
|
||||||
|
for key in keys:
|
||||||
|
for candidate in lookup.get(key, []):
|
||||||
|
if candidate not in values:
|
||||||
|
values.append(candidate)
|
||||||
|
if not values:
|
||||||
|
unresolved = True
|
||||||
|
return match.group(0) if preserve_unresolved else ""
|
||||||
|
return ", ".join(values)
|
||||||
|
|
||||||
|
return _TAG_VALUE_TEMPLATE_RE.sub(_replace, text), unresolved
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_tag_value_integer(value: Any) -> Optional[int]:
|
||||||
|
text = _strip_tag_value_function_arg(value)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
if not re.fullmatch(r"[+-]?\d+", text):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(text)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_tag_value_function(
|
||||||
|
name: str,
|
||||||
|
args: Sequence[str],
|
||||||
|
*,
|
||||||
|
lookup: Dict[str, List[str]],
|
||||||
|
) -> Optional[str]:
|
||||||
|
func = str(name or "").strip().lower()
|
||||||
|
|
||||||
|
resolved_values: list[str] = []
|
||||||
|
unresolved_flags: list[bool] = []
|
||||||
|
for arg in args:
|
||||||
|
rendered, unresolved = _replace_tag_value_placeholders(
|
||||||
|
arg,
|
||||||
|
lookup,
|
||||||
|
preserve_unresolved=True,
|
||||||
|
)
|
||||||
|
resolved_values.append(_strip_tag_value_function_arg(rendered))
|
||||||
|
unresolved_flags.append(unresolved)
|
||||||
|
|
||||||
|
if func in {"padding", "pad", "zfill"}:
|
||||||
|
if len(resolved_values) != 2 or any(unresolved_flags):
|
||||||
|
return None
|
||||||
|
width = _padding_width_from_spec(resolved_values[0])
|
||||||
|
if width is None:
|
||||||
|
return None
|
||||||
|
return str(resolved_values[1]).zfill(width)
|
||||||
|
|
||||||
|
if func == "default":
|
||||||
|
if len(resolved_values) != 2:
|
||||||
|
return None
|
||||||
|
primary = resolved_values[0]
|
||||||
|
fallback = resolved_values[1]
|
||||||
|
if not unresolved_flags[0] and str(primary).strip():
|
||||||
|
return str(primary)
|
||||||
|
if unresolved_flags[1]:
|
||||||
|
return None
|
||||||
|
return str(fallback)
|
||||||
|
|
||||||
|
if func == "replace":
|
||||||
|
if len(resolved_values) != 3 or any(unresolved_flags):
|
||||||
|
return None
|
||||||
|
return str(resolved_values[0]).replace(
|
||||||
|
str(resolved_values[1]),
|
||||||
|
str(resolved_values[2]),
|
||||||
|
)
|
||||||
|
|
||||||
|
if func in {"increment", "inc", "add"}:
|
||||||
|
if len(resolved_values) not in {1, 2}:
|
||||||
|
return None
|
||||||
|
if unresolved_flags[0]:
|
||||||
|
return None
|
||||||
|
base_value = _coerce_tag_value_integer(resolved_values[0])
|
||||||
|
if base_value is None:
|
||||||
|
return None
|
||||||
|
step_value = 1
|
||||||
|
if len(resolved_values) == 2:
|
||||||
|
if unresolved_flags[1]:
|
||||||
|
return None
|
||||||
|
parsed_step = _coerce_tag_value_integer(resolved_values[1])
|
||||||
|
if parsed_step is None:
|
||||||
|
return None
|
||||||
|
step_value = parsed_step
|
||||||
|
return str(base_value + step_value)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _render_tag_value_function_templates(
|
||||||
|
value: Any,
|
||||||
|
*,
|
||||||
|
lookup: Dict[str, List[str]],
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
text = str(value or "")
|
||||||
|
unresolved = False
|
||||||
|
|
||||||
|
def _replace(match: re.Match[str]) -> str:
|
||||||
|
nonlocal unresolved
|
||||||
|
func_name = match.group(1) or ""
|
||||||
|
func_args = _split_tag_value_function_args(match.group(2) or "")
|
||||||
|
rendered = _apply_tag_value_function(
|
||||||
|
func_name,
|
||||||
|
func_args,
|
||||||
|
lookup=lookup,
|
||||||
|
)
|
||||||
|
if rendered is None:
|
||||||
|
unresolved = True
|
||||||
|
return match.group(0)
|
||||||
|
return rendered
|
||||||
|
|
||||||
|
previous = None
|
||||||
|
rendered = text
|
||||||
|
while previous != rendered and _TAG_VALUE_FUNCTION_RE.search(rendered):
|
||||||
|
previous = rendered
|
||||||
|
rendered = _TAG_VALUE_FUNCTION_RE.sub(_replace, rendered)
|
||||||
|
if unresolved:
|
||||||
|
break
|
||||||
|
return rendered, unresolved
|
||||||
|
|
||||||
|
|
||||||
|
def render_tag_value_templates(
|
||||||
|
tags: Sequence[Any],
|
||||||
|
*,
|
||||||
|
existing_tags: Optional[Iterable[Any]] = None,
|
||||||
|
result: Any = None,
|
||||||
|
) -> tuple[list[str], list[str]]:
|
||||||
|
"""Resolve ``#(namespace)`` placeholders and ``<transform(...)>`` functions.
|
||||||
|
|
||||||
|
Returns ``(resolved_tags, unresolved_templates)``. Tags whose placeholders
|
||||||
|
cannot be fully resolved are omitted from ``resolved_tags`` and returned in
|
||||||
|
``unresolved_templates`` so callers can warn or summarize skipped items.
|
||||||
|
|
||||||
|
Currently supported transforms:
|
||||||
|
- ``<padding(00,#(episode))>`` or ``<pad(2,#(episode))>`` for zero-padding
|
||||||
|
- ``<default(#(season),0)>`` to fall back when a placeholder is missing
|
||||||
|
- ``<replace(#(title),old,new)>`` for simple substring replacement
|
||||||
|
- ``<increment(#(episode),1)>`` for integer arithmetic
|
||||||
|
"""
|
||||||
|
|
||||||
|
entries: list[dict[str, Any]] = []
|
||||||
|
lookup = build_tag_value_lookup(existing_tags, result=result)
|
||||||
|
|
||||||
|
for raw_tag in tags or []:
|
||||||
|
text = str(raw_tag or "").strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
has_template = bool(
|
||||||
|
_TAG_VALUE_TEMPLATE_RE.search(text)
|
||||||
|
or _TAG_VALUE_FUNCTION_RE.search(text)
|
||||||
|
)
|
||||||
|
entry = {
|
||||||
|
"raw": text,
|
||||||
|
"resolved": None,
|
||||||
|
"has_template": has_template,
|
||||||
|
}
|
||||||
|
if not has_template:
|
||||||
|
entry["resolved"] = text
|
||||||
|
_add_tag_values_to_lookup(lookup, text)
|
||||||
|
entries.append(entry)
|
||||||
|
|
||||||
|
progress = True
|
||||||
|
while progress:
|
||||||
|
progress = False
|
||||||
|
for entry in entries:
|
||||||
|
if entry["resolved"] is not None or not entry["has_template"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
rendered, unresolved = _replace_tag_value_placeholders(
|
||||||
|
entry["raw"],
|
||||||
|
lookup,
|
||||||
|
preserve_unresolved=bool(_TAG_VALUE_FUNCTION_RE.search(str(entry["raw"]))),
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered, function_unresolved = _render_tag_value_function_templates(
|
||||||
|
rendered,
|
||||||
|
lookup=lookup,
|
||||||
|
)
|
||||||
|
if function_unresolved:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if unresolved and _TAG_VALUE_TEMPLATE_RE.search(rendered):
|
||||||
|
continue
|
||||||
|
|
||||||
|
rendered = rendered.strip()
|
||||||
|
if not rendered:
|
||||||
|
entry["resolved"] = ""
|
||||||
|
progress = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry["resolved"] = rendered
|
||||||
|
_add_tag_values_to_lookup(lookup, rendered)
|
||||||
|
progress = True
|
||||||
|
|
||||||
|
resolved_tags = merge_sequences(
|
||||||
|
[entry["resolved"] for entry in entries if isinstance(entry.get("resolved"), str) and entry.get("resolved")],
|
||||||
|
case_sensitive=True,
|
||||||
|
)
|
||||||
|
unresolved_templates = [
|
||||||
|
str(entry["raw"])
|
||||||
|
for entry in entries
|
||||||
|
if entry["has_template"] and not entry.get("resolved")
|
||||||
|
]
|
||||||
|
return resolved_tags, unresolved_templates
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_tag_group_entry(value: Any) -> Optional[str]:
|
||||||
|
"""Internal: Normalize a single tag group entry."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = str(value)
|
||||||
|
text = value.strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tag_groups() -> Dict[str, List[str]]:
|
||||||
|
"""Load tag group definitions from JSON file with caching."""
|
||||||
|
global _TAG_GROUPS_CACHE, _TAG_GROUPS_MTIME, TAG_GROUPS_PATH
|
||||||
|
|
||||||
|
if TAG_GROUPS_PATH is None:
|
||||||
|
try:
|
||||||
|
script_dir = Path(__file__).parent.parent
|
||||||
|
|
||||||
|
candidate = script_dir / "adjective.json"
|
||||||
|
if candidate.exists():
|
||||||
|
TAG_GROUPS_PATH = candidate
|
||||||
|
else:
|
||||||
|
candidate = script_dir / "helper" / "adjective.json"
|
||||||
|
if candidate.exists():
|
||||||
|
TAG_GROUPS_PATH = candidate
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if TAG_GROUPS_PATH is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
path = TAG_GROUPS_PATH
|
||||||
|
try:
|
||||||
|
stat_result = path.stat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
_TAG_GROUPS_CACHE = {}
|
||||||
|
_TAG_GROUPS_MTIME = None
|
||||||
|
return {}
|
||||||
|
except OSError as exc:
|
||||||
|
log(f"Failed to read tag groups: {exc}", file=sys.stderr)
|
||||||
|
_TAG_GROUPS_CACHE = {}
|
||||||
|
_TAG_GROUPS_MTIME = None
|
||||||
|
return {}
|
||||||
|
|
||||||
|
mtime = stat_result.st_mtime
|
||||||
|
if _TAG_GROUPS_CACHE is not None and _TAG_GROUPS_MTIME == mtime:
|
||||||
|
return _TAG_GROUPS_CACHE
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
log(f"Invalid tag group JSON ({path}): {exc}", file=sys.stderr)
|
||||||
|
_TAG_GROUPS_CACHE = {}
|
||||||
|
_TAG_GROUPS_MTIME = mtime
|
||||||
|
return {}
|
||||||
|
|
||||||
|
groups: Dict[str, List[str]] = {}
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for key, value in payload.items():
|
||||||
|
if not isinstance(key, str):
|
||||||
|
continue
|
||||||
|
name = key.strip().lower()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
members: List[str] = []
|
||||||
|
if isinstance(value, list):
|
||||||
|
for entry in value:
|
||||||
|
normalized = _normalize_tag_group_entry(entry)
|
||||||
|
if normalized:
|
||||||
|
members.append(normalized)
|
||||||
|
elif isinstance(value, str):
|
||||||
|
normalized = _normalize_tag_group_entry(value)
|
||||||
|
if normalized:
|
||||||
|
members.extend(
|
||||||
|
token.strip() for token in normalized.split(",")
|
||||||
|
if token.strip()
|
||||||
|
)
|
||||||
|
if members:
|
||||||
|
groups[name] = members
|
||||||
|
|
||||||
|
_TAG_GROUPS_CACHE = groups
|
||||||
|
_TAG_GROUPS_MTIME = mtime
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def expand_tag_groups(raw_tags: Iterable[str]) -> List[str]:
|
||||||
|
"""Expand tag group references (e.g., {my_group}) into member tags.
|
||||||
|
|
||||||
|
Tag groups are defined in JSON and can be nested. Groups are referenced
|
||||||
|
with curly braces: {group_name}.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw_tags: Sequence of tag strings, some may reference groups like "{group_name}"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of expanded tags with group references replaced
|
||||||
|
"""
|
||||||
|
groups = _load_tag_groups()
|
||||||
|
if not groups:
|
||||||
|
return [tag for tag in raw_tags if isinstance(tag, str) and tag.strip()]
|
||||||
|
|
||||||
|
def _expand(tokens: Iterable[str], seen: Set[str]) -> List[str]:
|
||||||
|
result: List[str] = []
|
||||||
|
for token in tokens:
|
||||||
|
if not isinstance(token, str):
|
||||||
|
continue
|
||||||
|
candidate = token.strip()
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
if candidate.startswith("{") and candidate.endswith("}") and len(candidate) > 2:
|
||||||
|
name = candidate[1:-1].strip().lower()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
if name in seen:
|
||||||
|
log(
|
||||||
|
f"Tag group recursion detected for {{{name}}}; skipping",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
members = groups.get(name)
|
||||||
|
if not members:
|
||||||
|
log(f"Unknown tag group {{{name}}}", file=sys.stderr)
|
||||||
|
result.append(candidate)
|
||||||
|
continue
|
||||||
|
result.extend(_expand(members, seen | {name}))
|
||||||
|
else:
|
||||||
|
result.append(candidate)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return _expand(raw_tags, set())
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tag_arguments(arguments: Sequence[str]) -> List[str]:
|
||||||
|
"""Parse tag arguments from command line tokens.
|
||||||
|
|
||||||
|
- Supports comma-separated tags.
|
||||||
|
- Supports pipe namespace shorthand: "artist:A|B|C" -> artist:A, artist:B, artist:C.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
arguments: Sequence of argument strings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of normalized tag strings (empty strings filtered out)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _split_top_level_commas(text: str) -> List[str]:
|
||||||
|
segments: List[str] = []
|
||||||
|
current: List[str] = []
|
||||||
|
paren_depth = 0
|
||||||
|
angle_depth = 0
|
||||||
|
quote: Optional[str] = None
|
||||||
|
escape = False
|
||||||
|
|
||||||
|
for ch in text:
|
||||||
|
if escape:
|
||||||
|
current.append(ch)
|
||||||
|
escape = False
|
||||||
|
continue
|
||||||
|
if ch == "\\":
|
||||||
|
current.append(ch)
|
||||||
|
escape = True
|
||||||
|
continue
|
||||||
|
if quote:
|
||||||
|
current.append(ch)
|
||||||
|
if ch == quote:
|
||||||
|
quote = None
|
||||||
|
continue
|
||||||
|
if ch in {"'", '"'}:
|
||||||
|
current.append(ch)
|
||||||
|
quote = ch
|
||||||
|
continue
|
||||||
|
if ch == "(":
|
||||||
|
paren_depth += 1
|
||||||
|
current.append(ch)
|
||||||
|
continue
|
||||||
|
if ch == ")":
|
||||||
|
paren_depth = max(0, paren_depth - 1)
|
||||||
|
current.append(ch)
|
||||||
|
continue
|
||||||
|
if ch == "<":
|
||||||
|
angle_depth += 1
|
||||||
|
current.append(ch)
|
||||||
|
continue
|
||||||
|
if ch == ">":
|
||||||
|
angle_depth = max(0, angle_depth - 1)
|
||||||
|
current.append(ch)
|
||||||
|
continue
|
||||||
|
if ch == "," and paren_depth == 0 and angle_depth == 0:
|
||||||
|
segments.append("".join(current).strip())
|
||||||
|
current = []
|
||||||
|
continue
|
||||||
|
current.append(ch)
|
||||||
|
|
||||||
|
tail = "".join(current).strip()
|
||||||
|
if tail or segments:
|
||||||
|
segments.append(tail)
|
||||||
|
return segments
|
||||||
|
|
||||||
|
def _expand_pipe_namespace(text: str) -> List[str]:
|
||||||
|
parts = text.split("|")
|
||||||
|
expanded: List[str] = []
|
||||||
|
last_ns: Optional[str] = None
|
||||||
|
for part in parts:
|
||||||
|
segment = part.strip()
|
||||||
|
if not segment:
|
||||||
|
continue
|
||||||
|
if ":" in segment:
|
||||||
|
ns, val = segment.split(":", 1)
|
||||||
|
ns = ns.strip()
|
||||||
|
val = val.strip()
|
||||||
|
last_ns = ns or last_ns
|
||||||
|
if last_ns and val:
|
||||||
|
expanded.append(f"{last_ns}:{val}")
|
||||||
|
elif ns or val:
|
||||||
|
expanded.append(f"{ns}:{val}".strip(":"))
|
||||||
|
else:
|
||||||
|
if last_ns:
|
||||||
|
expanded.append(f"{last_ns}:{segment}")
|
||||||
|
else:
|
||||||
|
expanded.append(segment)
|
||||||
|
return expanded
|
||||||
|
|
||||||
|
tags: List[str] = []
|
||||||
|
for argument in arguments:
|
||||||
|
for token in _split_top_level_commas(str(argument)):
|
||||||
|
text = token.strip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
pipe_expanded = _expand_pipe_namespace(text)
|
||||||
|
for entry in pipe_expanded:
|
||||||
|
candidate = entry.strip()
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
if ":" in candidate:
|
||||||
|
ns, val = candidate.split(":", 1)
|
||||||
|
ns = ns.strip()
|
||||||
|
val = val.strip()
|
||||||
|
candidate = f"{ns}:{val}" if ns or val else ""
|
||||||
|
if candidate:
|
||||||
|
tags.append(candidate)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def first_title_tag(source: Optional[Iterable[str]]) -> Optional[str]:
|
||||||
|
"""Find the first tag starting with "title:" in a collection.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
source: Iterable of tag strings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
First title: tag found, or None
|
||||||
|
"""
|
||||||
|
if not source:
|
||||||
|
return None
|
||||||
|
for item in source:
|
||||||
|
if not isinstance(item, str):
|
||||||
|
continue
|
||||||
|
candidate = item.strip()
|
||||||
|
if candidate and candidate.lower().startswith("title:"):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def apply_preferred_title(tags: List[str], preferred: Optional[str]) -> List[str]:
|
||||||
|
"""Replace any title: tags with a preferred title tag.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tags: List of tags (may contain multiple "title:" entries)
|
||||||
|
preferred: Preferred title tag to use (full "title: ..." format)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List with old title tags removed and preferred title added (at most once)
|
||||||
|
"""
|
||||||
|
if not preferred:
|
||||||
|
return tags
|
||||||
|
preferred_clean = preferred.strip()
|
||||||
|
if not preferred_clean:
|
||||||
|
return tags
|
||||||
|
preferred_lower = preferred_clean.lower()
|
||||||
|
filtered: List[str] = []
|
||||||
|
has_preferred = False
|
||||||
|
for tag in tags:
|
||||||
|
candidate = tag.strip()
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
if candidate.lower().startswith("title:"):
|
||||||
|
if candidate.lower() == preferred_lower:
|
||||||
|
if not has_preferred:
|
||||||
|
filtered.append(candidate)
|
||||||
|
has_preferred = True
|
||||||
|
continue
|
||||||
|
filtered.append(candidate)
|
||||||
|
if not has_preferred:
|
||||||
|
filtered.append(preferred_clean)
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def collapse_namespace_tags(
|
||||||
|
tags: Optional[Iterable[Any]],
|
||||||
|
namespace: str,
|
||||||
|
prefer: str = "last",
|
||||||
|
) -> list[str]:
|
||||||
|
"""Reduce tags so only one entry for a given namespace remains.
|
||||||
|
|
||||||
|
Keeps either the first or last occurrence (default last) while preserving overall order
|
||||||
|
for non-matching tags. Useful for ensuring a single title: tag.
|
||||||
|
"""
|
||||||
|
if not tags:
|
||||||
|
return []
|
||||||
|
ns = str(namespace or "").strip().lower()
|
||||||
|
if not ns:
|
||||||
|
return list(tags) if isinstance(tags, list) else list(tags)
|
||||||
|
|
||||||
|
prefer_last = str(prefer or "last").lower() != "first"
|
||||||
|
ns_prefix = ns + ":"
|
||||||
|
|
||||||
|
items = list(tags)
|
||||||
|
if prefer_last:
|
||||||
|
kept: list[str] = []
|
||||||
|
seen_ns = False
|
||||||
|
for tag in reversed(items):
|
||||||
|
text = str(tag)
|
||||||
|
if text.lower().startswith(ns_prefix):
|
||||||
|
if seen_ns:
|
||||||
|
continue
|
||||||
|
seen_ns = True
|
||||||
|
kept.append(text)
|
||||||
|
kept.reverse()
|
||||||
|
return kept
|
||||||
|
else:
|
||||||
|
kept_ns = False
|
||||||
|
result: list[str] = []
|
||||||
|
for tag in items:
|
||||||
|
text = str(tag)
|
||||||
|
if text.lower().startswith(ns_prefix):
|
||||||
|
if kept_ns:
|
||||||
|
continue
|
||||||
|
kept_ns = True
|
||||||
|
result.append(text)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def collect_relationship_labels(
|
||||||
|
payload: Any,
|
||||||
|
label_stack: List[str] | None = None,
|
||||||
|
mapping: Dict[str, str] | None = None,
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Recursively extract hash-to-label mappings from nested relationship data.
|
||||||
|
|
||||||
|
Walks through nested dicts/lists looking for sha256-like strings (64 hex chars)
|
||||||
|
and builds a mapping from hash to its path in the structure.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
data = {
|
||||||
|
"duplicates": [
|
||||||
|
"abc123...", # Will be mapped to "duplicates"
|
||||||
|
{"type": "related", "items": ["def456..."]} # Will be mapped to "duplicates / type / items"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
result = collect_relationship_labels(data)
|
||||||
|
# result = {"abc123...": "duplicates", "def456...": "duplicates / type / items"}
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Nested data structure (dict, list, string, etc.)
|
||||||
|
label_stack: Internal use - tracks path during recursion
|
||||||
|
mapping: Internal use - accumulates hash->label mappings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping hash strings to their path labels
|
||||||
|
"""
|
||||||
|
if label_stack is None:
|
||||||
|
label_stack = []
|
||||||
|
if mapping is None:
|
||||||
|
mapping = {}
|
||||||
|
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
for key, value in payload.items():
|
||||||
|
next_stack = label_stack
|
||||||
|
if isinstance(key, str) and key:
|
||||||
|
formatted = key.replace("_", " ").strip()
|
||||||
|
next_stack = label_stack + [formatted]
|
||||||
|
collect_relationship_labels(value, next_stack, mapping)
|
||||||
|
elif isinstance(payload, (list, tuple, set)):
|
||||||
|
for value in payload:
|
||||||
|
collect_relationship_labels(value, label_stack, mapping)
|
||||||
|
elif isinstance(payload, str) and looks_like_hash(payload):
|
||||||
|
hash_value = payload.lower()
|
||||||
|
if label_stack:
|
||||||
|
label = " / ".join(item for item in label_stack if item)
|
||||||
|
else:
|
||||||
|
label = "related"
|
||||||
|
mapping.setdefault(hash_value, label)
|
||||||
|
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def extract_tag_from_result(result: Any) -> list[str]:
|
||||||
|
"""Extract all tags from a result dict or PipeObject.
|
||||||
|
|
||||||
|
Handles mixed types (lists, sets, strings) and various field names.
|
||||||
|
"""
|
||||||
|
tag: list[str] = []
|
||||||
|
|
||||||
|
def _extend(candidate: Any) -> None:
|
||||||
|
if not candidate:
|
||||||
|
return
|
||||||
|
if isinstance(candidate, (list, set, tuple)):
|
||||||
|
tag.extend(str(t) for t in candidate if t is not None)
|
||||||
|
elif isinstance(candidate, str):
|
||||||
|
tag.append(candidate)
|
||||||
|
|
||||||
|
if isinstance(result, models.PipeObject):
|
||||||
|
tag.extend(result.tag or [])
|
||||||
|
if isinstance(result.extra, dict):
|
||||||
|
_extend(result.extra.get("tag"))
|
||||||
|
if isinstance(result.metadata, dict):
|
||||||
|
_extend(result.metadata.get("tag"))
|
||||||
|
_extend(result.metadata.get("tags"))
|
||||||
|
elif hasattr(result, "tag"):
|
||||||
|
_extend(getattr(result, "tag"))
|
||||||
|
|
||||||
|
if isinstance(result, dict):
|
||||||
|
_extend(result.get("tag"))
|
||||||
|
_extend(result.get("tags"))
|
||||||
|
|
||||||
|
extra = result.get("extra")
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
_extend(extra.get("tag"))
|
||||||
|
_extend(extra.get("tags"))
|
||||||
|
|
||||||
|
fm = result.get("full_metadata") or result.get("metadata")
|
||||||
|
if isinstance(fm, dict):
|
||||||
|
_extend(fm.get("tag"))
|
||||||
|
_extend(fm.get("tags"))
|
||||||
|
|
||||||
|
return merge_sequences(tag, case_sensitive=True)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_title_from_result(result: Any) -> Optional[str]:
|
||||||
|
"""Extract the title from a result dict or PipeObject."""
|
||||||
|
if isinstance(result, models.PipeObject):
|
||||||
|
return result.title
|
||||||
|
elif hasattr(result, "title"):
|
||||||
|
return getattr(result, "title")
|
||||||
|
elif isinstance(result, dict):
|
||||||
|
return result.get("title")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_url_from_result(result: Any) -> list[str]:
|
||||||
|
"""Extract all unique URLs from a result dict or PipeObject.
|
||||||
|
|
||||||
|
Handles mixed types (lists, strings) and various field names (url, source_url, webpage_url).
|
||||||
|
Centralizes extraction logic for cmdlets like download-file, add-file, get-url.
|
||||||
|
"""
|
||||||
|
url: list[str] = []
|
||||||
|
|
||||||
|
def _extend(candidate: Any) -> None:
|
||||||
|
if not candidate:
|
||||||
|
return
|
||||||
|
if isinstance(candidate, list):
|
||||||
|
url.extend(candidate)
|
||||||
|
elif isinstance(candidate, str):
|
||||||
|
url.append(candidate)
|
||||||
|
|
||||||
|
if isinstance(result, models.PipeObject):
|
||||||
|
_extend(result.url)
|
||||||
|
_extend(result.source_url)
|
||||||
|
if isinstance(result.extra, dict):
|
||||||
|
_extend(result.extra.get("url"))
|
||||||
|
_extend(result.extra.get("source_url"))
|
||||||
|
if isinstance(result.metadata, dict):
|
||||||
|
_extend(result.metadata.get("url"))
|
||||||
|
_extend(result.metadata.get("source_url"))
|
||||||
|
_extend(result.metadata.get("webpage_url"))
|
||||||
|
if isinstance(getattr(result, "full_metadata", None), dict):
|
||||||
|
fm = getattr(result, "full_metadata", None)
|
||||||
|
if isinstance(fm, dict):
|
||||||
|
_extend(fm.get("url"))
|
||||||
|
_extend(fm.get("source_url"))
|
||||||
|
_extend(fm.get("webpage_url"))
|
||||||
|
|
||||||
|
elif hasattr(result, "url") or hasattr(result, "source_url"):
|
||||||
|
_extend(getattr(result, "url", None))
|
||||||
|
_extend(getattr(result, "source_url", None))
|
||||||
|
|
||||||
|
if isinstance(result, dict):
|
||||||
|
_extend(result.get("url"))
|
||||||
|
_extend(result.get("source_url"))
|
||||||
|
_extend(result.get("webpage_url"))
|
||||||
|
|
||||||
|
extra = result.get("extra")
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
_extend(extra.get("url"))
|
||||||
|
|
||||||
|
fm = result.get("full_metadata") or result.get("metadata")
|
||||||
|
if isinstance(fm, dict):
|
||||||
|
_extend(fm.get("url"))
|
||||||
|
_extend(fm.get("source_url"))
|
||||||
|
_extend(fm.get("webpage_url"))
|
||||||
|
|
||||||
|
from SYS.metadata import normalize_urls
|
||||||
|
return normalize_urls(url)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Template rendering and pipeline preview utilities.
|
||||||
|
|
||||||
|
Contains helpers for constructing pipeline progress previews and any
|
||||||
|
template-related utilities that don't belong in tag-specific modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, List, Sequence
|
||||||
|
|
||||||
|
from SYS.item_accessors import get_field
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"build_pipeline_preview",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_pipeline_preview(raw_urls: Sequence[str], piped_items: Sequence[Any]) -> List[str]:
|
||||||
|
"""Construct a short preview list for pipeline/cmdlet progress UI."""
|
||||||
|
preview: List[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for u in (raw_urls or [])[:3]:
|
||||||
|
if u:
|
||||||
|
preview.append(str(u))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(preview) < 5:
|
||||||
|
try:
|
||||||
|
for item in (piped_items or [])[:5]:
|
||||||
|
if len(preview) >= 5:
|
||||||
|
break
|
||||||
|
title = get_field(item, "title") or get_field(item, "target") or "Piped item"
|
||||||
|
preview.append(str(title))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not preview:
|
||||||
|
total = len(raw_urls or []) + len(piped_items or [])
|
||||||
|
if total:
|
||||||
|
preview.append(f"Processing {total} item(s)...")
|
||||||
|
|
||||||
|
return preview
|
||||||
@@ -0,0 +1,941 @@
|
|||||||
|
"""URL existence checks, URL merging/removal helpers, and URL preflight logic.
|
||||||
|
|
||||||
|
Contains ``check_url_exists_in_storage`` with all its nested closures that
|
||||||
|
perform bulk preflight checks across storage backends to determine whether
|
||||||
|
URLs already exist before downloading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
||||||
|
|
||||||
|
from SYS.logger import log, debug, debug_panel
|
||||||
|
from SYS import pipeline as pipeline_context
|
||||||
|
from SYS.item_accessors import get_field
|
||||||
|
from SYS.payload_builders import build_table_result_payload
|
||||||
|
from SYS.result_table import Table
|
||||||
|
from SYS.rich_display import stderr_console as get_stderr_console
|
||||||
|
from rich.prompt import Confirm
|
||||||
|
from contextlib import AbstractContextManager, nullcontext
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"check_url_exists_in_storage",
|
||||||
|
"merge_urls",
|
||||||
|
"remove_urls",
|
||||||
|
"set_item_urls",
|
||||||
|
"register_url_with_local_library",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def register_url_with_local_library(
|
||||||
|
pipe_obj: Any,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
) -> bool:
|
||||||
|
"""Register url with a file in the local library database.
|
||||||
|
|
||||||
|
This is called automatically by download cmdlet to ensure url are persisted
|
||||||
|
without requiring a separate add-url step in the pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pipe_obj: PipeObject with path and url
|
||||||
|
config: Config dict containing local library path
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if url were registered, False otherwise
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def merge_urls(existing: Any, incoming: Sequence[Any]) -> list[str]:
|
||||||
|
"""Merge URL values into a normalized, de-duplicated list."""
|
||||||
|
from SYS.metadata import normalize_urls
|
||||||
|
|
||||||
|
merged: list[str] = []
|
||||||
|
for value in normalize_urls(existing):
|
||||||
|
if value not in merged:
|
||||||
|
merged.append(value)
|
||||||
|
for value in normalize_urls(list(incoming or [])):
|
||||||
|
if value not in merged:
|
||||||
|
merged.append(value)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def remove_urls(existing: Any, remove: Sequence[Any]) -> list[str]:
|
||||||
|
"""Remove URL values from an existing URL field and return survivors."""
|
||||||
|
from SYS.metadata import normalize_urls
|
||||||
|
|
||||||
|
current = normalize_urls(existing)
|
||||||
|
remove_set = {value for value in normalize_urls(list(remove or [])) if value}
|
||||||
|
if not remove_set:
|
||||||
|
return current
|
||||||
|
return [value for value in current if value not in remove_set]
|
||||||
|
|
||||||
|
|
||||||
|
def set_item_urls(item: Any, urls: Sequence[Any]) -> None:
|
||||||
|
"""Persist normalized URL values back onto a dict/object result item."""
|
||||||
|
normalized = merge_urls([], list(urls or []))
|
||||||
|
payload: Any = normalized[0] if len(normalized) == 1 else list(normalized)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
item["url"] = payload
|
||||||
|
return
|
||||||
|
if hasattr(item, "url"):
|
||||||
|
setattr(item, "url", payload)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def check_url_exists_in_storage(
|
||||||
|
urls: Sequence[str],
|
||||||
|
storage: Any,
|
||||||
|
hydrus_available: bool,
|
||||||
|
final_output_dir: Optional[Path] = None,
|
||||||
|
*,
|
||||||
|
auto_continue_duplicates: bool = True,
|
||||||
|
force_prompt_in_pipeline: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Pre-flight check to see if URLs already exist in storage.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
urls: List of URLs to check
|
||||||
|
storage: The storage interface
|
||||||
|
hydrus_available: Whether Hydrus is available
|
||||||
|
final_output_dir: Final output directory (to skip if same as storage)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if check passed (user said yes or no dups), False if user said no (stop).
|
||||||
|
"""
|
||||||
|
if storage is None:
|
||||||
|
debug("Bulk URL preflight skipped: storage unavailable")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_cmd_text = pipeline_context.get_current_command_text("")
|
||||||
|
except Exception:
|
||||||
|
current_cmd_text = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
stage_ctx = pipeline_context.get_stage_context()
|
||||||
|
except Exception:
|
||||||
|
stage_ctx = None
|
||||||
|
|
||||||
|
in_pipeline = bool(stage_ctx is not None or ("|" in str(current_cmd_text or "")))
|
||||||
|
start_time = time.monotonic()
|
||||||
|
time_budget = 45.0
|
||||||
|
if in_pipeline:
|
||||||
|
try:
|
||||||
|
already_checked = bool(
|
||||||
|
pipeline_context.load_value(
|
||||||
|
"preflight.url_duplicates.checked", default=False
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
already_checked = False
|
||||||
|
|
||||||
|
if already_checked:
|
||||||
|
debug("Bulk URL preflight: already checked in pipeline; skipping duplicate check")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _load_preflight_cache() -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
existing = pipeline_context.load_value("preflight", default=None)
|
||||||
|
except Exception:
|
||||||
|
existing = None
|
||||||
|
return existing if isinstance(existing, dict) else {}
|
||||||
|
|
||||||
|
def _store_preflight_cache(cache: Dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
pipeline_context.store_value("preflight", cache)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _mark_preflight_checked() -> None:
|
||||||
|
if not in_pipeline:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pipeline_context.store_value("preflight.url_duplicates.checked", True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
preflight_cache = _load_preflight_cache()
|
||||||
|
preflight_cache["url_duplicates_checked"] = True
|
||||||
|
url_dup_cache = preflight_cache.get("url_duplicates")
|
||||||
|
if not isinstance(url_dup_cache, dict):
|
||||||
|
url_dup_cache = {}
|
||||||
|
url_dup_cache["checked"] = True
|
||||||
|
preflight_cache["url_duplicates"] = url_dup_cache
|
||||||
|
_store_preflight_cache(preflight_cache)
|
||||||
|
|
||||||
|
def _timed_out(reason: str) -> bool:
|
||||||
|
try:
|
||||||
|
if (time.monotonic() - start_time) >= time_budget:
|
||||||
|
debug(
|
||||||
|
f"Bulk URL preflight timed out after {time_budget:.0f}s ({reason}); continuing"
|
||||||
|
)
|
||||||
|
_mark_preflight_checked()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
if in_pipeline and auto_continue_duplicates:
|
||||||
|
try:
|
||||||
|
cached_cmd = pipeline_context.load_value("preflight.url_duplicates.command", default="")
|
||||||
|
cached_decision = pipeline_context.load_value("preflight.url_duplicates.continue", default=None)
|
||||||
|
except Exception:
|
||||||
|
cached_cmd = ""
|
||||||
|
cached_decision = None
|
||||||
|
|
||||||
|
if (not force_prompt_in_pipeline) and cached_decision is not None and str(cached_cmd or "") == str(current_cmd_text or ""):
|
||||||
|
_mark_preflight_checked()
|
||||||
|
if bool(cached_decision):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
pipeline_context.request_pipeline_stop(reason="duplicate-url declined", exit_code=0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
unique_urls: List[str] = []
|
||||||
|
for u in urls or []:
|
||||||
|
s = str(u or "").strip()
|
||||||
|
if s and s not in unique_urls:
|
||||||
|
unique_urls.append(s)
|
||||||
|
if len(unique_urls) == 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
from SYS.metadata import normalize_urls
|
||||||
|
except Exception:
|
||||||
|
normalize_urls = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
def _httpish(value: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bool(value) and (value.startswith("http://") or value.startswith("https://"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _normalize_url_for_search(value: str) -> str:
|
||||||
|
url = str(value or "").strip()
|
||||||
|
|
||||||
|
url = url.split("#", 1)[0]
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
|
||||||
|
if parsed is not None and parsed.query:
|
||||||
|
time_keys = {"t", "start", "time_continue", "timestamp", "time", "begin"}
|
||||||
|
tracking_prefixes = ("utm_",)
|
||||||
|
try:
|
||||||
|
pairs = parse_qsl(parsed.query, keep_blank_values=True)
|
||||||
|
filtered = []
|
||||||
|
for key, val in pairs:
|
||||||
|
key_norm = str(key or "").lower()
|
||||||
|
if key_norm in time_keys:
|
||||||
|
continue
|
||||||
|
if key_norm.startswith(tracking_prefixes):
|
||||||
|
continue
|
||||||
|
filtered.append((key, val))
|
||||||
|
if filtered:
|
||||||
|
url = urlunparse(parsed._replace(query=urlencode(filtered, doseq=True)))
|
||||||
|
else:
|
||||||
|
url = urlunparse(parsed._replace(query=""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
url = re.sub(r"^[a-z][a-z0-9+.-]*://", "", url, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
url = re.sub(r"^www\.", "", url, flags=re.IGNORECASE)
|
||||||
|
|
||||||
|
return url.lower()
|
||||||
|
|
||||||
|
def _expand_url_variants(value: str) -> List[str]:
|
||||||
|
if not _httpish(value):
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(value)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if parsed.scheme.lower() not in {"http", "https"}:
|
||||||
|
return []
|
||||||
|
|
||||||
|
out: List[str] = []
|
||||||
|
|
||||||
|
def _add_variant(candidate: str) -> None:
|
||||||
|
_maybe_add(candidate)
|
||||||
|
try:
|
||||||
|
lower = str(candidate or "").lower()
|
||||||
|
except Exception:
|
||||||
|
lower = ""
|
||||||
|
if lower and lower != candidate:
|
||||||
|
_maybe_add(lower)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed_candidate = urlparse(candidate)
|
||||||
|
except Exception:
|
||||||
|
parsed_candidate = None
|
||||||
|
|
||||||
|
if parsed_candidate is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
host = (parsed_candidate.hostname or "").strip().lower()
|
||||||
|
if host.startswith("www."):
|
||||||
|
host = host[4:]
|
||||||
|
if host:
|
||||||
|
netloc = host
|
||||||
|
try:
|
||||||
|
if parsed_candidate.port:
|
||||||
|
netloc = f"{netloc}:{parsed_candidate.port}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if parsed_candidate.username or parsed_candidate.password:
|
||||||
|
userinfo = parsed_candidate.username or ""
|
||||||
|
if parsed_candidate.password:
|
||||||
|
userinfo = f"{userinfo}:{parsed_candidate.password}"
|
||||||
|
if userinfo:
|
||||||
|
netloc = f"{userinfo}@{netloc}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
alt = urlunparse(parsed_candidate._replace(netloc=netloc))
|
||||||
|
_maybe_add(alt)
|
||||||
|
try:
|
||||||
|
lower_alt = alt.lower()
|
||||||
|
except Exception:
|
||||||
|
lower_alt = ""
|
||||||
|
if lower_alt and lower_alt != alt:
|
||||||
|
_maybe_add(lower_alt)
|
||||||
|
|
||||||
|
def _maybe_add(candidate: str) -> None:
|
||||||
|
if not candidate or candidate == value:
|
||||||
|
return
|
||||||
|
if candidate not in out:
|
||||||
|
out.append(candidate)
|
||||||
|
|
||||||
|
if parsed.fragment:
|
||||||
|
_add_variant(urlunparse(parsed._replace(fragment="")))
|
||||||
|
|
||||||
|
time_keys = {"t", "start", "time_continue", "timestamp", "time", "begin"}
|
||||||
|
tracking_prefixes = ("utm_",)
|
||||||
|
|
||||||
|
try:
|
||||||
|
query_pairs = parse_qsl(parsed.query, keep_blank_values=True)
|
||||||
|
except Exception:
|
||||||
|
query_pairs = []
|
||||||
|
|
||||||
|
if query_pairs or parsed.fragment:
|
||||||
|
filtered_pairs = []
|
||||||
|
removed = False
|
||||||
|
for key, val in query_pairs:
|
||||||
|
key_norm = str(key or "").lower()
|
||||||
|
if key_norm in time_keys:
|
||||||
|
removed = True
|
||||||
|
continue
|
||||||
|
if key_norm.startswith(tracking_prefixes):
|
||||||
|
removed = True
|
||||||
|
continue
|
||||||
|
filtered_pairs.append((key, val))
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
new_query = urlencode(filtered_pairs, doseq=True) if filtered_pairs else ""
|
||||||
|
_add_variant(urlunparse(parsed._replace(query=new_query, fragment="")))
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _dedupe_needles(raw_needles: Sequence[str]) -> List[str]:
|
||||||
|
output: List[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for candidate in (raw_needles or []):
|
||||||
|
candidate_text = str(candidate or "").strip()
|
||||||
|
if not candidate_text:
|
||||||
|
continue
|
||||||
|
key = candidate_text.lower()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
output.append(candidate_text)
|
||||||
|
return output
|
||||||
|
|
||||||
|
url_needles: Dict[str, List[str]] = {}
|
||||||
|
for u in unique_urls:
|
||||||
|
needles: List[str] = []
|
||||||
|
if normalize_urls is not None:
|
||||||
|
try:
|
||||||
|
needles.extend([n for n in (normalize_urls(u) or []) if isinstance(n, str)])
|
||||||
|
except Exception:
|
||||||
|
needles = []
|
||||||
|
if not needles:
|
||||||
|
needles = [u]
|
||||||
|
filtered: List[str] = []
|
||||||
|
for n in needles:
|
||||||
|
n2 = str(n or "").strip()
|
||||||
|
if not n2:
|
||||||
|
continue
|
||||||
|
if not _httpish(n2):
|
||||||
|
continue
|
||||||
|
if n2 not in filtered:
|
||||||
|
filtered.append(n2)
|
||||||
|
lowered: List[str] = []
|
||||||
|
for n2 in filtered:
|
||||||
|
try:
|
||||||
|
lower = n2.lower()
|
||||||
|
except Exception:
|
||||||
|
lower = ""
|
||||||
|
if lower and lower != n2 and lower not in filtered and lower not in lowered:
|
||||||
|
lowered.append(lower)
|
||||||
|
normalized: List[str] = []
|
||||||
|
for n2 in filtered:
|
||||||
|
norm = _normalize_url_for_search(n2)
|
||||||
|
if norm and norm not in normalized and norm not in filtered:
|
||||||
|
normalized.append(norm)
|
||||||
|
expanded: List[str] = []
|
||||||
|
for n2 in filtered:
|
||||||
|
for extra in _expand_url_variants(n2):
|
||||||
|
if extra not in expanded and extra not in filtered and extra not in lowered:
|
||||||
|
expanded.append(extra)
|
||||||
|
norm_extra = _normalize_url_for_search(extra)
|
||||||
|
if (
|
||||||
|
norm_extra
|
||||||
|
and norm_extra not in normalized
|
||||||
|
and norm_extra not in filtered
|
||||||
|
and norm_extra not in expanded
|
||||||
|
and norm_extra not in lowered
|
||||||
|
):
|
||||||
|
normalized.append(norm_extra)
|
||||||
|
|
||||||
|
combined = filtered + expanded + lowered + normalized
|
||||||
|
deduped = _dedupe_needles(combined)
|
||||||
|
url_needles[u] = deduped if deduped else [u]
|
||||||
|
|
||||||
|
if in_pipeline:
|
||||||
|
preflight_cache = _load_preflight_cache()
|
||||||
|
url_dup_cache = preflight_cache.get("url_duplicates")
|
||||||
|
if not isinstance(url_dup_cache, dict):
|
||||||
|
url_dup_cache = {}
|
||||||
|
cached_urls = url_dup_cache.get("urls")
|
||||||
|
cached_set = {str(u) for u in cached_urls} if isinstance(cached_urls, list) else set()
|
||||||
|
|
||||||
|
if cached_set:
|
||||||
|
all_cached = True
|
||||||
|
for original_url, needles in url_needles.items():
|
||||||
|
original_cached = str(original_url or "") in cached_set
|
||||||
|
needles_cached = True
|
||||||
|
if original_cached:
|
||||||
|
for needle in (needles or []):
|
||||||
|
needle_text = str(needle or "")
|
||||||
|
if not needle_text:
|
||||||
|
continue
|
||||||
|
if needle_text not in cached_set:
|
||||||
|
needles_cached = False
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
needles_cached = False
|
||||||
|
|
||||||
|
if original_cached and needles_cached:
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_cached = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if all_cached:
|
||||||
|
debug("Bulk URL preflight: cached for pipeline; skipping duplicate check")
|
||||||
|
_mark_preflight_checked()
|
||||||
|
return True
|
||||||
|
|
||||||
|
if _timed_out("before backend scan"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
bulk_mode = len(unique_urls) > 3
|
||||||
|
|
||||||
|
def _build_bulk_patterns(needles_map: Dict[str, List[str]], max_per_url: int = 3, max_total: int = 240) -> List[str]:
|
||||||
|
patterns: List[str] = []
|
||||||
|
for _original, needles in needles_map.items():
|
||||||
|
for needle in (needles or [])[:max_per_url]:
|
||||||
|
needle_text = str(needle or "").strip()
|
||||||
|
if not needle_text:
|
||||||
|
continue
|
||||||
|
if needle_text not in patterns:
|
||||||
|
patterns.append(needle_text)
|
||||||
|
if len(patterns) >= max_total:
|
||||||
|
return patterns
|
||||||
|
return patterns
|
||||||
|
|
||||||
|
bulk_patterns = _build_bulk_patterns(url_needles)
|
||||||
|
|
||||||
|
def _match_normalized_url(pattern_text: str, candidate_url: str) -> bool:
|
||||||
|
pattern_norm = _normalize_url_for_search(pattern_text)
|
||||||
|
candidate_norm = _normalize_url_for_search(candidate_url)
|
||||||
|
if not pattern_norm or not candidate_norm:
|
||||||
|
return False
|
||||||
|
if pattern_norm == candidate_norm:
|
||||||
|
return True
|
||||||
|
return pattern_norm in candidate_norm
|
||||||
|
|
||||||
|
def _extract_urls_from_hit(
|
||||||
|
hit: Any,
|
||||||
|
backend: Any,
|
||||||
|
*,
|
||||||
|
allow_backend_lookup: bool = True,
|
||||||
|
) -> List[str]:
|
||||||
|
url_values: List[str] = []
|
||||||
|
try:
|
||||||
|
raw_urls = get_field(hit, "known_urls") or get_field(hit, "urls") or get_field(hit, "url")
|
||||||
|
if isinstance(raw_urls, str) and raw_urls.strip():
|
||||||
|
url_values.append(raw_urls.strip())
|
||||||
|
elif isinstance(raw_urls, (list, tuple, set)):
|
||||||
|
for item in raw_urls:
|
||||||
|
if isinstance(item, str) and item.strip():
|
||||||
|
url_values.append(item.strip())
|
||||||
|
except Exception:
|
||||||
|
url_values = []
|
||||||
|
|
||||||
|
if url_values or not allow_backend_lookup:
|
||||||
|
return url_values
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_hash = get_field(hit, "hash") or get_field(hit, "file_hash") or get_field(hit, "sha256") or ""
|
||||||
|
except Exception:
|
||||||
|
file_hash = ""
|
||||||
|
|
||||||
|
if file_hash:
|
||||||
|
try:
|
||||||
|
fetched = backend.get_url(str(file_hash))
|
||||||
|
if isinstance(fetched, str) and fetched.strip():
|
||||||
|
url_values.append(fetched.strip())
|
||||||
|
elif isinstance(fetched, (list, tuple, set)):
|
||||||
|
for item in fetched:
|
||||||
|
if isinstance(item, str) and item.strip():
|
||||||
|
url_values.append(item.strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return url_values
|
||||||
|
|
||||||
|
def _build_display_row_for_hit(
|
||||||
|
hit: Any,
|
||||||
|
backend_name: str,
|
||||||
|
original_url: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
from SYS.result_table import build_display_row
|
||||||
|
extracted = build_display_row(hit, keys=["title", "store", "hash", "ext", "size"])
|
||||||
|
except Exception:
|
||||||
|
extracted = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
title = extracted.get("title") or get_field(hit, "title") or get_field(hit, "name") or get_field(hit, "target") or get_field(hit, "path") or "(exists)"
|
||||||
|
except Exception:
|
||||||
|
title = "(exists)"
|
||||||
|
|
||||||
|
try:
|
||||||
|
file_hash = extracted.get("hash") or get_field(hit, "hash") or get_field(hit, "file_hash") or get_field(hit, "sha256") or ""
|
||||||
|
except Exception:
|
||||||
|
file_hash = ""
|
||||||
|
|
||||||
|
ext = extracted.get("ext") if isinstance(extracted, dict) else ""
|
||||||
|
size_val = extracted.get("size") if isinstance(extracted, dict) else None
|
||||||
|
|
||||||
|
return build_table_result_payload(
|
||||||
|
title=str(title),
|
||||||
|
columns=[
|
||||||
|
("Title", str(title)),
|
||||||
|
("Store", str(get_field(hit, "store") or backend_name)),
|
||||||
|
("Hash", str(file_hash or "")),
|
||||||
|
("Ext", str(ext or "")),
|
||||||
|
("Size", size_val),
|
||||||
|
("URL", original_url),
|
||||||
|
],
|
||||||
|
store=str(get_field(hit, "store") or backend_name),
|
||||||
|
hash=str(file_hash or ""),
|
||||||
|
ext=str(ext or ""),
|
||||||
|
size=size_val,
|
||||||
|
url=original_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _search_backend_url_hits(
|
||||||
|
backend: Any,
|
||||||
|
backend_name: str,
|
||||||
|
original_url: str,
|
||||||
|
needles: Sequence[str],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
backend_hits: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for needle in (needles or [])[:5]:
|
||||||
|
needle_stripped = str(needle or "").strip()
|
||||||
|
if not needle_stripped or not _httpish(needle_stripped):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
query = f"url:{needle_stripped}"
|
||||||
|
backend_hits = backend.search(query, limit=1, minimal=True) or []
|
||||||
|
if backend_hits:
|
||||||
|
return _build_display_row_for_hit(backend_hits[0], backend_name, original_url)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for needle in (needles or [])[:3]:
|
||||||
|
needle_text = str(needle or "").strip()
|
||||||
|
if not needle_text:
|
||||||
|
continue
|
||||||
|
search_needle = _normalize_url_for_search(needle_text) or needle_text
|
||||||
|
query = f"url:*{search_needle}*"
|
||||||
|
try:
|
||||||
|
backend_hits = backend.search(query, limit=1, minimal=True) or []
|
||||||
|
if backend_hits:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not backend_hits:
|
||||||
|
return None
|
||||||
|
|
||||||
|
hit = backend_hits[0]
|
||||||
|
return _build_display_row_for_hit(hit, backend_name, original_url)
|
||||||
|
|
||||||
|
backend_names: List[str] = []
|
||||||
|
try:
|
||||||
|
backend_names_all = storage.list_searchable_backends()
|
||||||
|
except Exception:
|
||||||
|
backend_names_all = []
|
||||||
|
|
||||||
|
for backend_name in backend_names_all:
|
||||||
|
try:
|
||||||
|
backend = storage[backend_name]
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
if str(backend_name).strip().lower() == "temp":
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
backend_location = getattr(backend, "_location", None)
|
||||||
|
if backend_location and final_output_dir:
|
||||||
|
backend_path = Path(str(backend_location)).expanduser().resolve()
|
||||||
|
temp_path = Path(str(final_output_dir)).expanduser().resolve()
|
||||||
|
if backend_path == temp_path:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
backend_names.append(backend_name)
|
||||||
|
|
||||||
|
if not backend_names:
|
||||||
|
debug("Bulk URL preflight skipped: no searchable backends")
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
debug_panel(
|
||||||
|
"URL preflight",
|
||||||
|
[
|
||||||
|
("url_count", len(unique_urls)),
|
||||||
|
("pipeline", in_pipeline),
|
||||||
|
("bulk_mode", bulk_mode),
|
||||||
|
("backends", ", ".join(str(name) for name in backend_names)),
|
||||||
|
],
|
||||||
|
border_style="yellow",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
seen_pairs: set[tuple[str, str]] = set()
|
||||||
|
matched_urls: set[str] = set()
|
||||||
|
match_rows: List[Dict[str, Any]] = []
|
||||||
|
max_rows = 200
|
||||||
|
|
||||||
|
hydrus_provider = None
|
||||||
|
try:
|
||||||
|
from PluginCore.registry import get_plugin
|
||||||
|
|
||||||
|
hydrus_provider = get_plugin("hydrusnetwork", config)
|
||||||
|
except Exception:
|
||||||
|
hydrus_provider = None
|
||||||
|
|
||||||
|
for backend_name in backend_names:
|
||||||
|
if _timed_out("backend scan"):
|
||||||
|
return True
|
||||||
|
if len(match_rows) >= max_rows:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
backend = storage[backend_name]
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_hydrus_backend = False
|
||||||
|
try:
|
||||||
|
is_hydrus_backend = bool(hydrus_provider and hydrus_provider.is_backend(backend, str(backend_name)))
|
||||||
|
except Exception:
|
||||||
|
is_hydrus_backend = False
|
||||||
|
if not is_hydrus_backend:
|
||||||
|
try:
|
||||||
|
is_hydrus_backend = str(getattr(backend, "STORE_TYPE", "")).strip().lower() == "hydrusnetwork"
|
||||||
|
except Exception:
|
||||||
|
is_hydrus_backend = False
|
||||||
|
|
||||||
|
if is_hydrus_backend:
|
||||||
|
if not hydrus_available:
|
||||||
|
debug("Bulk URL preflight: global Hydrus availability check failed; attempting per-backend best-effort lookup")
|
||||||
|
|
||||||
|
if _timed_out("hydrus scan"):
|
||||||
|
return True
|
||||||
|
|
||||||
|
for original_url, needles in url_needles.items():
|
||||||
|
if _timed_out("hydrus per-url scan"):
|
||||||
|
return True
|
||||||
|
if len(match_rows) >= max_rows:
|
||||||
|
break
|
||||||
|
if (original_url, str(backend_name)) in seen_pairs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
found_hash: Optional[str] = None
|
||||||
|
found = False
|
||||||
|
lookup_exact = getattr(backend, "find_hashes_by_url", None)
|
||||||
|
if callable(lookup_exact):
|
||||||
|
for needle in [original_url, *(needles or [])][:7]:
|
||||||
|
needle_text = str(needle or "").strip()
|
||||||
|
if not _httpish(needle_text):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
exact_hashes = lookup_exact(needle_text) or []
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not isinstance(exact_hashes, list) or not exact_hashes:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
found_hash = str(exact_hashes[0] or "").strip().lower()
|
||||||
|
except Exception:
|
||||||
|
found_hash = None
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_pairs.add((original_url, str(backend_name)))
|
||||||
|
matched_urls.add(original_url)
|
||||||
|
display_row = build_table_result_payload(
|
||||||
|
title="(exists)",
|
||||||
|
columns=[
|
||||||
|
("Title", "(exists)"),
|
||||||
|
("Store", str(backend_name)),
|
||||||
|
("Hash", found_hash or ""),
|
||||||
|
("URL", original_url),
|
||||||
|
],
|
||||||
|
store=str(backend_name),
|
||||||
|
hash=found_hash or "",
|
||||||
|
url=original_url,
|
||||||
|
)
|
||||||
|
match_rows.append(display_row)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if bulk_mode and bulk_patterns:
|
||||||
|
bulk_hits: Optional[List[Any]] = None
|
||||||
|
bulk_limit = min(2000, max(200, len(unique_urls) * 8))
|
||||||
|
try:
|
||||||
|
bulk_hits = backend.search(
|
||||||
|
"url:*",
|
||||||
|
limit=bulk_limit,
|
||||||
|
pattern_hint=bulk_patterns,
|
||||||
|
) or []
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
bulk_hits = backend.search("url:*", limit=bulk_limit) or []
|
||||||
|
except Exception:
|
||||||
|
bulk_hits = None
|
||||||
|
|
||||||
|
if bulk_hits is not None:
|
||||||
|
for hit in bulk_hits:
|
||||||
|
if _timed_out("backend bulk scan"):
|
||||||
|
return True
|
||||||
|
if len(match_rows) >= max_rows:
|
||||||
|
break
|
||||||
|
url_values = _extract_urls_from_hit(hit, backend, allow_backend_lookup=False)
|
||||||
|
if not url_values:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for original_url, needles in url_needles.items():
|
||||||
|
if _timed_out("backend bulk scan"):
|
||||||
|
return True
|
||||||
|
if len(match_rows) >= max_rows:
|
||||||
|
break
|
||||||
|
if (original_url, str(backend_name)) in seen_pairs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
matched = False
|
||||||
|
for url_value in url_values:
|
||||||
|
for needle in (needles or []):
|
||||||
|
if _match_normalized_url(str(needle or ""), str(url_value or "")):
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if matched:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_pairs.add((original_url, str(backend_name)))
|
||||||
|
matched_urls.add(original_url)
|
||||||
|
match_rows.append(
|
||||||
|
_build_display_row_for_hit(hit, str(backend_name), original_url)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for original_url, needles in url_needles.items():
|
||||||
|
if _timed_out("backend per-url scan"):
|
||||||
|
return True
|
||||||
|
if len(match_rows) >= max_rows:
|
||||||
|
break
|
||||||
|
if (original_url, str(backend_name)) in seen_pairs:
|
||||||
|
continue
|
||||||
|
|
||||||
|
display_row = _search_backend_url_hits(backend, str(backend_name), original_url, needles)
|
||||||
|
if not display_row:
|
||||||
|
continue
|
||||||
|
|
||||||
|
seen_pairs.add((original_url, str(backend_name)))
|
||||||
|
matched_urls.add(original_url)
|
||||||
|
match_rows.append(display_row)
|
||||||
|
|
||||||
|
if not match_rows:
|
||||||
|
if in_pipeline:
|
||||||
|
preflight_cache = _load_preflight_cache()
|
||||||
|
url_dup_cache = preflight_cache.get("url_duplicates")
|
||||||
|
if not isinstance(url_dup_cache, dict):
|
||||||
|
url_dup_cache = {}
|
||||||
|
|
||||||
|
cached_urls = url_dup_cache.get("urls")
|
||||||
|
cached_set = {str(u) for u in cached_urls} if isinstance(cached_urls, list) else set()
|
||||||
|
|
||||||
|
for original_url, needles in url_needles.items():
|
||||||
|
cached_set.add(original_url)
|
||||||
|
for needle in needles or []:
|
||||||
|
cached_set.add(str(needle))
|
||||||
|
|
||||||
|
url_dup_cache["urls"] = sorted(cached_set)
|
||||||
|
preflight_cache["url_duplicates"] = url_dup_cache
|
||||||
|
_store_preflight_cache(preflight_cache)
|
||||||
|
_mark_preflight_checked()
|
||||||
|
return True
|
||||||
|
|
||||||
|
table = Table(f"URL already exists ({len(matched_urls)} url(s))", max_columns=10)
|
||||||
|
table._interactive(True)
|
||||||
|
try:
|
||||||
|
table._perseverance(True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for row in match_rows:
|
||||||
|
table.add_result(row)
|
||||||
|
|
||||||
|
try:
|
||||||
|
pipeline_context.set_last_result_table_overlay(table, match_rows)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
suspend = getattr(pipeline_context, "suspend_live_progress", None)
|
||||||
|
cm: AbstractContextManager[Any] = nullcontext()
|
||||||
|
if callable(suspend):
|
||||||
|
try:
|
||||||
|
maybe_cm = suspend()
|
||||||
|
if maybe_cm is not None:
|
||||||
|
cm = maybe_cm # type: ignore[assignment]
|
||||||
|
except Exception:
|
||||||
|
cm = nullcontext()
|
||||||
|
|
||||||
|
auto_confirm_reason: Optional[str] = None
|
||||||
|
if in_pipeline and stage_ctx is not None:
|
||||||
|
try:
|
||||||
|
total_stages = int(getattr(stage_ctx, "total_stages", 0))
|
||||||
|
except Exception:
|
||||||
|
total_stages = 0
|
||||||
|
try:
|
||||||
|
is_last_stage = bool(getattr(stage_ctx, "is_last_stage", False))
|
||||||
|
except Exception:
|
||||||
|
is_last_stage = False
|
||||||
|
if total_stages > 1 and not is_last_stage and not force_prompt_in_pipeline:
|
||||||
|
auto_confirm_reason = "pipeline stage (pre-last)"
|
||||||
|
if auto_confirm_reason is None:
|
||||||
|
try:
|
||||||
|
stdin_interactive = bool(sys.stdin and sys.stdin.isatty())
|
||||||
|
except Exception:
|
||||||
|
stdin_interactive = False
|
||||||
|
if not stdin_interactive:
|
||||||
|
auto_confirm_reason = "non-interactive stdin"
|
||||||
|
|
||||||
|
answered_yes = True
|
||||||
|
auto_declined = False
|
||||||
|
with cm:
|
||||||
|
get_stderr_console().print(table)
|
||||||
|
setattr(table, "_rendered_by_cmdlet", True)
|
||||||
|
if auto_confirm_reason is None:
|
||||||
|
answered_yes = bool(Confirm.ask("Continue?", default=False, console=get_stderr_console()))
|
||||||
|
else:
|
||||||
|
answered_yes = bool(auto_continue_duplicates)
|
||||||
|
auto_declined = not answered_yes
|
||||||
|
if answered_yes:
|
||||||
|
debug(
|
||||||
|
f"Bulk URL preflight auto-confirmed duplicates ({auto_confirm_reason}); continuing without user input."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
log(
|
||||||
|
f"Auto-confirmed duplicate URL warning ({auto_confirm_reason}). Continuing...",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
debug(
|
||||||
|
f"Bulk URL preflight auto-skipped duplicates ({auto_confirm_reason}); skipping without user input."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
log(
|
||||||
|
f"Duplicate URL detected ({auto_confirm_reason}). Skipping download.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if in_pipeline and auto_continue_duplicates:
|
||||||
|
try:
|
||||||
|
existing = pipeline_context.load_value("preflight", default=None)
|
||||||
|
except Exception:
|
||||||
|
existing = None
|
||||||
|
preflight_cache: Dict[str, Any] = existing if isinstance(existing, dict) else {}
|
||||||
|
url_dup_cache = preflight_cache.get("url_duplicates")
|
||||||
|
if not isinstance(url_dup_cache, dict):
|
||||||
|
url_dup_cache = {}
|
||||||
|
url_dup_cache["command"] = str(current_cmd_text or "")
|
||||||
|
url_dup_cache["continue"] = bool(answered_yes)
|
||||||
|
cached_urls = url_dup_cache.get("urls")
|
||||||
|
cached_set = {str(u) for u in cached_urls} if isinstance(cached_urls, list) else set()
|
||||||
|
for original_url, needles in url_needles.items():
|
||||||
|
cached_set.add(original_url)
|
||||||
|
for needle in needles or []:
|
||||||
|
cached_set.add(str(needle))
|
||||||
|
url_dup_cache["urls"] = sorted(cached_set)
|
||||||
|
preflight_cache["url_duplicates"] = url_dup_cache
|
||||||
|
try:
|
||||||
|
pipeline_context.store_value("preflight", preflight_cache)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not answered_yes:
|
||||||
|
if in_pipeline and not auto_declined:
|
||||||
|
try:
|
||||||
|
pipeline_context.request_pipeline_stop(reason="duplicate-url declined", exit_code=0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_mark_preflight_checked()
|
||||||
|
return False
|
||||||
|
_mark_preflight_checked()
|
||||||
|
return True
|
||||||
+56
-3263
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,562 @@
|
|||||||
|
"""Tag resolution, FlorenceVision integration, and metadata extraction for add-file."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
|
||||||
|
from SYS import models
|
||||||
|
from SYS.logger import log, debug
|
||||||
|
|
||||||
|
from .. import _shared as sh
|
||||||
|
|
||||||
|
# Import from add_core (safe: add_core defines these before importing this module)
|
||||||
|
from .add_core import (
|
||||||
|
Add_File,
|
||||||
|
_SCREENSHOT_TIME_SUFFIX_RE,
|
||||||
|
_REMOTE_URL_PREFIXES,
|
||||||
|
)
|
||||||
|
|
||||||
|
extract_tag_from_result = sh.extract_tag_from_result
|
||||||
|
extract_title_from_result = sh.extract_title_from_result
|
||||||
|
extract_url_from_result = sh.extract_url_from_result
|
||||||
|
merge_sequences = sh.merge_sequences
|
||||||
|
extract_relationships = sh.extract_relationships
|
||||||
|
extract_duration = sh.extract_duration
|
||||||
|
collapse_namespace_tags = sh.collapse_namespace_tags
|
||||||
|
resolve_media_kind_by_extension = sh.resolve_media_kind_by_extension
|
||||||
|
get_field = sh.get_field
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_apply_florencevision_tags(
|
||||||
|
media_path: Path,
|
||||||
|
tags: List[str],
|
||||||
|
config: Dict[str, Any],
|
||||||
|
pipe_obj: Optional[models.PipeObject] = None,
|
||||||
|
) -> List[str]:
|
||||||
|
"""Optionally auto-tag images using the FlorenceVision plugin helper.
|
||||||
|
|
||||||
|
Controlled via config:
|
||||||
|
[plugin=florencevision]
|
||||||
|
enabled=true
|
||||||
|
strict=false
|
||||||
|
|
||||||
|
If strict=false (default), failures log a warning and return the original tags.
|
||||||
|
If strict=true, failures raise to abort the ingest.
|
||||||
|
"""
|
||||||
|
strict = False
|
||||||
|
try:
|
||||||
|
plugin_block = (config or {}).get("plugin")
|
||||||
|
fv_block = plugin_block.get("florencevision") if isinstance(plugin_block, dict) else None
|
||||||
|
enabled = False
|
||||||
|
if isinstance(fv_block, dict):
|
||||||
|
enabled = bool(fv_block.get("enabled"))
|
||||||
|
strict = bool(fv_block.get("strict"))
|
||||||
|
if not enabled:
|
||||||
|
return tags
|
||||||
|
|
||||||
|
from plugins.florencevision import FlorenceVisionTool
|
||||||
|
|
||||||
|
cfg_for_tool: Dict[str, Any] = config
|
||||||
|
try:
|
||||||
|
action = str(getattr(pipe_obj, "action", "") or "") if pipe_obj is not None else ""
|
||||||
|
cmdlet_name = ""
|
||||||
|
if action.lower().startswith("cmdlet:"):
|
||||||
|
cmdlet_name = action.split(":", 1)[1].strip().lower()
|
||||||
|
if cmdlet_name in {"screen-shot", "screen_shot", "screenshot"}:
|
||||||
|
plugin_block2 = dict((config or {}).get("plugin") or {})
|
||||||
|
fv_block2 = dict(plugin_block2.get("florencevision") or {})
|
||||||
|
fv_block2["task"] = "ocr"
|
||||||
|
plugin_block2["florencevision"] = fv_block2
|
||||||
|
cfg_for_tool = dict(config or {})
|
||||||
|
cfg_for_tool["plugin"] = plugin_block2
|
||||||
|
except Exception:
|
||||||
|
cfg_for_tool = config
|
||||||
|
|
||||||
|
fv = FlorenceVisionTool(cfg_for_tool)
|
||||||
|
if not fv.enabled() or not fv.applicable_path(media_path):
|
||||||
|
return tags
|
||||||
|
|
||||||
|
auto_tags = fv.tags_for_file(media_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
caption_text = getattr(fv, "last_caption", None)
|
||||||
|
if caption_text and pipe_obj is not None:
|
||||||
|
if not isinstance(pipe_obj.extra, dict):
|
||||||
|
pipe_obj.extra = {}
|
||||||
|
notes = pipe_obj.extra.get("notes")
|
||||||
|
if not isinstance(notes, dict):
|
||||||
|
notes = {}
|
||||||
|
notes.setdefault("caption", caption_text)
|
||||||
|
pipe_obj.extra["notes"] = notes
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not auto_tags:
|
||||||
|
return tags
|
||||||
|
|
||||||
|
merged = merge_sequences(tags or [], auto_tags, case_sensitive=False)
|
||||||
|
debug(f"[add-file] FlorenceVision added {len(auto_tags)} tag(s)")
|
||||||
|
return merged
|
||||||
|
except Exception as exc:
|
||||||
|
strict2 = False
|
||||||
|
try:
|
||||||
|
tool_block = (config or {}).get("tool")
|
||||||
|
fv_block = tool_block.get("florencevision") if isinstance(tool_block, dict) else None
|
||||||
|
strict2 = bool(fv_block.get("strict")) if isinstance(fv_block, dict) else False
|
||||||
|
except Exception:
|
||||||
|
strict2 = False
|
||||||
|
|
||||||
|
if strict or strict2:
|
||||||
|
raise
|
||||||
|
log(f"[add-file] Warning: FlorenceVision tagging failed: {exc}", file=sys.stderr)
|
||||||
|
return tags
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_hash_candidate(value: Any) -> str:
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
if len(text) != 64:
|
||||||
|
return ""
|
||||||
|
if any(ch not in "0123456789abcdef" for ch in text):
|
||||||
|
return ""
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_relationship_tag_king_alts(
|
||||||
|
tag_value: str
|
||||||
|
) -> tuple[Optional[str], List[str]]:
|
||||||
|
"""Parse a relationship tag into (king_hash, alt_hashes).
|
||||||
|
|
||||||
|
Supported formats:
|
||||||
|
- New: relationship: <KING_HASH>,<ALT_HASH>,<ALT_HASH>
|
||||||
|
- Old: relationship: hash(king)<KING_HASH>,hash(alt)<ALT_HASH>...
|
||||||
|
relationship: hash(king)KING,hash(alt)ALT
|
||||||
|
|
||||||
|
For the local DB we treat the first hash listed as the king.
|
||||||
|
"""
|
||||||
|
if not isinstance(tag_value, str):
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
raw = tag_value.strip()
|
||||||
|
if not raw:
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
rhs = raw
|
||||||
|
if ":" in raw:
|
||||||
|
prefix, rest = raw.split(":", 1)
|
||||||
|
if prefix.strip().lower() == "relationship":
|
||||||
|
rhs = rest.strip()
|
||||||
|
|
||||||
|
typed = re.findall(r"hash\((\w+)\)<?([a-fA-F0-9]{64})>?", rhs)
|
||||||
|
if typed:
|
||||||
|
king: Optional[str] = None
|
||||||
|
alts: List[str] = []
|
||||||
|
for rel_type, h in typed:
|
||||||
|
h_norm = str(h).strip().lower()
|
||||||
|
if rel_type.strip().lower() == "king":
|
||||||
|
king = h_norm
|
||||||
|
elif rel_type.strip().lower() in {"alt", "related"}:
|
||||||
|
alts.append(h_norm)
|
||||||
|
if not king:
|
||||||
|
all_hashes = [str(h).strip().lower() for _, h in typed]
|
||||||
|
king = all_hashes[0] if all_hashes else None
|
||||||
|
alts = [h for h in all_hashes[1:] if h]
|
||||||
|
seen: set[str] = set()
|
||||||
|
alts = [
|
||||||
|
h for h in alts
|
||||||
|
if h and len(h) == 64 and not (h in seen or seen.add(h))
|
||||||
|
]
|
||||||
|
if king and len(king) == 64:
|
||||||
|
return king, [h for h in alts if h != king]
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
hashes = re.findall(r"\b[a-fA-F0-9]{64}\b", rhs)
|
||||||
|
hashes = [h.strip().lower() for h in hashes if isinstance(h, str)]
|
||||||
|
if not hashes:
|
||||||
|
return None, []
|
||||||
|
king = hashes[0]
|
||||||
|
alts = hashes[1:]
|
||||||
|
seen2: set[str] = set()
|
||||||
|
alts = [
|
||||||
|
h for h in alts if h and len(h) == 64 and not (h in seen2 or seen2.add(h))
|
||||||
|
]
|
||||||
|
return king, [h for h in alts if h != king]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_relationships_king_alts(
|
||||||
|
relationships: Dict[str, Any],
|
||||||
|
) -> tuple[Optional[str], List[str]]:
|
||||||
|
"""Parse a PipeObject.relationships dict into (king_hash, alt_hashes).
|
||||||
|
|
||||||
|
Supported shapes:
|
||||||
|
- {"king": [KING], "alt": [ALT1, ALT2]}
|
||||||
|
- {"king": KING, "alt": ALT} (strings)
|
||||||
|
- Also treats "related" hashes as alts for persistence purposes.
|
||||||
|
"""
|
||||||
|
if not isinstance(relationships, dict) or not relationships:
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
def _first_hash(val: Any) -> Optional[str]:
|
||||||
|
if isinstance(val, str):
|
||||||
|
h = val.strip().lower()
|
||||||
|
return h if len(h) == 64 else None
|
||||||
|
if isinstance(val, list):
|
||||||
|
for item in val:
|
||||||
|
if isinstance(item, str):
|
||||||
|
h = item.strip().lower()
|
||||||
|
if len(h) == 64:
|
||||||
|
return h
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _many_hashes(val: Any) -> List[str]:
|
||||||
|
out: List[str] = []
|
||||||
|
if isinstance(val, str):
|
||||||
|
h = val.strip().lower()
|
||||||
|
if len(h) == 64:
|
||||||
|
out.append(h)
|
||||||
|
elif isinstance(val, list):
|
||||||
|
for item in val:
|
||||||
|
if isinstance(item, str):
|
||||||
|
h = item.strip().lower()
|
||||||
|
if len(h) == 64:
|
||||||
|
out.append(h)
|
||||||
|
return out
|
||||||
|
|
||||||
|
king = _first_hash(relationships.get("king"))
|
||||||
|
if not king:
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
alts = _many_hashes(relationships.get("alt"))
|
||||||
|
alts.extend(_many_hashes(relationships.get("related")))
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
alts = [h for h in alts if h and h != king and not (h in seen or seen.add(h))]
|
||||||
|
return king, alts
|
||||||
|
|
||||||
|
|
||||||
|
def _get_url(result: Any, pipe_obj: models.PipeObject) -> List[str]:
|
||||||
|
"""Extract valid URLs from pipe object or result dict."""
|
||||||
|
from SYS.metadata import normalize_urls
|
||||||
|
|
||||||
|
candidates: List[str] = []
|
||||||
|
|
||||||
|
if pipe_obj.url:
|
||||||
|
candidates.append(pipe_obj.url)
|
||||||
|
if pipe_obj.source_url:
|
||||||
|
candidates.append(pipe_obj.source_url)
|
||||||
|
|
||||||
|
if isinstance(pipe_obj.extra, dict):
|
||||||
|
u = pipe_obj.extra.get("url")
|
||||||
|
if isinstance(u, list):
|
||||||
|
candidates.extend(str(x) for x in u if x)
|
||||||
|
elif isinstance(u, str):
|
||||||
|
candidates.append(u)
|
||||||
|
|
||||||
|
raw_from_result = extract_url_from_result(result)
|
||||||
|
if raw_from_result:
|
||||||
|
candidates.extend(raw_from_result)
|
||||||
|
|
||||||
|
normalized = normalize_urls(candidates)
|
||||||
|
return [u for u in normalized if Add_File._is_probable_url(u)]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_relationships(result: Any, pipe_obj: models.PipeObject) -> Optional[Dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
rels = pipe_obj.get_relationships()
|
||||||
|
if rels:
|
||||||
|
return rels
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if isinstance(result, dict) and result.get("relationships"):
|
||||||
|
return result.get("relationships")
|
||||||
|
try:
|
||||||
|
return extract_relationships(result)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_duration(result: Any, pipe_obj: models.PipeObject) -> Optional[float]:
|
||||||
|
|
||||||
|
def _parse_duration(value: Any) -> Optional[float]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value) if value > 0 else None
|
||||||
|
if isinstance(value, str):
|
||||||
|
s = value.strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
candidate = float(s)
|
||||||
|
return candidate if candidate > 0 else None
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if ":" in s:
|
||||||
|
parts = [p.strip() for p in s.split(":") if p.strip()]
|
||||||
|
if len(parts) in {2, 3} and all(p.isdigit() for p in parts):
|
||||||
|
nums = [int(p) for p in parts]
|
||||||
|
if len(nums) == 2:
|
||||||
|
minutes, seconds = nums
|
||||||
|
return float(minutes * 60 + seconds)
|
||||||
|
hours, minutes, seconds = nums
|
||||||
|
return float(hours * 3600 + minutes * 60 + seconds)
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed = _parse_duration(getattr(pipe_obj, "duration", None))
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
try:
|
||||||
|
return _parse_duration(extract_duration(result))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_note_text(result: Any, pipe_obj: models.PipeObject, note_name: str) -> Optional[str]:
|
||||||
|
"""Extract a named note text from a piped item.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- pipe_obj.extra["notes"][note_name]
|
||||||
|
- result["notes"][note_name] for dict results
|
||||||
|
- pipe_obj.extra[note_name] / result[note_name] as fallback
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _normalize(val: Any) -> Optional[str]:
|
||||||
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, bytes):
|
||||||
|
try:
|
||||||
|
val = val.decode("utf-8", errors="ignore")
|
||||||
|
except Exception:
|
||||||
|
val = str(val)
|
||||||
|
if isinstance(val, str):
|
||||||
|
text = val.strip()
|
||||||
|
return text if text else None
|
||||||
|
try:
|
||||||
|
text = str(val).strip()
|
||||||
|
return text if text else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
note_key = str(note_name or "").strip()
|
||||||
|
if not note_key:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(pipe_obj.extra, dict):
|
||||||
|
notes_val = pipe_obj.extra.get("notes")
|
||||||
|
if isinstance(notes_val, dict) and note_key in notes_val:
|
||||||
|
return _normalize(notes_val.get(note_key))
|
||||||
|
if note_key in pipe_obj.extra:
|
||||||
|
return _normalize(pipe_obj.extra.get(note_key))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if isinstance(result, dict):
|
||||||
|
try:
|
||||||
|
notes_val = result.get("notes")
|
||||||
|
if isinstance(notes_val, dict) and note_key in notes_val:
|
||||||
|
return _normalize(notes_val.get(note_key))
|
||||||
|
if note_key in result:
|
||||||
|
return _normalize(result.get(note_key))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sidecar_bundle(
|
||||||
|
media_path: Path,
|
||||||
|
instance: Optional[str],
|
||||||
|
config: Dict[str, Any],
|
||||||
|
) -> Tuple[Optional[Path], Optional[str], List[str], List[str]]:
|
||||||
|
"""Load sidecar metadata (placeholder — overridden by active plugins)."""
|
||||||
|
return None, None, [], []
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_file_hash(
|
||||||
|
result: Any,
|
||||||
|
media_path: Path,
|
||||||
|
pipe_obj: models.PipeObject,
|
||||||
|
fallback_hash: Optional[str],
|
||||||
|
) -> Optional[str]:
|
||||||
|
from SYS.utils import sha256_file
|
||||||
|
|
||||||
|
if pipe_obj.hash and pipe_obj.hash != "unknown":
|
||||||
|
return pipe_obj.hash
|
||||||
|
if fallback_hash:
|
||||||
|
return fallback_hash
|
||||||
|
|
||||||
|
if isinstance(result, dict):
|
||||||
|
candidate = result.get("hash")
|
||||||
|
if candidate:
|
||||||
|
return str(candidate)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return sha256_file(media_path)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_media_kind(path: Path) -> str:
|
||||||
|
return resolve_media_kind_by_extension(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_metadata(
|
||||||
|
result: Any,
|
||||||
|
media_path: Path,
|
||||||
|
pipe_obj: models.PipeObject,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
) -> Tuple[List[str], List[str], Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Prepare tags, url, and title for the file.
|
||||||
|
Returns (tags, url, preferred_title, file_hash)
|
||||||
|
"""
|
||||||
|
tags_from_result = list(pipe_obj.tag or [])
|
||||||
|
if not tags_from_result:
|
||||||
|
try:
|
||||||
|
tags_from_result = list(extract_tag_from_result(result) or [])
|
||||||
|
except Exception:
|
||||||
|
tags_from_result = []
|
||||||
|
|
||||||
|
url_from_result = Add_File._get_url(result, pipe_obj)
|
||||||
|
|
||||||
|
def _has_namespace_tag(tags: Sequence[str], namespace: str) -> bool:
|
||||||
|
namespace_text = str(namespace or "").strip().lower()
|
||||||
|
if not namespace_text:
|
||||||
|
return False
|
||||||
|
prefix = f"{namespace_text}:"
|
||||||
|
for tag in tags or []:
|
||||||
|
text = str(tag or "").strip().lower()
|
||||||
|
if text.startswith(prefix):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _extract_screenshot_time_title() -> tuple[Optional[str], Optional[str]]:
|
||||||
|
current_title = str(preferred_title or "").strip()
|
||||||
|
filename_title = str(media_path.stem or "").strip()
|
||||||
|
if current_title and current_title != filename_title:
|
||||||
|
return None, None
|
||||||
|
if not url_from_result:
|
||||||
|
return None, None
|
||||||
|
suffix = str(media_path.suffix or "").strip().lower()
|
||||||
|
if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".avif", ".mhtml"}:
|
||||||
|
return None, None
|
||||||
|
match = _SCREENSHOT_TIME_SUFFIX_RE.match(str(media_path.stem or "").strip())
|
||||||
|
if not match:
|
||||||
|
return None, None
|
||||||
|
title_text = str(match.group("title") or "").strip().replace("_", " ").strip()
|
||||||
|
label_text = str(match.group("label") or "").strip().lower()
|
||||||
|
if not title_text or not label_text:
|
||||||
|
return None, None
|
||||||
|
return title_text, label_text
|
||||||
|
|
||||||
|
preferred_title = pipe_obj.title
|
||||||
|
if not preferred_title:
|
||||||
|
for t in tags_from_result:
|
||||||
|
if str(t).strip().lower().startswith("title:"):
|
||||||
|
candidate = t.split(":", 1)[1].strip().replace("_", " ").strip()
|
||||||
|
if candidate:
|
||||||
|
preferred_title = candidate
|
||||||
|
break
|
||||||
|
if not preferred_title:
|
||||||
|
preferred_title = extract_title_from_result(result)
|
||||||
|
if preferred_title:
|
||||||
|
preferred_title = preferred_title.replace("_", " ").strip()
|
||||||
|
|
||||||
|
derived_screenshot_title, derived_time_tag = _extract_screenshot_time_title()
|
||||||
|
if derived_screenshot_title and (
|
||||||
|
not preferred_title or str(preferred_title or "").strip() == str(media_path.stem or "").strip()
|
||||||
|
):
|
||||||
|
preferred_title = derived_screenshot_title
|
||||||
|
|
||||||
|
store = getattr(pipe_obj, "store", None)
|
||||||
|
_, sidecar_hash, sidecar_tags, sidecar_url = Add_File._load_sidecar_bundle(
|
||||||
|
media_path, store, config
|
||||||
|
)
|
||||||
|
|
||||||
|
def normalize_title_tag(tag: str) -> str:
|
||||||
|
if str(tag).strip().lower().startswith("title:"):
|
||||||
|
parts = tag.split(":", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
value = parts[1].replace("_", " ").strip()
|
||||||
|
return f"title:{value}"
|
||||||
|
return tag
|
||||||
|
|
||||||
|
tags_from_result_no_title = [
|
||||||
|
t for t in tags_from_result
|
||||||
|
if not str(t).strip().lower().startswith("title:")
|
||||||
|
]
|
||||||
|
sidecar_tags = collapse_namespace_tags(
|
||||||
|
[normalize_title_tag(t) for t in sidecar_tags],
|
||||||
|
"title",
|
||||||
|
prefer="last"
|
||||||
|
)
|
||||||
|
sidecar_tags_filtered = [
|
||||||
|
t for t in sidecar_tags if not str(t).strip().lower().startswith("title:")
|
||||||
|
]
|
||||||
|
|
||||||
|
merged_tags = merge_sequences(
|
||||||
|
tags_from_result_no_title,
|
||||||
|
sidecar_tags_filtered,
|
||||||
|
case_sensitive=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if derived_time_tag and not _has_namespace_tag(merged_tags, "time") and not _has_namespace_tag(merged_tags, "timestamp"):
|
||||||
|
merged_tags.append(f"time:{derived_time_tag}")
|
||||||
|
|
||||||
|
if preferred_title:
|
||||||
|
merged_tags.append(f"title:{preferred_title}")
|
||||||
|
|
||||||
|
merged_url = merge_sequences(url_from_result, sidecar_url, case_sensitive=False)
|
||||||
|
merged_url = [u for u in merged_url if Add_File._is_probable_url(u)]
|
||||||
|
|
||||||
|
file_hash = Add_File._resolve_file_hash(
|
||||||
|
result,
|
||||||
|
media_path,
|
||||||
|
pipe_obj,
|
||||||
|
sidecar_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
relationship_tags = [
|
||||||
|
t for t in merged_tags
|
||||||
|
if isinstance(t, str) and t.strip().lower().startswith("relationship:")
|
||||||
|
]
|
||||||
|
if relationship_tags:
|
||||||
|
try:
|
||||||
|
if (not isinstance(getattr(pipe_obj, "relationships", None), dict) or not pipe_obj.relationships):
|
||||||
|
king: Optional[str] = None
|
||||||
|
alts: List[str] = []
|
||||||
|
for rel_tag in relationship_tags:
|
||||||
|
k, a = _parse_relationship_tag_king_alts(rel_tag)
|
||||||
|
if k and not king:
|
||||||
|
king = k
|
||||||
|
if a:
|
||||||
|
alts.extend(a)
|
||||||
|
if king:
|
||||||
|
seen_alt: set[str] = set()
|
||||||
|
alts = [
|
||||||
|
h for h in alts if h and h != king and len(h) == 64
|
||||||
|
and not (h in seen_alt or seen_alt.add(h))
|
||||||
|
]
|
||||||
|
payload: Dict[str, Any] = {"king": [king]}
|
||||||
|
if alts:
|
||||||
|
payload["alt"] = alts
|
||||||
|
pipe_obj.relationships = payload
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
merged_tags = [
|
||||||
|
t for t in merged_tags if
|
||||||
|
not (isinstance(t, str) and t.strip().lower().startswith("relationship:"))
|
||||||
|
]
|
||||||
|
|
||||||
|
pipe_obj.tag = merged_tags
|
||||||
|
if preferred_title and not pipe_obj.title:
|
||||||
|
pipe_obj.title = preferred_title
|
||||||
|
if file_hash and not pipe_obj.hash:
|
||||||
|
pipe_obj.hash = file_hash
|
||||||
|
if isinstance(pipe_obj.extra, dict):
|
||||||
|
pipe_obj.extra["url"] = merged_url
|
||||||
|
return merged_tags, merged_url, preferred_title, file_hash
|
||||||
@@ -0,0 +1,611 @@
|
|||||||
|
"""File validation, path resolution, and source fetching for add-file."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional, List
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from SYS import models
|
||||||
|
from SYS import pipeline as ctx
|
||||||
|
from SYS.logger import log, debug
|
||||||
|
from SYS.pipeline_progress import PipelineProgress
|
||||||
|
from SYS.utils import sha256_file, sanitize_filename
|
||||||
|
from API.HTTP import download_direct_file
|
||||||
|
|
||||||
|
from .. import _shared as sh
|
||||||
|
|
||||||
|
# Import from add_core (safe: add_core defines these before importing this module)
|
||||||
|
from .add_core import (
|
||||||
|
Add_File,
|
||||||
|
_CommandDependencies,
|
||||||
|
SUPPORTED_MEDIA_EXTENSIONS,
|
||||||
|
_REMOTE_URL_PREFIXES,
|
||||||
|
)
|
||||||
|
|
||||||
|
coerce_to_path = sh.coerce_to_path
|
||||||
|
get_field = sh.get_field
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_backend_by_name(instance: Any, backend_name: str) -> Optional[Any]:
|
||||||
|
if not instance or not backend_name:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return instance[backend_name]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
target = str(backend_name or "").strip().lower()
|
||||||
|
if not target:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
for candidate in instance.list_backends():
|
||||||
|
if isinstance(candidate, str) and candidate.strip().lower() == target:
|
||||||
|
try:
|
||||||
|
return instance[candidate]
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_provider_filename(
|
||||||
|
pipe_obj: models.PipeObject,
|
||||||
|
fallback_hash: Optional[str] = None,
|
||||||
|
source_url: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
title_candidates: List[str] = []
|
||||||
|
title_value = getattr(pipe_obj, "title", "")
|
||||||
|
if title_value:
|
||||||
|
title_candidates.append(str(title_value))
|
||||||
|
|
||||||
|
extra = getattr(pipe_obj, "extra", {})
|
||||||
|
if isinstance(extra, dict):
|
||||||
|
candid = extra.get("name") or extra.get("title")
|
||||||
|
if candid:
|
||||||
|
title_candidates.append(str(candid))
|
||||||
|
|
||||||
|
metadata = getattr(pipe_obj, "metadata", {})
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
meta_name = metadata.get("title") or metadata.get("name")
|
||||||
|
if meta_name:
|
||||||
|
title_candidates.append(str(meta_name))
|
||||||
|
|
||||||
|
text = ""
|
||||||
|
for candidate in title_candidates:
|
||||||
|
if candidate:
|
||||||
|
text = candidate.strip()
|
||||||
|
if text:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not text and fallback_hash:
|
||||||
|
text = fallback_hash[:8]
|
||||||
|
|
||||||
|
safe_name = sanitize_filename(text or "download")
|
||||||
|
|
||||||
|
ext = ""
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
ext = metadata.get("ext") or metadata.get("extension") or ""
|
||||||
|
if not ext and isinstance(extra, dict):
|
||||||
|
ext = extra.get("ext") or ""
|
||||||
|
if not ext and source_url:
|
||||||
|
try:
|
||||||
|
parsed = urlparse(source_url)
|
||||||
|
ext = Path(parsed.path).suffix.lstrip(".")
|
||||||
|
except Exception:
|
||||||
|
ext = ""
|
||||||
|
|
||||||
|
if ext:
|
||||||
|
ext_text = str(ext)
|
||||||
|
if not ext_text.startswith("."):
|
||||||
|
ext_text = "." + ext_text.lstrip(".")
|
||||||
|
if not safe_name.lower().endswith(ext_text.lower()):
|
||||||
|
safe_name = f"{safe_name}{ext_text}"
|
||||||
|
|
||||||
|
return safe_name or "download"
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_download_backend_file(
|
||||||
|
backend: Any,
|
||||||
|
file_hash: str,
|
||||||
|
pipe_obj: models.PipeObject,
|
||||||
|
*,
|
||||||
|
output_dir: Optional[Path] = None,
|
||||||
|
) -> 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:
|
||||||
|
suffix = None
|
||||||
|
if pipe_obj.path:
|
||||||
|
try:
|
||||||
|
suffix = Path(pipe_obj.path).suffix
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not suffix:
|
||||||
|
metadata = getattr(pipe_obj, "metadata", {})
|
||||||
|
if isinstance(metadata, dict):
|
||||||
|
suffix = metadata.get("ext")
|
||||||
|
|
||||||
|
download_root = output_dir
|
||||||
|
if download_root is None:
|
||||||
|
tmp_dir = Path(tempfile.mkdtemp(prefix="add-file-src-"))
|
||||||
|
download_root = tmp_dir
|
||||||
|
if download_root is None:
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
sig = inspect.signature(downloader)
|
||||||
|
kwargs = {"temp_root": download_root}
|
||||||
|
if "suffix" in sig.parameters:
|
||||||
|
kwargs["suffix"] = suffix
|
||||||
|
|
||||||
|
pipeline_progress = PipelineProgress(ctx)
|
||||||
|
transfer_label = "peer transfer"
|
||||||
|
try:
|
||||||
|
transfer_label = str(getattr(pipe_obj, "title", "") or "").strip() or transfer_label
|
||||||
|
except Exception:
|
||||||
|
transfer_label = "peer transfer"
|
||||||
|
if "pipeline_progress" in sig.parameters:
|
||||||
|
kwargs["pipeline_progress"] = pipeline_progress
|
||||||
|
if "transfer_label" in sig.parameters:
|
||||||
|
kwargs["transfer_label"] = transfer_label
|
||||||
|
if "progress_callback" in sig.parameters:
|
||||||
|
|
||||||
|
def _cb(done, total):
|
||||||
|
try:
|
||||||
|
total_val = int(total) if total is not None else None
|
||||||
|
except Exception:
|
||||||
|
total_val = None
|
||||||
|
try:
|
||||||
|
if int(done or 0) <= 0:
|
||||||
|
pipeline_progress.begin_transfer(
|
||||||
|
label=transfer_label,
|
||||||
|
total=total_val,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
pipeline_progress.update_transfer(
|
||||||
|
label=transfer_label,
|
||||||
|
completed=int(done or 0),
|
||||||
|
total=total_val,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
kwargs["progress_callback"] = _cb
|
||||||
|
|
||||||
|
downloaded = downloader(str(file_hash), **kwargs)
|
||||||
|
|
||||||
|
if isinstance(downloaded, Path) and downloaded.exists():
|
||||||
|
if output_dir is not None:
|
||||||
|
pipe_obj.is_temp = False
|
||||||
|
if isinstance(pipe_obj.extra, dict):
|
||||||
|
pipe_obj.extra["_direct_export_download"] = True
|
||||||
|
else:
|
||||||
|
pipe_obj.extra = {"_direct_export_download": True}
|
||||||
|
return downloaded, None
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _download_remote_backend_url(
|
||||||
|
remote_url: str,
|
||||||
|
pipe_obj: models.PipeObject,
|
||||||
|
*,
|
||||||
|
file_hash: Optional[str] = None,
|
||||||
|
output_dir: Optional[Path] = None,
|
||||||
|
) -> tuple[Optional[Path], Optional[Path]]:
|
||||||
|
"""Best-effort fetch of a remote backend URL.
|
||||||
|
|
||||||
|
Returns (downloaded_path, temp_dir_to_cleanup).
|
||||||
|
When ``output_dir`` is provided, the file is downloaded directly there and no
|
||||||
|
temp cleanup path is returned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
url_text = str(remote_url or "").strip()
|
||||||
|
if not url_text:
|
||||||
|
return None, None
|
||||||
|
if not url_text.lower().startswith(_REMOTE_URL_PREFIXES):
|
||||||
|
return None, None
|
||||||
|
if not url_text.lower().startswith(("http://", "https://")):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
tmp_dir: Optional[Path] = None
|
||||||
|
try:
|
||||||
|
download_root = output_dir
|
||||||
|
if download_root is None:
|
||||||
|
tmp_dir = Path(tempfile.mkdtemp(prefix="add-file-src-"))
|
||||||
|
download_root = tmp_dir
|
||||||
|
|
||||||
|
suggested_name = _build_provider_filename(
|
||||||
|
pipe_obj,
|
||||||
|
fallback_hash=file_hash,
|
||||||
|
source_url=url_text,
|
||||||
|
)
|
||||||
|
pipeline_progress = PipelineProgress(ctx)
|
||||||
|
try:
|
||||||
|
destination_label = str(download_root) if download_root is not None else "temporary workspace"
|
||||||
|
pipeline_progress.set_status(f"downloading {suggested_name} to {destination_label}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
downloaded = download_direct_file(
|
||||||
|
url_text,
|
||||||
|
download_root,
|
||||||
|
quiet=False,
|
||||||
|
suggested_filename=suggested_name,
|
||||||
|
pipeline_progress=pipeline_progress,
|
||||||
|
)
|
||||||
|
downloaded_path = getattr(downloaded, "path", None)
|
||||||
|
if isinstance(downloaded_path, Path) and downloaded_path.exists():
|
||||||
|
if output_dir is not None:
|
||||||
|
pipe_obj.is_temp = False
|
||||||
|
if isinstance(pipe_obj.extra, dict):
|
||||||
|
pipe_obj.extra["_direct_export_download"] = True
|
||||||
|
else:
|
||||||
|
pipe_obj.extra = {"_direct_export_download": True}
|
||||||
|
return downloaded_path, None
|
||||||
|
|
||||||
|
pipe_obj.is_temp = True
|
||||||
|
return downloaded_path, tmp_dir
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
PipelineProgress(ctx).clear_status()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if tmp_dir is not None:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(tmp_dir, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_download_plugin_result(
|
||||||
|
result: Any,
|
||||||
|
pipe_obj: models.PipeObject,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
deps: Optional[_CommandDependencies] = None,
|
||||||
|
) -> tuple[Optional[Path], Optional[str], Optional[Path]]:
|
||||||
|
plugin_key = None
|
||||||
|
for source in (
|
||||||
|
pipe_obj.plugin,
|
||||||
|
get_field(result, "plugin"),
|
||||||
|
get_field(result, "table"),
|
||||||
|
):
|
||||||
|
candidate = Add_File._normalize_provider_key(source)
|
||||||
|
if candidate:
|
||||||
|
plugin_key = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if not plugin_key:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
if deps is None:
|
||||||
|
deps = _CommandDependencies(config)
|
||||||
|
|
||||||
|
plugin = deps.get_plugin(plugin_key)
|
||||||
|
if plugin is None:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return plugin.resolve_pipe_result_download(result, pipe_obj)
|
||||||
|
except Exception as exc:
|
||||||
|
debug(f"[add-file] Plugin '{plugin_key}' download helper failed: {exc}")
|
||||||
|
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _download_piped_source(
|
||||||
|
pipe_obj: models.PipeObject,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
store_instance: Optional[Any],
|
||||||
|
deps: Optional[_CommandDependencies] = None,
|
||||||
|
) -> tuple[Optional[Path], Optional[str], Optional[Path]]:
|
||||||
|
r_hash = str(getattr(pipe_obj, "hash", None) or getattr(pipe_obj, "file_hash", None) or "").strip()
|
||||||
|
r_store = str(getattr(pipe_obj, "store", None) or "").strip()
|
||||||
|
if not (r_hash and r_store):
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
if deps is None:
|
||||||
|
deps = _CommandDependencies(config)
|
||||||
|
|
||||||
|
backend_registry = store_instance or deps.get_backend_registry()
|
||||||
|
backend = _resolve_backend_by_name(backend_registry, r_store) if backend_registry is not None else None
|
||||||
|
if backend is None:
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
source = backend.get_file(r_hash.lower())
|
||||||
|
if isinstance(source, Path) and source.exists():
|
||||||
|
pipe_obj.path = str(source)
|
||||||
|
return source, str(r_hash), None
|
||||||
|
if isinstance(source, str) and source.strip():
|
||||||
|
dl_path, tmp_dir = _maybe_download_backend_file(
|
||||||
|
backend, str(r_hash), pipe_obj
|
||||||
|
)
|
||||||
|
if dl_path and dl_path.exists():
|
||||||
|
return dl_path, str(r_hash), tmp_dir
|
||||||
|
source_url = str(source).strip()
|
||||||
|
if source_url.lower().startswith(("http://", "https://")):
|
||||||
|
download_dir = Path(tempfile.mkdtemp(prefix="add-file-src-"))
|
||||||
|
try:
|
||||||
|
filename = _build_provider_filename(
|
||||||
|
pipe_obj,
|
||||||
|
str(r_hash),
|
||||||
|
source_url,
|
||||||
|
)
|
||||||
|
downloaded = download_direct_file(
|
||||||
|
source_url,
|
||||||
|
download_dir,
|
||||||
|
quiet=True,
|
||||||
|
suggested_filename=filename,
|
||||||
|
)
|
||||||
|
downloaded_path = downloaded.path
|
||||||
|
if downloaded_path and downloaded_path.exists():
|
||||||
|
pipe_obj.is_temp = True
|
||||||
|
pipe_obj.path = str(downloaded_path)
|
||||||
|
return downloaded_path, str(r_hash), download_dir
|
||||||
|
except Exception as exc:
|
||||||
|
debug(f"[add-file] Provider download failed: {exc}")
|
||||||
|
try:
|
||||||
|
shutil.rmtree(download_dir, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_directory_for_files(directory: Path, compute_hash: bool = True) -> List[Dict[str, Any]]:
|
||||||
|
"""Scan a directory for supported media files and return list of file info dicts.
|
||||||
|
|
||||||
|
Each dict contains:
|
||||||
|
- path: Path object
|
||||||
|
- name: filename
|
||||||
|
- hash: sha256 hash (or None if compute_hash=False)
|
||||||
|
- size: file size in bytes
|
||||||
|
- ext: file extension
|
||||||
|
"""
|
||||||
|
if not directory.exists() or not directory.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
files_info: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for item in directory.iterdir():
|
||||||
|
if not item.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
ext = item.suffix.lower()
|
||||||
|
if ext not in SUPPORTED_MEDIA_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_hash = None
|
||||||
|
if compute_hash:
|
||||||
|
try:
|
||||||
|
file_hash = sha256_file(item)
|
||||||
|
except Exception as exc:
|
||||||
|
debug(f"Failed to hash {item}: {exc}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
size = item.stat().st_size
|
||||||
|
except Exception:
|
||||||
|
size = 0
|
||||||
|
|
||||||
|
files_info.append(
|
||||||
|
{
|
||||||
|
"path": item,
|
||||||
|
"name": item.name,
|
||||||
|
"hash": file_hash,
|
||||||
|
"size": size,
|
||||||
|
"ext": ext,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
debug(f"Error scanning directory {directory}: {exc}")
|
||||||
|
|
||||||
|
return files_info
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_source(media_path: Optional[Path], allow_all_extensions: bool = False) -> bool:
|
||||||
|
"""Validate that the source file exists and is supported.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media_path: Path to the file to validate
|
||||||
|
allow_all_extensions: If True, skip file type filtering for non-backend exports.
|
||||||
|
If False, only allow SUPPORTED_MEDIA_EXTENSIONS for backend ingest.
|
||||||
|
"""
|
||||||
|
if media_path is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not media_path.exists() or not media_path.is_file():
|
||||||
|
log(f"File not found: {media_path}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not allow_all_extensions:
|
||||||
|
file_extension = media_path.suffix.lower()
|
||||||
|
if file_extension not in SUPPORTED_MEDIA_EXTENSIONS:
|
||||||
|
log(f"❌ Unsupported file type: {file_extension}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _is_probable_url(s: Any) -> bool:
|
||||||
|
"""Check if a string looks like a URL/magnet/identifier (vs a tag/title)."""
|
||||||
|
if not isinstance(s, str):
|
||||||
|
return False
|
||||||
|
val = s.strip().lower()
|
||||||
|
if not val:
|
||||||
|
return False
|
||||||
|
if val.startswith(_REMOTE_URL_PREFIXES):
|
||||||
|
return True
|
||||||
|
if "://" in val:
|
||||||
|
return True
|
||||||
|
if val.startswith("hash:"):
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_source(
|
||||||
|
result: Any,
|
||||||
|
source_arg: Optional[str],
|
||||||
|
pipe_obj: models.PipeObject,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
export_destination: Optional[Path] = None,
|
||||||
|
store_instance: Optional[Any] = None,
|
||||||
|
deps: Optional[_CommandDependencies] = None,
|
||||||
|
) -> tuple[Optional[Path], Optional[str], Optional[Path]]:
|
||||||
|
"""Resolve the source file path from the positional source arg or pipeline result.
|
||||||
|
|
||||||
|
Returns (media_path, file_hash, temp_dir_to_cleanup).
|
||||||
|
"""
|
||||||
|
# PRIORITY 1a: Prefer an explicit local path when it exists.
|
||||||
|
if isinstance(result, dict):
|
||||||
|
r_path = result.get("path")
|
||||||
|
r_hash = result.get("hash")
|
||||||
|
if r_path:
|
||||||
|
try:
|
||||||
|
p = coerce_to_path(r_path)
|
||||||
|
if p.exists() and p.is_file():
|
||||||
|
pipe_obj.path = str(p)
|
||||||
|
return p, str(r_hash) if r_hash else None, None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# PRIORITY 1b: Try hash+store from result (fetch from backend)
|
||||||
|
r_hash = get_field(result, "hash") or get_field(result, "file_hash")
|
||||||
|
r_store = get_field(result, "store")
|
||||||
|
|
||||||
|
if r_hash and r_store:
|
||||||
|
try:
|
||||||
|
if deps is None:
|
||||||
|
deps = _CommandDependencies(config)
|
||||||
|
backend_registry = store_instance or deps.get_backend_registry()
|
||||||
|
|
||||||
|
backend = _resolve_backend_by_name(backend_registry, r_store)
|
||||||
|
if backend is not None:
|
||||||
|
mp = backend.get_file(r_hash)
|
||||||
|
if isinstance(mp, Path) and mp.exists():
|
||||||
|
pipe_obj.path = str(mp)
|
||||||
|
return mp, str(r_hash), None
|
||||||
|
if isinstance(mp, str) and mp.strip():
|
||||||
|
try:
|
||||||
|
mp_path = Path(str(mp))
|
||||||
|
except Exception:
|
||||||
|
mp_path = None
|
||||||
|
if mp_path is not None and mp_path.exists() and mp_path.is_file():
|
||||||
|
pipe_obj.path = str(mp_path)
|
||||||
|
return mp_path, str(r_hash), None
|
||||||
|
|
||||||
|
dl_path, tmp_dir = _maybe_download_backend_file(
|
||||||
|
backend,
|
||||||
|
str(r_hash),
|
||||||
|
pipe_obj,
|
||||||
|
output_dir=export_destination,
|
||||||
|
)
|
||||||
|
if dl_path and dl_path.exists():
|
||||||
|
pipe_obj.path = str(dl_path)
|
||||||
|
return dl_path, str(r_hash), tmp_dir
|
||||||
|
|
||||||
|
dl_path, tmp_dir = _download_remote_backend_url(
|
||||||
|
str(mp),
|
||||||
|
pipe_obj,
|
||||||
|
file_hash=str(r_hash),
|
||||||
|
output_dir=export_destination,
|
||||||
|
)
|
||||||
|
if dl_path and dl_path.exists():
|
||||||
|
pipe_obj.path = str(dl_path)
|
||||||
|
return dl_path, str(r_hash), tmp_dir
|
||||||
|
except Exception as exc:
|
||||||
|
debug(f"[add-file] _resolve_source backend fetch failed for {r_store}/{r_hash}: {exc}")
|
||||||
|
|
||||||
|
# PRIORITY 2: Generic Coercion (Path arg > PipeObject > Result)
|
||||||
|
candidate: Optional[Path] = None
|
||||||
|
|
||||||
|
if source_arg:
|
||||||
|
candidate = Path(source_arg)
|
||||||
|
elif pipe_obj.path:
|
||||||
|
candidate = Path(pipe_obj.path)
|
||||||
|
|
||||||
|
if not candidate:
|
||||||
|
obj = result[0] if isinstance(result, list) and result else result
|
||||||
|
if obj:
|
||||||
|
try:
|
||||||
|
candidate = coerce_to_path(obj)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if candidate:
|
||||||
|
s = str(candidate).lower()
|
||||||
|
if s.startswith(_REMOTE_URL_PREFIXES):
|
||||||
|
downloaded_path, hash_hint, tmp_dir = _maybe_download_plugin_result(
|
||||||
|
result,
|
||||||
|
pipe_obj,
|
||||||
|
config,
|
||||||
|
deps=deps,
|
||||||
|
)
|
||||||
|
if downloaded_path:
|
||||||
|
pipe_obj.path = str(downloaded_path)
|
||||||
|
return downloaded_path, hash_hint, tmp_dir
|
||||||
|
|
||||||
|
dl_path, tmp_dir = _download_remote_backend_url(
|
||||||
|
str(candidate),
|
||||||
|
pipe_obj,
|
||||||
|
file_hash=get_field(result, "hash") or get_field(result, "file_hash"),
|
||||||
|
output_dir=export_destination,
|
||||||
|
)
|
||||||
|
if dl_path:
|
||||||
|
pipe_obj.path = str(dl_path)
|
||||||
|
hash_hint = get_field(result, "hash") or get_field(result, "file_hash")
|
||||||
|
return dl_path, hash_hint, tmp_dir
|
||||||
|
|
||||||
|
log("add-file could not auto-fetch remote source. Use download-file first.", file=sys.stderr)
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
pipe_obj.path = str(candidate)
|
||||||
|
hash_hint = get_field(result, "hash") or get_field(result, "file_hash") or getattr(pipe_obj, "hash", None)
|
||||||
|
return candidate, hash_hint, None
|
||||||
|
|
||||||
|
downloaded_path, hash_hint, tmp_dir = _maybe_download_plugin_result(
|
||||||
|
result,
|
||||||
|
pipe_obj,
|
||||||
|
config,
|
||||||
|
deps=deps,
|
||||||
|
)
|
||||||
|
if downloaded_path:
|
||||||
|
pipe_obj.path = str(downloaded_path)
|
||||||
|
return downloaded_path, hash_hint, tmp_dir
|
||||||
|
|
||||||
|
debug(f"No resolution path matched. result type={type(result).__name__}")
|
||||||
|
log("File path could not be resolved")
|
||||||
|
return None, None, None
|
||||||
+50
-3095
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,897 @@
|
|||||||
|
"""Generic file/stream downloader — core orchestration.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Direct HTTP file URLs (PDFs, images, documents; non-yt-dlp)
|
||||||
|
- Piped plugin items (uses plugin.download when available)
|
||||||
|
- Streaming sites via yt-dlp (YouTube, Bandcamp, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence as SequenceABC
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from contextlib import AbstractContextManager, nullcontext
|
||||||
|
import shutil
|
||||||
|
import webbrowser
|
||||||
|
|
||||||
|
from API.HTTP import download_direct_file
|
||||||
|
from SYS.models import DownloadError, DownloadOptions, DownloadMediaResult
|
||||||
|
from SYS.logger import log, debug_panel, is_debug_enabled
|
||||||
|
from SYS.payload_builders import build_file_result_payload, build_table_result_payload
|
||||||
|
from SYS.pipeline_progress import PipelineProgress
|
||||||
|
from SYS.result_table import Table, build_display_row
|
||||||
|
from SYS.rich_display import stderr_console as get_stderr_console
|
||||||
|
from SYS import pipeline as pipeline_context
|
||||||
|
from SYS.item_accessors import get_result_title
|
||||||
|
from rich.prompt import Prompt
|
||||||
|
from SYS.selection_builder import (
|
||||||
|
build_hash_store_selection,
|
||||||
|
extract_selection_fields,
|
||||||
|
extract_urls_from_selection_args,
|
||||||
|
selection_args_have_url,
|
||||||
|
)
|
||||||
|
from SYS.utils import sha256_file
|
||||||
|
|
||||||
|
try:
|
||||||
|
from plugins.ytdlp import YtDlpTool # type: ignore
|
||||||
|
except Exception: # pragma: no cover - optional dependency for tests/runtime wrappers
|
||||||
|
YtDlpTool = None # type: ignore
|
||||||
|
|
||||||
|
from .. import _shared as sh
|
||||||
|
|
||||||
|
Cmdlet = sh.Cmdlet
|
||||||
|
CmdletArg = sh.CmdletArg
|
||||||
|
SharedArgs = sh.SharedArgs
|
||||||
|
QueryArg = sh.QueryArg
|
||||||
|
parse_cmdlet_args = sh.parse_cmdlet_args
|
||||||
|
register_url_with_local_library = sh.register_url_with_local_library
|
||||||
|
coerce_to_pipe_object = sh.coerce_to_pipe_object
|
||||||
|
get_field = sh.get_field
|
||||||
|
resolve_target_dir = sh.resolve_target_dir
|
||||||
|
coerce_to_path = sh.coerce_to_path
|
||||||
|
build_pipeline_preview = sh.build_pipeline_preview
|
||||||
|
|
||||||
|
|
||||||
|
class Download_File(Cmdlet):
|
||||||
|
"""Class-based download-file cmdlet - direct HTTP downloads."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize download-file cmdlet."""
|
||||||
|
super().__init__(
|
||||||
|
name="download-file",
|
||||||
|
summary="Download files or streaming media",
|
||||||
|
usage=
|
||||||
|
"download-file <url|path> [-plugin NAME] [-instance NAME] [-path DIR] [options] OR @N | download-file [-plugin NAME] [-instance NAME] [-path DIR] [options] OR download-file -query \"hash:<sha256>\" -instance <store> [-browser]",
|
||||||
|
alias=["dl-file",
|
||||||
|
"download-http"],
|
||||||
|
arg=[
|
||||||
|
SharedArgs.URL,
|
||||||
|
SharedArgs.PLUGIN,
|
||||||
|
SharedArgs.INSTANCE,
|
||||||
|
SharedArgs.PATH,
|
||||||
|
SharedArgs.QUERY,
|
||||||
|
CmdletArg(
|
||||||
|
name="name",
|
||||||
|
type="string",
|
||||||
|
description="Output filename override for store exports.",
|
||||||
|
),
|
||||||
|
CmdletArg(
|
||||||
|
name="browser",
|
||||||
|
type="flag",
|
||||||
|
description="Open a backend-provided browser URL instead of exporting to disk when available.",
|
||||||
|
),
|
||||||
|
QueryArg(
|
||||||
|
"clip",
|
||||||
|
key="clip",
|
||||||
|
aliases=["range",
|
||||||
|
"section",
|
||||||
|
"sections"],
|
||||||
|
type="string",
|
||||||
|
required=False,
|
||||||
|
description=(
|
||||||
|
"Clip time ranges via -query keyed fields (e.g. clip:1m-2m or clip:00:01-00:10). "
|
||||||
|
"Comma-separated values supported."
|
||||||
|
),
|
||||||
|
query_only=True,
|
||||||
|
),
|
||||||
|
CmdletArg(
|
||||||
|
name="item",
|
||||||
|
type="string",
|
||||||
|
description="Item selection for playlists/formats",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
detail=[
|
||||||
|
"Download files directly via HTTP or streaming media via yt-dlp.",
|
||||||
|
"Also exports store-backed files via hash+store selection or -query \"hash:<sha256>\" -instance <store>.",
|
||||||
|
"Use -plugin with -instance to target a named provider config when a plugin exposes multiple instances.",
|
||||||
|
"For Internet Archive item pages (archive.org/details/...), shows a selectable file/format list; pick with @N to download.",
|
||||||
|
],
|
||||||
|
exec=self.run,
|
||||||
|
)
|
||||||
|
self.register()
|
||||||
|
|
||||||
|
def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int:
|
||||||
|
"""Main execution method."""
|
||||||
|
try:
|
||||||
|
debug_panel(
|
||||||
|
"download-file",
|
||||||
|
[
|
||||||
|
("args", list(args)),
|
||||||
|
("has_piped_input", bool(result)),
|
||||||
|
],
|
||||||
|
border_style="cyan",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return self._run_impl(result, args, config)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _path_from_download_result(result_obj: Any) -> Path:
|
||||||
|
"""Normalize downloader return values to a concrete filesystem path."""
|
||||||
|
resolved = coerce_to_path(result_obj)
|
||||||
|
if resolved is None:
|
||||||
|
raise DownloadError("Could not determine downloaded file path")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _selection_run_label(
|
||||||
|
run_args: Sequence[str],
|
||||||
|
*,
|
||||||
|
extra_url_prefixes: Sequence[str] = (),
|
||||||
|
) -> str:
|
||||||
|
try:
|
||||||
|
urls = extract_urls_from_selection_args(
|
||||||
|
run_args,
|
||||||
|
extra_url_prefixes=extra_url_prefixes,
|
||||||
|
)
|
||||||
|
if urls:
|
||||||
|
return str(urls[0])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
for arg in run_args:
|
||||||
|
text = str(arg or "").strip()
|
||||||
|
if text and not text.startswith("-"):
|
||||||
|
return text
|
||||||
|
return "item"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _batch_progress_state(config: Optional[Dict[str, Any]]) -> tuple[bool, int, int, str]:
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return False, 0, 0, ""
|
||||||
|
|
||||||
|
suppress_nested = bool(config.get("_download_file_suppress_nested_pipe_progress"))
|
||||||
|
if not suppress_nested:
|
||||||
|
return False, 0, 0, ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
total = max(0, int(config.get("_download_file_batch_total") or 0))
|
||||||
|
except Exception:
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
index = max(0, int(config.get("_download_file_batch_index") or 0))
|
||||||
|
except Exception:
|
||||||
|
index = 0
|
||||||
|
try:
|
||||||
|
label = str(config.get("_download_file_batch_label") or "").strip()
|
||||||
|
except Exception:
|
||||||
|
label = ""
|
||||||
|
|
||||||
|
return True, total, index, label
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _selection_url_prefixes(registry: Dict[str, Any]) -> List[str]:
|
||||||
|
loader = registry.get("list_selection_url_prefixes")
|
||||||
|
if not callable(loader):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
values = loader() or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
return [str(value).strip().lower() for value in values if str(value or "").strip()]
|
||||||
|
|
||||||
|
def _normalize_provider_key(self, value: Optional[Any]) -> Optional[str]:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
normalized = str(value).strip()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
if "." in normalized:
|
||||||
|
normalized = normalized.split(".", 1)[0]
|
||||||
|
return normalized.lower()
|
||||||
|
|
||||||
|
def _provider_key_from_item(self, item: Any) -> Optional[str]:
|
||||||
|
table_hint = get_field(item, "table")
|
||||||
|
key = self._normalize_provider_key(table_hint)
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
|
provider_hint = get_field(item, "plugin")
|
||||||
|
key = self._normalize_provider_key(provider_hint)
|
||||||
|
if key:
|
||||||
|
return key
|
||||||
|
return self._normalize_provider_key(get_field(item, "source"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _path_looks_local(value: Any) -> bool:
|
||||||
|
text = str(value or "").strip()
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
if text.startswith(("http://", "https://", "ftp://", "ftps://", "magnet:", "torrent:")):
|
||||||
|
return False
|
||||||
|
if len(text) >= 2 and text[1] == ":":
|
||||||
|
return True
|
||||||
|
if text.startswith(("\\", "/", ".", "~")):
|
||||||
|
return True
|
||||||
|
return Path(text).exists()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_display_title(result: Any, metadata: Optional[Dict[str, Any]]) -> str:
|
||||||
|
candidates = [
|
||||||
|
get_result_title(result, "title", "name", "filename"),
|
||||||
|
get_result_title(metadata or {}, "title", "name", "filename"),
|
||||||
|
]
|
||||||
|
for candidate in candidates:
|
||||||
|
if candidate is None:
|
||||||
|
continue
|
||||||
|
text = str(candidate).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sanitize_export_filename(name: str) -> str:
|
||||||
|
allowed_chars: List[str] = []
|
||||||
|
for ch in str(name or ""):
|
||||||
|
if ch.isalnum() or ch in {"-", "_", " ", "."}:
|
||||||
|
allowed_chars.append(ch)
|
||||||
|
else:
|
||||||
|
allowed_chars.append(" ")
|
||||||
|
sanitized = " ".join("".join(allowed_chars).split())
|
||||||
|
return sanitized or "export"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _unique_export_path(path: Path) -> Path:
|
||||||
|
if not path.exists():
|
||||||
|
return path
|
||||||
|
stem = path.stem
|
||||||
|
suffix = path.suffix
|
||||||
|
parent = path.parent
|
||||||
|
counter = 1
|
||||||
|
while True:
|
||||||
|
candidate = parent / f"{stem} ({counter}){suffix}"
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_provider_registry() -> Dict[str, Any]:
|
||||||
|
"""Lightweight accessor for plugin helpers without hard dependencies."""
|
||||||
|
try:
|
||||||
|
from PluginCore import registry as provider_registry # type: ignore
|
||||||
|
from PluginCore.base import SearchResult # type: ignore
|
||||||
|
|
||||||
|
return {
|
||||||
|
"get_plugin": getattr(provider_registry, "get_plugin", None),
|
||||||
|
"match_plugin_name_for_url": getattr(provider_registry, "match_plugin_name_for_url", None),
|
||||||
|
"list_selection_url_prefixes": getattr(provider_registry, "list_selection_url_prefixes", None),
|
||||||
|
"SearchResult": SearchResult,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
return {
|
||||||
|
"get_plugin": None,
|
||||||
|
"match_plugin_name_for_url": None,
|
||||||
|
"list_selection_url_prefixes": None,
|
||||||
|
"SearchResult": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _normalize_duplicate_preflight_policy(cls, value: Any) -> Optional[str]:
|
||||||
|
text = str(value or "").strip().lower()
|
||||||
|
if not text:
|
||||||
|
return "skip"
|
||||||
|
mapping = {
|
||||||
|
"i": "ignore",
|
||||||
|
"ignore": "ignore",
|
||||||
|
"s": "skip",
|
||||||
|
"skip": "skip",
|
||||||
|
"c": "cancel",
|
||||||
|
"cancel": "cancel",
|
||||||
|
}
|
||||||
|
return mapping.get(text)
|
||||||
|
|
||||||
|
def _maybe_render_download_details(self, *, config: Dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
stage_ctx = pipeline_context.get_stage_context()
|
||||||
|
except Exception:
|
||||||
|
stage_ctx = None
|
||||||
|
|
||||||
|
is_last_stage = (stage_ctx is None) or bool(getattr(stage_ctx, "is_last_stage", False))
|
||||||
|
if not is_last_stage:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
quiet_mode = bool(config.get("_quiet_background_output")) if isinstance(config, dict) else False
|
||||||
|
except Exception:
|
||||||
|
quiet_mode = False
|
||||||
|
if quiet_mode:
|
||||||
|
return
|
||||||
|
|
||||||
|
emitted_items: List[Any] = []
|
||||||
|
try:
|
||||||
|
emitted_items = list(getattr(stage_ctx, "emits", None) or []) if stage_ctx is not None else []
|
||||||
|
except Exception:
|
||||||
|
emitted_items = []
|
||||||
|
|
||||||
|
if not emitted_items:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
live_progress = pipeline_context.get_live_progress()
|
||||||
|
except Exception:
|
||||||
|
live_progress = None
|
||||||
|
|
||||||
|
if live_progress is not None:
|
||||||
|
try:
|
||||||
|
pipe_idx = getattr(stage_ctx, "pipe_index", None) if stage_ctx is not None else None
|
||||||
|
if isinstance(pipe_idx, int):
|
||||||
|
live_progress.finish_pipe(int(pipe_idx), force_complete=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
live_progress.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if hasattr(pipeline_context, "set_live_progress"):
|
||||||
|
pipeline_context.set_live_progress(None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
subject = emitted_items[0] if len(emitted_items) == 1 else list(emitted_items)
|
||||||
|
from .._shared import display_and_persist_items
|
||||||
|
display_and_persist_items(list(emitted_items), title="Result", subject=subject)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
if stage_ctx is not None:
|
||||||
|
stage_ctx.emits = []
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _maybe_show_provider_picker(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
raw_urls: Sequence[str],
|
||||||
|
piped_items: Sequence[Any],
|
||||||
|
parsed: Dict[str, Any],
|
||||||
|
config: Dict[str, Any],
|
||||||
|
registry: Dict[str, Any],
|
||||||
|
) -> Optional[int]:
|
||||||
|
"""Generic hook for providers to show a selection table (e.g. Internet Archive format picker)."""
|
||||||
|
total_inputs = len(raw_urls or []) + len(piped_items or [])
|
||||||
|
if total_inputs != 1:
|
||||||
|
return None
|
||||||
|
|
||||||
|
target_url = None
|
||||||
|
if raw_urls:
|
||||||
|
target_url = str(raw_urls[0])
|
||||||
|
elif piped_items:
|
||||||
|
target_url = str(get_field(piped_items[0], "path") or get_field(piped_items[0], "url") or "")
|
||||||
|
|
||||||
|
if not target_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
match_provider_name_for_url = registry.get("match_plugin_name_for_url")
|
||||||
|
get_provider = registry.get("get_plugin")
|
||||||
|
|
||||||
|
provider_name = None
|
||||||
|
if match_provider_name_for_url:
|
||||||
|
try:
|
||||||
|
provider_name = match_provider_name_for_url(target_url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if provider_name and get_provider:
|
||||||
|
provider = get_provider(provider_name, config)
|
||||||
|
if provider and hasattr(provider, "maybe_show_picker"):
|
||||||
|
try:
|
||||||
|
quiet_mode = bool(config.get("_quiet_background_output"))
|
||||||
|
res = provider.maybe_show_picker(
|
||||||
|
url=target_url,
|
||||||
|
item=piped_items[0] if piped_items else None,
|
||||||
|
parsed=parsed,
|
||||||
|
config=config,
|
||||||
|
quiet_mode=quiet_mode,
|
||||||
|
)
|
||||||
|
if res is not None:
|
||||||
|
return int(res)
|
||||||
|
except Exception as e:
|
||||||
|
debug_panel(
|
||||||
|
"download-file picker error",
|
||||||
|
[
|
||||||
|
("plugin", provider_name),
|
||||||
|
("url", target_url),
|
||||||
|
("error", e),
|
||||||
|
],
|
||||||
|
border_style="yellow",
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _run_impl(
|
||||||
|
self,
|
||||||
|
result: Any,
|
||||||
|
args: Sequence[str],
|
||||||
|
config: Dict[str, Any]
|
||||||
|
) -> int:
|
||||||
|
"""Main download implementation for direct HTTP files."""
|
||||||
|
progress = PipelineProgress(pipeline_context)
|
||||||
|
prev_progress = None
|
||||||
|
had_progress_key = False
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
if isinstance(config, dict):
|
||||||
|
had_progress_key = "_pipeline_progress" in config
|
||||||
|
prev_progress = config.get("_pipeline_progress")
|
||||||
|
config["_pipeline_progress"] = progress
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
parsed = parse_cmdlet_args(args, self)
|
||||||
|
registry = self._load_provider_registry()
|
||||||
|
selection_url_prefixes = self._selection_url_prefixes(registry)
|
||||||
|
explicit_input = parsed.get("url")
|
||||||
|
|
||||||
|
url_candidates = parsed.get("url") or [
|
||||||
|
a for a in parsed.get("args", [])
|
||||||
|
if isinstance(a, str) and (
|
||||||
|
a.startswith("http") or "://" in a or ":" in a
|
||||||
|
or "\U0001f9f2" in a
|
||||||
|
and not a.startswith("-")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
from SYS.metadata import normalize_urls as normalize_url_list # lazy: avoids Cryptodome at startup
|
||||||
|
raw_url = normalize_url_list(url_candidates)
|
||||||
|
local_source_inputs: List[str] = []
|
||||||
|
if not raw_url and isinstance(explicit_input, str) and self._path_looks_local(explicit_input):
|
||||||
|
local_source_inputs = [str(explicit_input)]
|
||||||
|
|
||||||
|
quiet_mode = bool(config.get("_quiet_background_output")) if isinstance(config, dict) else False
|
||||||
|
|
||||||
|
piped_items = []
|
||||||
|
if not raw_url:
|
||||||
|
piped_items = sh.normalize_result_items(
|
||||||
|
result,
|
||||||
|
include_falsey_single=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if piped_items and not raw_url:
|
||||||
|
selection_runs: List[List[str]] = []
|
||||||
|
residual_items: List[Any] = []
|
||||||
|
|
||||||
|
for item in piped_items:
|
||||||
|
handled = False
|
||||||
|
try:
|
||||||
|
normalized_args, _normalized_action, item_url = extract_selection_fields(
|
||||||
|
item,
|
||||||
|
extra_url_prefixes=selection_url_prefixes,
|
||||||
|
)
|
||||||
|
if normalized_args:
|
||||||
|
if selection_args_have_url(
|
||||||
|
normalized_args,
|
||||||
|
extra_url_prefixes=selection_url_prefixes,
|
||||||
|
):
|
||||||
|
selection_runs.append(list(normalized_args))
|
||||||
|
handled = True
|
||||||
|
elif item_url:
|
||||||
|
selection_runs.append([str(item_url)] + list(normalized_args))
|
||||||
|
handled = True
|
||||||
|
except Exception as e:
|
||||||
|
debug_panel(
|
||||||
|
"download-file selection args failed",
|
||||||
|
[("error", e)],
|
||||||
|
border_style="yellow",
|
||||||
|
)
|
||||||
|
handled = False
|
||||||
|
if not handled:
|
||||||
|
residual_items.append(item)
|
||||||
|
if selection_runs:
|
||||||
|
selection_urls: List[str] = []
|
||||||
|
|
||||||
|
for run_args in selection_runs:
|
||||||
|
for u in extract_urls_from_selection_args(
|
||||||
|
run_args,
|
||||||
|
extra_url_prefixes=selection_url_prefixes,
|
||||||
|
):
|
||||||
|
if u not in selection_urls:
|
||||||
|
selection_urls.append(u)
|
||||||
|
|
||||||
|
original_skip_preflight = None
|
||||||
|
original_timeout = None
|
||||||
|
original_skip_direct = None
|
||||||
|
original_batch_total = None
|
||||||
|
original_batch_index = None
|
||||||
|
original_batch_label = None
|
||||||
|
original_suppress_nested = None
|
||||||
|
try:
|
||||||
|
if isinstance(config, dict):
|
||||||
|
original_skip_preflight = config.get("_skip_url_preflight")
|
||||||
|
original_timeout = config.get("_pipeobject_timeout_seconds")
|
||||||
|
original_skip_direct = config.get("_skip_direct_on_streaming_failure")
|
||||||
|
original_batch_total = config.get("_download_file_batch_total")
|
||||||
|
original_batch_index = config.get("_download_file_batch_index")
|
||||||
|
original_batch_label = config.get("_download_file_batch_label")
|
||||||
|
original_suppress_nested = config.get("_download_file_suppress_nested_pipe_progress")
|
||||||
|
except Exception:
|
||||||
|
original_skip_preflight = None
|
||||||
|
original_timeout = None
|
||||||
|
original_batch_total = None
|
||||||
|
original_batch_index = None
|
||||||
|
original_batch_label = None
|
||||||
|
original_suppress_nested = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
if selection_urls:
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config["_skip_url_preflight"] = True
|
||||||
|
config["_skip_direct_on_streaming_failure"] = True
|
||||||
|
|
||||||
|
if isinstance(config, dict) and config.get("_pipeobject_timeout_seconds") is None:
|
||||||
|
config["_pipeobject_timeout_seconds"] = 600
|
||||||
|
|
||||||
|
successes = 0
|
||||||
|
failures = 0
|
||||||
|
last_code = 0
|
||||||
|
total_selection = len(selection_runs)
|
||||||
|
preview_items = list(selection_urls[:5]) or [
|
||||||
|
self._selection_run_label(
|
||||||
|
run_args,
|
||||||
|
extra_url_prefixes=selection_url_prefixes,
|
||||||
|
)
|
||||||
|
for run_args in selection_runs[:5]
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
progress.ensure_local_ui(
|
||||||
|
label="download-file",
|
||||||
|
total_items=total_selection,
|
||||||
|
items_preview=preview_items,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
progress.begin_pipe(
|
||||||
|
total_items=total_selection,
|
||||||
|
items_preview=preview_items,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for idx, run_args in enumerate(selection_runs, 1):
|
||||||
|
run_label = self._selection_run_label(
|
||||||
|
run_args,
|
||||||
|
extra_url_prefixes=selection_url_prefixes,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
progress.set_status(
|
||||||
|
f"downloading {idx}/{total_selection}: {run_label}"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config["_download_file_batch_total"] = total_selection
|
||||||
|
config["_download_file_batch_index"] = idx
|
||||||
|
config["_download_file_batch_label"] = run_label
|
||||||
|
config["_download_file_suppress_nested_pipe_progress"] = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
exit_code = self._run_impl(None, run_args, config)
|
||||||
|
if exit_code == 0:
|
||||||
|
successes += 1
|
||||||
|
else:
|
||||||
|
failures += 1
|
||||||
|
last_code = exit_code
|
||||||
|
|
||||||
|
piped_items = residual_items
|
||||||
|
if not piped_items:
|
||||||
|
if successes > 0:
|
||||||
|
return 0
|
||||||
|
return last_code or 1
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if isinstance(config, dict):
|
||||||
|
if original_skip_preflight is None:
|
||||||
|
config.pop("_skip_url_preflight", None)
|
||||||
|
else:
|
||||||
|
config["_skip_url_preflight"] = original_skip_preflight
|
||||||
|
if original_timeout is None:
|
||||||
|
config.pop("_pipeobject_timeout_seconds", None)
|
||||||
|
else:
|
||||||
|
config["_pipeobject_timeout_seconds"] = original_timeout
|
||||||
|
if original_skip_direct is None:
|
||||||
|
config.pop("_skip_direct_on_streaming_failure", None)
|
||||||
|
else:
|
||||||
|
config["_skip_direct_on_streaming_failure"] = original_skip_direct
|
||||||
|
if original_batch_total is None:
|
||||||
|
config.pop("_download_file_batch_total", None)
|
||||||
|
else:
|
||||||
|
config["_download_file_batch_total"] = original_batch_total
|
||||||
|
if original_batch_index is None:
|
||||||
|
config.pop("_download_file_batch_index", None)
|
||||||
|
else:
|
||||||
|
config["_download_file_batch_index"] = original_batch_index
|
||||||
|
if original_batch_label is None:
|
||||||
|
config.pop("_download_file_batch_label", None)
|
||||||
|
else:
|
||||||
|
config["_download_file_batch_label"] = original_batch_label
|
||||||
|
if original_suppress_nested is None:
|
||||||
|
config.pop("_download_file_suppress_nested_pipe_progress", None)
|
||||||
|
else:
|
||||||
|
config["_download_file_suppress_nested_pipe_progress"] = original_suppress_nested
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
had_piped_input = False
|
||||||
|
try:
|
||||||
|
if isinstance(result, list):
|
||||||
|
had_piped_input = bool(result)
|
||||||
|
else:
|
||||||
|
had_piped_input = bool(result)
|
||||||
|
except Exception:
|
||||||
|
had_piped_input = False
|
||||||
|
|
||||||
|
if (had_piped_input and raw_url and len(raw_url) == 1
|
||||||
|
and (not parsed.get("path"))):
|
||||||
|
candidate = str(raw_url[0] or "").strip()
|
||||||
|
low = candidate.lower()
|
||||||
|
looks_like_url = low.startswith(
|
||||||
|
("http://", "https://", "ftp://", "magnet:", "torrent:")
|
||||||
|
+ tuple(selection_url_prefixes)
|
||||||
|
)
|
||||||
|
looks_like_provider = (
|
||||||
|
":" in candidate and not candidate.startswith(
|
||||||
|
("http:", "https:", "ftp:", "ftps:", "file:")
|
||||||
|
+ tuple(selection_url_prefixes)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
looks_like_windows_path = (
|
||||||
|
(len(candidate) >= 2 and candidate[1] == ":")
|
||||||
|
or candidate.startswith("\\\\") or candidate.startswith("\\")
|
||||||
|
or candidate.endswith(("\\", "/"))
|
||||||
|
)
|
||||||
|
if (not looks_like_url) and (
|
||||||
|
not looks_like_provider) and looks_like_windows_path:
|
||||||
|
parsed["path"] = candidate
|
||||||
|
raw_url = []
|
||||||
|
piped_items = sh.normalize_result_items(result, include_falsey_single=True)
|
||||||
|
|
||||||
|
if not raw_url and not piped_items and not local_source_inputs:
|
||||||
|
log("No url or piped items to download", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
picker_result = self._maybe_show_provider_picker(
|
||||||
|
raw_urls=raw_url,
|
||||||
|
piped_items=piped_items,
|
||||||
|
parsed=parsed,
|
||||||
|
config=config,
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
|
if picker_result is not None:
|
||||||
|
return int(picker_result)
|
||||||
|
|
||||||
|
picker_result = self._maybe_show_provider_picker(
|
||||||
|
raw_urls=raw_url,
|
||||||
|
piped_items=piped_items,
|
||||||
|
parsed=parsed,
|
||||||
|
config=config,
|
||||||
|
registry=registry,
|
||||||
|
)
|
||||||
|
if picker_result is not None:
|
||||||
|
return int(picker_result)
|
||||||
|
|
||||||
|
final_output_dir = resolve_target_dir(parsed, config)
|
||||||
|
if not final_output_dir:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
debug_panel(
|
||||||
|
"download-file plan",
|
||||||
|
[
|
||||||
|
("output_dir", final_output_dir),
|
||||||
|
("remaining_urls", len(raw_url)),
|
||||||
|
("piped_items", len(piped_items) if isinstance(piped_items, list) else int(bool(piped_items))),
|
||||||
|
],
|
||||||
|
border_style="cyan",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
total_items = max(1, len(raw_url or []) + len(piped_items or []))
|
||||||
|
preview = build_pipeline_preview(raw_url, piped_items)
|
||||||
|
|
||||||
|
progress.ensure_local_ui(
|
||||||
|
label="download-file",
|
||||||
|
total_items=total_items,
|
||||||
|
items_preview=preview
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_url, preflight_exit, skipped_dupe_count = self._preflight_explicit_url_duplicates(
|
||||||
|
raw_urls=raw_url,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
if preflight_exit is not None:
|
||||||
|
return int(preflight_exit)
|
||||||
|
|
||||||
|
downloaded_count = 0
|
||||||
|
|
||||||
|
if local_source_inputs:
|
||||||
|
downloaded_count += self._process_explicit_local_sources(
|
||||||
|
local_sources=local_source_inputs,
|
||||||
|
final_output_dir=final_output_dir,
|
||||||
|
parsed=parsed,
|
||||||
|
progress=progress,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
storage_downloaded, piped_items, storage_exit = self._process_storage_items(
|
||||||
|
piped_items=piped_items,
|
||||||
|
parsed=parsed,
|
||||||
|
config=config,
|
||||||
|
final_output_dir=final_output_dir,
|
||||||
|
)
|
||||||
|
downloaded_count += int(storage_downloaded)
|
||||||
|
if storage_exit is not None:
|
||||||
|
return int(storage_exit)
|
||||||
|
|
||||||
|
if skipped_dupe_count and not raw_url and not piped_items:
|
||||||
|
return 0 if downloaded_count > 0 else 0
|
||||||
|
|
||||||
|
urls_downloaded, early_exit = self._process_explicit_urls(
|
||||||
|
raw_urls=raw_url,
|
||||||
|
final_output_dir=final_output_dir,
|
||||||
|
config=config,
|
||||||
|
quiet_mode=quiet_mode,
|
||||||
|
registry=registry,
|
||||||
|
progress=progress,
|
||||||
|
parsed=parsed,
|
||||||
|
args=args,
|
||||||
|
context_items=(result if isinstance(result, list) else ([result] if result else [])),
|
||||||
|
)
|
||||||
|
downloaded_count += int(urls_downloaded)
|
||||||
|
if early_exit is not None:
|
||||||
|
return int(early_exit)
|
||||||
|
|
||||||
|
provider_downloaded, magnet_submissions = self._process_provider_items(
|
||||||
|
piped_items=piped_items,
|
||||||
|
final_output_dir=final_output_dir,
|
||||||
|
config=config,
|
||||||
|
quiet_mode=quiet_mode,
|
||||||
|
registry=registry,
|
||||||
|
progress=progress,
|
||||||
|
)
|
||||||
|
downloaded_count += provider_downloaded
|
||||||
|
|
||||||
|
if downloaded_count > 0 or magnet_submissions > 0:
|
||||||
|
self._maybe_render_download_details(config=config)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log(f"Error in download-file: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
if isinstance(config, dict):
|
||||||
|
if had_progress_key:
|
||||||
|
config["_pipeline_progress"] = prev_progress
|
||||||
|
else:
|
||||||
|
config.pop("_pipeline_progress", None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
progress.close_local_ui(force_complete=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Late-binding: import sub-module functions and attach them as methods.
|
||||||
|
from .download_fetch import ( # noqa: E402
|
||||||
|
_emit_plugin_items,
|
||||||
|
_consume_plugin_download_result,
|
||||||
|
_process_explicit_urls,
|
||||||
|
_expand_provider_items,
|
||||||
|
_process_provider_items,
|
||||||
|
_download_provider_items,
|
||||||
|
_emit_local_file,
|
||||||
|
_resolve_provider_preflight_items,
|
||||||
|
_build_provider_playlist_item_selector,
|
||||||
|
_format_timecode,
|
||||||
|
_rebase_subtitle_timestamp_text,
|
||||||
|
_format_clip_range,
|
||||||
|
_apply_clip_decorations,
|
||||||
|
_parse_clip_spec_to_ranges,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .download_storage import ( # noqa: E402
|
||||||
|
_iter_storage_export_refs,
|
||||||
|
_export_store_file,
|
||||||
|
_process_storage_items,
|
||||||
|
_process_explicit_local_sources,
|
||||||
|
_extract_hash_from_search_hit,
|
||||||
|
_iter_duplicate_tag_values,
|
||||||
|
_extract_duplicate_namespace_tags,
|
||||||
|
_extract_duplicate_title_tag,
|
||||||
|
_extract_duplicate_title,
|
||||||
|
_has_duplicate_title,
|
||||||
|
_build_duplicate_display_row,
|
||||||
|
_fetch_duplicate_metadata_for_hash,
|
||||||
|
_enrich_duplicate_metadata,
|
||||||
|
_fetch_duplicate_metadata_for_hashes,
|
||||||
|
_collect_existing_url_match_refs_for_url,
|
||||||
|
_find_existing_url_matches_for_url,
|
||||||
|
_find_existing_hash_for_url,
|
||||||
|
_find_existing_hashes_for_url,
|
||||||
|
_init_storage,
|
||||||
|
_supports_storage_duplicate_lookup,
|
||||||
|
_filter_supported_urls,
|
||||||
|
_canonicalize_url_for_storage,
|
||||||
|
_preflight_url_duplicate,
|
||||||
|
_preflight_explicit_url_duplicates,
|
||||||
|
_download_supported_urls,
|
||||||
|
_maybe_show_playlist_table,
|
||||||
|
_maybe_show_format_table_for_single_url,
|
||||||
|
_run_streaming_urls,
|
||||||
|
)
|
||||||
|
|
||||||
|
Download_File._emit_plugin_items = _emit_plugin_items
|
||||||
|
Download_File._consume_plugin_download_result = _consume_plugin_download_result
|
||||||
|
Download_File._process_explicit_urls = _process_explicit_urls
|
||||||
|
Download_File._expand_provider_items = _expand_provider_items
|
||||||
|
Download_File._process_provider_items = _process_provider_items
|
||||||
|
Download_File._download_provider_items = _download_provider_items
|
||||||
|
Download_File._emit_local_file = _emit_local_file
|
||||||
|
Download_File._resolve_provider_preflight_items = staticmethod(_resolve_provider_preflight_items)
|
||||||
|
Download_File._build_provider_playlist_item_selector = staticmethod(_build_provider_playlist_item_selector)
|
||||||
|
Download_File._format_timecode = staticmethod(_format_timecode)
|
||||||
|
Download_File._rebase_subtitle_timestamp_text = staticmethod(_rebase_subtitle_timestamp_text)
|
||||||
|
Download_File._format_clip_range = classmethod(_format_clip_range)
|
||||||
|
Download_File._apply_clip_decorations = classmethod(_apply_clip_decorations)
|
||||||
|
Download_File._parse_clip_spec_to_ranges = staticmethod(_parse_clip_spec_to_ranges)
|
||||||
|
|
||||||
|
Download_File._iter_storage_export_refs = staticmethod(_iter_storage_export_refs)
|
||||||
|
Download_File._export_store_file = _export_store_file
|
||||||
|
Download_File._process_storage_items = _process_storage_items
|
||||||
|
Download_File._process_explicit_local_sources = _process_explicit_local_sources
|
||||||
|
Download_File._extract_hash_from_search_hit = classmethod(_extract_hash_from_search_hit)
|
||||||
|
Download_File._iter_duplicate_tag_values = staticmethod(_iter_duplicate_tag_values)
|
||||||
|
Download_File._extract_duplicate_namespace_tags = staticmethod(_extract_duplicate_namespace_tags)
|
||||||
|
Download_File._extract_duplicate_title_tag = staticmethod(_extract_duplicate_title_tag)
|
||||||
|
Download_File._extract_duplicate_title = classmethod(_extract_duplicate_title)
|
||||||
|
Download_File._has_duplicate_title = classmethod(_has_duplicate_title)
|
||||||
|
Download_File._build_duplicate_display_row = classmethod(_build_duplicate_display_row)
|
||||||
|
Download_File._fetch_duplicate_metadata_for_hash = classmethod(_fetch_duplicate_metadata_for_hash)
|
||||||
|
Download_File._enrich_duplicate_metadata = classmethod(_enrich_duplicate_metadata)
|
||||||
|
Download_File._fetch_duplicate_metadata_for_hashes = classmethod(_fetch_duplicate_metadata_for_hashes)
|
||||||
|
Download_File._collect_existing_url_match_refs_for_url = classmethod(_collect_existing_url_match_refs_for_url)
|
||||||
|
Download_File._find_existing_url_matches_for_url = classmethod(_find_existing_url_matches_for_url)
|
||||||
|
Download_File._find_existing_hash_for_url = classmethod(_find_existing_hash_for_url)
|
||||||
|
Download_File._find_existing_hashes_for_url = classmethod(_find_existing_hashes_for_url)
|
||||||
|
Download_File._init_storage = staticmethod(_init_storage)
|
||||||
|
Download_File._supports_storage_duplicate_lookup = staticmethod(_supports_storage_duplicate_lookup)
|
||||||
|
Download_File._filter_supported_urls = staticmethod(_filter_supported_urls)
|
||||||
|
Download_File._canonicalize_url_for_storage = staticmethod(_canonicalize_url_for_storage)
|
||||||
|
Download_File._preflight_url_duplicate = staticmethod(_preflight_url_duplicate)
|
||||||
|
Download_File._preflight_explicit_url_duplicates = _preflight_explicit_url_duplicates
|
||||||
|
Download_File._download_supported_urls = _download_supported_urls
|
||||||
|
Download_File._maybe_show_playlist_table = _maybe_show_playlist_table
|
||||||
|
Download_File._maybe_show_format_table_for_single_url = _maybe_show_format_table_for_single_url
|
||||||
|
Download_File._run_streaming_urls = _run_streaming_urls
|
||||||
|
|
||||||
|
CMDLET = Download_File()
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -43,29 +43,17 @@ from SYS import pipeline as pipeline_context
|
|||||||
# Playwright & Screenshot Dependencies
|
# Playwright & Screenshot Dependencies
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
from plugins.playwright import PlaywrightTimeoutError, PlaywrightTool
|
from plugins.playwright import PlaywrightTimeoutError, PlaywrightTool, USER_AGENT
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from SYS.config import resolve_output_dir
|
from SYS.config import resolve_output_dir
|
||||||
except ImportError:
|
except ImportError:
|
||||||
try:
|
resolve_output_dir = None
|
||||||
_parent_dir = str(Path(__file__).parent.parent)
|
|
||||||
if _parent_dir not in sys.path:
|
|
||||||
sys.path.insert(0, _parent_dir)
|
|
||||||
from SYS.config import resolve_output_dir
|
|
||||||
except ImportError:
|
|
||||||
resolve_output_dir = None
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Screenshot Constants & Configuration
|
# Screenshot Constants & Configuration
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
USER_AGENT = (
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/120.0.0.0 Safari/537.36"
|
|
||||||
)
|
|
||||||
|
|
||||||
DEFAULT_VIEWPORT: dict[str,
|
DEFAULT_VIEWPORT: dict[str,
|
||||||
int] = {
|
int] = {
|
||||||
"width": 1920,
|
"width": 1920,
|
||||||
|
|||||||
+456
-1201
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,994 @@
|
|||||||
|
"""Search engine result parsing — Bing, DuckDuckGo, Yahoo, and site crawling.
|
||||||
|
|
||||||
|
Extracted from search.py to keep the cmdlet class focused on orchestration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
import html as _html
|
||||||
|
from urllib.parse import urlparse, parse_qs, unquote, urljoin
|
||||||
|
|
||||||
|
from SYS.logger import debug
|
||||||
|
from SYS.payload_builders import normalize_file_extension
|
||||||
|
|
||||||
|
_WHITESPACE_RE = re.compile(r"\s+")
|
||||||
|
_SITE_TOKEN_RE = re.compile(r"(?:^|\s)site:([^\s,]+)", flags=re.IGNORECASE)
|
||||||
|
_FILETYPE_TOKEN_RE = re.compile(
|
||||||
|
r"(?:^|\s)(?:ext|filetype|type):\.?([a-z0-9]{1,12})\b",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_SITE_REMOVE_RE = re.compile(r"(?:^|\s)site:[^\s,]+", flags=re.IGNORECASE)
|
||||||
|
_FILETYPE_REMOVE_RE = re.compile(
|
||||||
|
r"(?:^|\s)(?:ext|filetype|type):\.?[a-z0-9]{1,12}\b",
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_SCHEME_PREFIX_RE = re.compile(r"^[a-z]+:")
|
||||||
|
_YAHOO_RU_RE = re.compile(r"/RU=([^/]+)/RK=", flags=re.IGNORECASE)
|
||||||
|
_HTML_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
_DDG_RESULT_ANCHOR_RE = re.compile(
|
||||||
|
r'<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_GENERIC_ANCHOR_RE = re.compile(
|
||||||
|
r'<a[^>]+href=["\']([^"\']+)["\'][^>]*>(.*?)</a>',
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
_BING_RESULT_ANCHOR_RE = re.compile(
|
||||||
|
r'<h2[^>]*>\s*<a[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
|
||||||
|
flags=re.IGNORECASE | re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_extension(ext_value: Any) -> str:
|
||||||
|
"""Sanitize extension strings to alphanumerics and cap at 5 chars."""
|
||||||
|
return normalize_file_extension(ext_value)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_host(value: Any) -> str:
|
||||||
|
"""Normalize host names for matching/filtering."""
|
||||||
|
host = str(value or "").strip().lower()
|
||||||
|
if host.startswith("www."):
|
||||||
|
host = host[4:]
|
||||||
|
if ":" in host:
|
||||||
|
host = host.split(":", 1)[0]
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_space(text: Any) -> str:
|
||||||
|
return _WHITESPACE_RE.sub(" ", str(text or "")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _url_matches_site(cls, url: str, site_host: str) -> bool:
|
||||||
|
"""Return True when URL host is the requested site/subdomain."""
|
||||||
|
try:
|
||||||
|
parsed = urlparse(str(url or ""))
|
||||||
|
host = _normalize_host(getattr(parsed, "hostname", "") or "")
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
target = _normalize_host(site_host)
|
||||||
|
if not host or not target:
|
||||||
|
return False
|
||||||
|
return host == target or host.endswith(f".{target}")
|
||||||
|
|
||||||
|
|
||||||
|
def _itertext_join(node: Any) -> str:
|
||||||
|
try:
|
||||||
|
return " ".join([str(text).strip() for text in node.itertext() if str(text).strip()])
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _html_fragment_to_text(fragment: Any) -> str:
|
||||||
|
text = _HTML_TAG_RE.sub(" ", str(fragment or ""))
|
||||||
|
return _html.unescape(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_web_result(
|
||||||
|
cls,
|
||||||
|
items: List[Dict[str, str]],
|
||||||
|
seen_urls: set[str],
|
||||||
|
*,
|
||||||
|
site_host: str,
|
||||||
|
url_text: str,
|
||||||
|
title_text: str,
|
||||||
|
snippet_text: str,
|
||||||
|
) -> None:
|
||||||
|
url_clean = str(url_text or "").strip()
|
||||||
|
if not url_clean or not url_clean.startswith(("http://", "https://")):
|
||||||
|
return
|
||||||
|
if not _url_matches_site(url_clean, site_host):
|
||||||
|
return
|
||||||
|
if url_clean in seen_urls:
|
||||||
|
return
|
||||||
|
|
||||||
|
seen_urls.add(url_clean)
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"url": url_clean,
|
||||||
|
"title": _normalize_space(title_text) or url_clean,
|
||||||
|
"snippet": _normalize_space(snippet_text),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_web_results_with_fallback(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
html_text: str,
|
||||||
|
limit: int,
|
||||||
|
lxml_parser: Any,
|
||||||
|
regex_parser: Any,
|
||||||
|
fallback_when_empty: bool = False,
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""Run an lxml-based parser with an optional regex fallback."""
|
||||||
|
items: List[Dict[str, str]] = []
|
||||||
|
seen_urls: set[str] = set()
|
||||||
|
should_run_regex = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from lxml import html as lxml_html
|
||||||
|
|
||||||
|
doc = lxml_html.fromstring(html_text or "")
|
||||||
|
lxml_parser(doc, items, seen_urls)
|
||||||
|
should_run_regex = fallback_when_empty and not items
|
||||||
|
except Exception:
|
||||||
|
should_run_regex = True
|
||||||
|
|
||||||
|
if should_run_regex:
|
||||||
|
regex_parser(html_text or "", items, seen_urls)
|
||||||
|
|
||||||
|
return items[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_duckduckgo_target_url(href: Any) -> str:
|
||||||
|
"""Extract direct target URL from DuckDuckGo result links."""
|
||||||
|
raw_href = str(href or "").strip()
|
||||||
|
if not raw_href:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if raw_href.startswith("//"):
|
||||||
|
raw_href = f"https:{raw_href}"
|
||||||
|
|
||||||
|
if raw_href.startswith("/"):
|
||||||
|
raw_href = f"https://duckduckgo.com{raw_href}"
|
||||||
|
|
||||||
|
parsed = None
|
||||||
|
try:
|
||||||
|
parsed = urlparse(raw_href)
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
host = str(getattr(parsed, "hostname", "") or "").strip().lower()
|
||||||
|
except Exception:
|
||||||
|
host = ""
|
||||||
|
|
||||||
|
if host.endswith("duckduckgo.com"):
|
||||||
|
try:
|
||||||
|
query = parse_qs(str(getattr(parsed, "query", "") or ""))
|
||||||
|
candidate = (query.get("uddg") or [""])[0]
|
||||||
|
if candidate:
|
||||||
|
return str(unquote(candidate)).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return raw_href
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_yahoo_target_url(href: Any) -> str:
|
||||||
|
"""Extract direct target URL from Yahoo redirect links."""
|
||||||
|
raw_href = str(href or "").strip()
|
||||||
|
if not raw_href:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
ru_match = _YAHOO_RU_RE.search(raw_href)
|
||||||
|
if ru_match:
|
||||||
|
try:
|
||||||
|
return str(unquote(ru_match.group(1))).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(raw_href)
|
||||||
|
query = parse_qs(str(getattr(parsed, "query", "") or ""))
|
||||||
|
candidate = (query.get("RU") or query.get("ru") or [""])[0]
|
||||||
|
if candidate:
|
||||||
|
return str(unquote(candidate)).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return raw_href
|
||||||
|
|
||||||
|
|
||||||
|
def parse_duckduckgo_results(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
html_text: str,
|
||||||
|
site_host: str,
|
||||||
|
limit: int,
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""Parse DuckDuckGo HTML results into normalized rows."""
|
||||||
|
def _parse_lxml(doc: Any, items: List[Dict[str, str]], seen_urls: set[str]) -> None:
|
||||||
|
result_nodes = doc.xpath("//div[contains(@class, 'result')]")
|
||||||
|
|
||||||
|
for node in result_nodes:
|
||||||
|
links = node.xpath(".//a[contains(@class, 'result__a')]")
|
||||||
|
if not links:
|
||||||
|
continue
|
||||||
|
|
||||||
|
link = links[0]
|
||||||
|
href = _extract_duckduckgo_target_url(link.get("href"))
|
||||||
|
title = _itertext_join(link)
|
||||||
|
|
||||||
|
snippet_nodes = node.xpath(".//*[contains(@class, 'result__snippet')]")
|
||||||
|
snippet = ""
|
||||||
|
if snippet_nodes:
|
||||||
|
snippet = _itertext_join(snippet_nodes[0])
|
||||||
|
|
||||||
|
_append_web_result(
|
||||||
|
cls,
|
||||||
|
items,
|
||||||
|
seen_urls,
|
||||||
|
site_host=site_host,
|
||||||
|
url_text=href,
|
||||||
|
title_text=title,
|
||||||
|
snippet_text=snippet,
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _parse_regex(raw_html: str, items: List[Dict[str, str]], seen_urls: set[str]) -> None:
|
||||||
|
for match in _DDG_RESULT_ANCHOR_RE.finditer(raw_html):
|
||||||
|
href = _extract_duckduckgo_target_url(match.group(1))
|
||||||
|
title_html = match.group(2)
|
||||||
|
title = _html_fragment_to_text(title_html)
|
||||||
|
_append_web_result(
|
||||||
|
cls,
|
||||||
|
items,
|
||||||
|
seen_urls,
|
||||||
|
site_host=site_host,
|
||||||
|
url_text=href,
|
||||||
|
title_text=title,
|
||||||
|
snippet_text="",
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return _parse_web_results_with_fallback(
|
||||||
|
cls,
|
||||||
|
html_text=html_text,
|
||||||
|
limit=limit,
|
||||||
|
lxml_parser=_parse_lxml,
|
||||||
|
regex_parser=_parse_regex,
|
||||||
|
fallback_when_empty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_yahoo_results(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
html_text: str,
|
||||||
|
site_host: str,
|
||||||
|
limit: int,
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""Parse Yahoo HTML search results into normalized rows."""
|
||||||
|
def _parse_lxml(doc: Any, items: List[Dict[str, str]], seen_urls: set[str]) -> None:
|
||||||
|
for node in doc.xpath("//a[@href]"):
|
||||||
|
href = _extract_yahoo_target_url(node.get("href"))
|
||||||
|
title = _itertext_join(node)
|
||||||
|
_append_web_result(
|
||||||
|
cls,
|
||||||
|
items,
|
||||||
|
seen_urls,
|
||||||
|
site_host=site_host,
|
||||||
|
url_text=href,
|
||||||
|
title_text=title,
|
||||||
|
snippet_text="",
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _parse_regex(raw_html: str, items: List[Dict[str, str]], seen_urls: set[str]) -> None:
|
||||||
|
for match in _GENERIC_ANCHOR_RE.finditer(raw_html):
|
||||||
|
href = _extract_yahoo_target_url(match.group(1))
|
||||||
|
title_html = match.group(2)
|
||||||
|
title = _html_fragment_to_text(title_html)
|
||||||
|
_append_web_result(
|
||||||
|
cls,
|
||||||
|
items,
|
||||||
|
seen_urls,
|
||||||
|
site_host=site_host,
|
||||||
|
url_text=href,
|
||||||
|
title_text=title,
|
||||||
|
snippet_text="",
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return _parse_web_results_with_fallback(
|
||||||
|
cls,
|
||||||
|
html_text=html_text,
|
||||||
|
limit=limit,
|
||||||
|
lxml_parser=_parse_lxml,
|
||||||
|
regex_parser=_parse_regex,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def query_yahoo(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
search_query: str,
|
||||||
|
site_host: str,
|
||||||
|
limit: int,
|
||||||
|
session: Any,
|
||||||
|
deadline: Optional[float] = None,
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""Fetch results from Yahoo search (robust fallback in bot-protected envs)."""
|
||||||
|
all_rows: List[Dict[str, str]] = []
|
||||||
|
seen_urls: set[str] = set()
|
||||||
|
|
||||||
|
max_pages = max(1, min((max(1, int(limit or 1)) + 9) // 10, 3))
|
||||||
|
for page_idx in range(max_pages):
|
||||||
|
if deadline is not None and time.monotonic() >= deadline:
|
||||||
|
break
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"p": search_query,
|
||||||
|
"n": "10",
|
||||||
|
"b": str((page_idx * 10) + 1),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
read_timeout = 10.0
|
||||||
|
if deadline is not None:
|
||||||
|
remaining = max(0.0, float(deadline - time.monotonic()))
|
||||||
|
if remaining <= 0.0:
|
||||||
|
break
|
||||||
|
read_timeout = max(3.0, min(10.0, remaining))
|
||||||
|
|
||||||
|
response = session.get(
|
||||||
|
"https://search.yahoo.com/search",
|
||||||
|
params=params,
|
||||||
|
timeout=(3, read_timeout),
|
||||||
|
headers={
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
page_rows = parse_yahoo_results(
|
||||||
|
cls,
|
||||||
|
html_text=response.text,
|
||||||
|
site_host=site_host,
|
||||||
|
limit=max(1, limit - len(all_rows)),
|
||||||
|
)
|
||||||
|
new_rows = 0
|
||||||
|
for row in page_rows:
|
||||||
|
url_value = str(row.get("url") or "").strip()
|
||||||
|
if not url_value or url_value in seen_urls:
|
||||||
|
continue
|
||||||
|
seen_urls.add(url_value)
|
||||||
|
all_rows.append(row)
|
||||||
|
new_rows += 1
|
||||||
|
if len(all_rows) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(all_rows) >= limit or new_rows == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
return all_rows[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bing_results(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
html_text: str,
|
||||||
|
site_host: str,
|
||||||
|
limit: int,
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""Parse Bing HTML search results into normalized rows."""
|
||||||
|
def _parse_lxml(doc: Any, items: List[Dict[str, str]], seen_urls: set[str]) -> None:
|
||||||
|
result_nodes = doc.xpath("//li[contains(@class, 'b_algo')]")
|
||||||
|
|
||||||
|
for node in result_nodes:
|
||||||
|
links = node.xpath(".//h2/a")
|
||||||
|
if not links:
|
||||||
|
continue
|
||||||
|
link = links[0]
|
||||||
|
href = str(link.get("href") or "").strip()
|
||||||
|
title = _itertext_join(link)
|
||||||
|
|
||||||
|
snippet = ""
|
||||||
|
for sel in (
|
||||||
|
".//*[contains(@class,'b_caption')]//p",
|
||||||
|
".//*[contains(@class,'b_snippet')]",
|
||||||
|
".//p",
|
||||||
|
):
|
||||||
|
snip_nodes = node.xpath(sel)
|
||||||
|
if snip_nodes:
|
||||||
|
snippet = _itertext_join(snip_nodes[0])
|
||||||
|
break
|
||||||
|
|
||||||
|
_append_web_result(
|
||||||
|
cls,
|
||||||
|
items,
|
||||||
|
seen_urls,
|
||||||
|
site_host=site_host,
|
||||||
|
url_text=href,
|
||||||
|
title_text=title,
|
||||||
|
snippet_text=snippet,
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _parse_regex(raw_html: str, items: List[Dict[str, str]], seen_urls: set[str]) -> None:
|
||||||
|
for match in _BING_RESULT_ANCHOR_RE.finditer(raw_html):
|
||||||
|
href = match.group(1)
|
||||||
|
title = _html_fragment_to_text(match.group(2))
|
||||||
|
_append_web_result(
|
||||||
|
cls,
|
||||||
|
items,
|
||||||
|
seen_urls,
|
||||||
|
site_host=site_host,
|
||||||
|
url_text=href,
|
||||||
|
title_text=title,
|
||||||
|
snippet_text="",
|
||||||
|
)
|
||||||
|
if len(items) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
return _parse_web_results_with_fallback(
|
||||||
|
cls,
|
||||||
|
html_text=html_text,
|
||||||
|
limit=limit,
|
||||||
|
lxml_parser=_parse_lxml,
|
||||||
|
regex_parser=_parse_regex,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def query_bing(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
search_query: str,
|
||||||
|
site_host: str,
|
||||||
|
limit: int,
|
||||||
|
session: Any,
|
||||||
|
deadline: Optional[float] = None,
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""Fetch results from Bing (supports filetype: and site: natively)."""
|
||||||
|
all_rows: List[Dict[str, str]] = []
|
||||||
|
seen_urls: set[str] = set()
|
||||||
|
|
||||||
|
page_start = 1
|
||||||
|
pages_checked = 0
|
||||||
|
max_pages = max(1, min((max(1, int(limit or 1)) + 49) // 50, 3))
|
||||||
|
while len(all_rows) < limit and pages_checked < max_pages:
|
||||||
|
if deadline is not None and time.monotonic() >= deadline:
|
||||||
|
break
|
||||||
|
|
||||||
|
params = {"q": search_query, "first": str(page_start), "count": "50"}
|
||||||
|
try:
|
||||||
|
read_timeout = 10.0
|
||||||
|
if deadline is not None:
|
||||||
|
remaining = max(0.0, float(deadline - time.monotonic()))
|
||||||
|
if remaining <= 0.0:
|
||||||
|
break
|
||||||
|
read_timeout = max(3.0, min(10.0, remaining))
|
||||||
|
|
||||||
|
response = session.get(
|
||||||
|
"https://www.bing.com/search",
|
||||||
|
params=params,
|
||||||
|
timeout=(3, read_timeout),
|
||||||
|
headers={
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
page_rows = parse_bing_results(
|
||||||
|
cls,
|
||||||
|
html_text=response.text,
|
||||||
|
site_host=site_host,
|
||||||
|
limit=max(1, limit - len(all_rows)),
|
||||||
|
)
|
||||||
|
new_rows = 0
|
||||||
|
for row in page_rows:
|
||||||
|
url_value = str(row.get("url") or "").strip()
|
||||||
|
if not url_value or url_value in seen_urls:
|
||||||
|
continue
|
||||||
|
seen_urls.add(url_value)
|
||||||
|
all_rows.append(row)
|
||||||
|
new_rows += 1
|
||||||
|
if len(all_rows) >= limit:
|
||||||
|
break
|
||||||
|
|
||||||
|
if new_rows == 0 or len(all_rows) >= limit:
|
||||||
|
break
|
||||||
|
page_start += 50
|
||||||
|
pages_checked += 1
|
||||||
|
|
||||||
|
return all_rows
|
||||||
|
|
||||||
|
|
||||||
|
def query_web_search(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
search_query: str,
|
||||||
|
site_host: str,
|
||||||
|
limit: int,
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""Execute web search and return parsed result rows.
|
||||||
|
|
||||||
|
Uses Yahoo first (works in environments where Bing/DDG HTML endpoints
|
||||||
|
are challenge-gated), then Bing, then DuckDuckGo.
|
||||||
|
"""
|
||||||
|
from API.requests_client import get_requests_session
|
||||||
|
|
||||||
|
session = get_requests_session()
|
||||||
|
normalized_limit = max(1, min(int(limit or 1), 100))
|
||||||
|
engine_deadline = time.monotonic() + 12.0
|
||||||
|
|
||||||
|
all_rows = query_yahoo(
|
||||||
|
cls,
|
||||||
|
search_query=search_query,
|
||||||
|
site_host=site_host,
|
||||||
|
limit=normalized_limit,
|
||||||
|
session=session,
|
||||||
|
deadline=engine_deadline,
|
||||||
|
)
|
||||||
|
if all_rows:
|
||||||
|
return all_rows[:normalized_limit]
|
||||||
|
|
||||||
|
all_rows = query_bing(
|
||||||
|
cls,
|
||||||
|
search_query=search_query,
|
||||||
|
site_host=site_host,
|
||||||
|
limit=normalized_limit,
|
||||||
|
session=session,
|
||||||
|
deadline=engine_deadline,
|
||||||
|
)
|
||||||
|
if all_rows:
|
||||||
|
return all_rows[:normalized_limit]
|
||||||
|
|
||||||
|
all_rows_ddg: List[Dict[str, str]] = []
|
||||||
|
seen_urls: set[str] = set()
|
||||||
|
endpoints = [
|
||||||
|
"https://html.duckduckgo.com/html/",
|
||||||
|
"https://duckduckgo.com/html/",
|
||||||
|
]
|
||||||
|
for endpoint in endpoints:
|
||||||
|
if time.monotonic() >= engine_deadline:
|
||||||
|
break
|
||||||
|
max_offsets = min(3, max(1, (normalized_limit + 29) // 30))
|
||||||
|
for page_idx in range(max_offsets):
|
||||||
|
if time.monotonic() >= engine_deadline:
|
||||||
|
break
|
||||||
|
offset = page_idx * 30
|
||||||
|
params = {"q": search_query, "s": str(offset)}
|
||||||
|
remaining = max(0.0, float(engine_deadline - time.monotonic()))
|
||||||
|
if remaining <= 0.0:
|
||||||
|
break
|
||||||
|
read_timeout = max(3.0, min(10.0, remaining))
|
||||||
|
response = session.get(
|
||||||
|
endpoint,
|
||||||
|
params=params,
|
||||||
|
timeout=(3, read_timeout),
|
||||||
|
headers={"Referer": "https://duckduckgo.com/"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
page_rows = parse_duckduckgo_results(
|
||||||
|
cls,
|
||||||
|
html_text=response.text,
|
||||||
|
site_host=site_host,
|
||||||
|
limit=max(1, normalized_limit - len(all_rows_ddg)),
|
||||||
|
)
|
||||||
|
new_rows = 0
|
||||||
|
for row in page_rows:
|
||||||
|
url_value = str(row.get("url") or "").strip()
|
||||||
|
if not url_value or url_value in seen_urls:
|
||||||
|
continue
|
||||||
|
seen_urls.add(url_value)
|
||||||
|
all_rows_ddg.append(row)
|
||||||
|
new_rows += 1
|
||||||
|
if len(all_rows_ddg) >= normalized_limit:
|
||||||
|
break
|
||||||
|
if len(all_rows_ddg) >= normalized_limit or new_rows == 0:
|
||||||
|
break
|
||||||
|
if all_rows_ddg:
|
||||||
|
break
|
||||||
|
|
||||||
|
return all_rows_ddg[:normalized_limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_probable_html_path(path_value: str) -> bool:
|
||||||
|
"""Return True when URL path likely points to an HTML page."""
|
||||||
|
path = str(path_value or "").strip()
|
||||||
|
if not path:
|
||||||
|
return True
|
||||||
|
suffix = Path(path).suffix.lower()
|
||||||
|
if not suffix:
|
||||||
|
return True
|
||||||
|
return suffix in {".html", ".htm", ".php", ".asp", ".aspx", ".jsp", ".shtml", ".xhtml"}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_html_links(cls, *, html_text: str, base_url: str) -> List[str]:
|
||||||
|
"""Extract absolute links from an HTML document."""
|
||||||
|
links: List[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def _add_link(raw_href: Any) -> None:
|
||||||
|
href = str(raw_href or "").strip()
|
||||||
|
if not href or href.startswith(("#", "javascript:", "mailto:")):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
absolute = urljoin(base_url, href)
|
||||||
|
parsed = urlparse(absolute)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if str(getattr(parsed, "scheme", "") or "").lower() not in {"http", "https"}:
|
||||||
|
return
|
||||||
|
clean = parsed._replace(fragment="").geturl()
|
||||||
|
if clean in seen:
|
||||||
|
return
|
||||||
|
seen.add(clean)
|
||||||
|
links.append(clean)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from lxml import html as lxml_html
|
||||||
|
|
||||||
|
doc = lxml_html.fromstring(html_text or "")
|
||||||
|
for node in doc.xpath("//a[@href]"):
|
||||||
|
_add_link(node.get("href"))
|
||||||
|
except Exception:
|
||||||
|
href_pattern = re.compile(r'<a[^>]+href=["\']([^"\']+)["\']', flags=re.IGNORECASE)
|
||||||
|
for match in href_pattern.finditer(html_text or ""):
|
||||||
|
_add_link(match.group(1))
|
||||||
|
|
||||||
|
return links
|
||||||
|
|
||||||
|
|
||||||
|
def crawl_site_for_extension(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
seed_url: str,
|
||||||
|
site_host: str,
|
||||||
|
extension: str,
|
||||||
|
limit: int,
|
||||||
|
max_duration_seconds: float = 15.0,
|
||||||
|
) -> List[Dict[str, str]]:
|
||||||
|
"""Fallback crawler that discovers in-site file links by extension."""
|
||||||
|
from API.requests_client import get_requests_session
|
||||||
|
|
||||||
|
normalized_ext = _normalize_extension(extension)
|
||||||
|
if not normalized_ext:
|
||||||
|
return []
|
||||||
|
|
||||||
|
start_url = _normalize_seed_url(cls, seed_url, site_host)
|
||||||
|
if not start_url:
|
||||||
|
return []
|
||||||
|
|
||||||
|
session = get_requests_session()
|
||||||
|
headers = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
}
|
||||||
|
|
||||||
|
queue: deque[str] = deque([start_url])
|
||||||
|
queued: set[str] = {start_url}
|
||||||
|
visited_pages: set[str] = set()
|
||||||
|
seen_files: set[str] = set()
|
||||||
|
rows: List[Dict[str, str]] = []
|
||||||
|
normalized_limit = max(1, min(int(limit or 1), 100))
|
||||||
|
max_pages = max(8, min(normalized_limit * 4, 64))
|
||||||
|
crawl_deadline = time.monotonic() + max(5.0, float(max_duration_seconds or 0.0))
|
||||||
|
|
||||||
|
while (
|
||||||
|
queue
|
||||||
|
and len(visited_pages) < max_pages
|
||||||
|
and len(rows) < normalized_limit
|
||||||
|
and time.monotonic() < crawl_deadline
|
||||||
|
):
|
||||||
|
page_url = queue.popleft()
|
||||||
|
queued.discard(page_url)
|
||||||
|
if page_url in visited_pages:
|
||||||
|
continue
|
||||||
|
visited_pages.add(page_url)
|
||||||
|
|
||||||
|
if time.monotonic() >= crawl_deadline:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = session.get(page_url, timeout=(4, 8), headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
final_url = str(getattr(response, "url", "") or page_url)
|
||||||
|
try:
|
||||||
|
parsed_final = urlparse(final_url)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
final_host = _normalize_host(getattr(parsed_final, "hostname", "") or "")
|
||||||
|
if not _url_matches_site(final_url, site_host):
|
||||||
|
continue
|
||||||
|
|
||||||
|
final_path = str(getattr(parsed_final, "path", "") or "")
|
||||||
|
direct_ext = _normalize_extension(Path(final_path).suffix)
|
||||||
|
if direct_ext == normalized_ext:
|
||||||
|
file_url = parsed_final._replace(fragment="").geturl()
|
||||||
|
if file_url not in seen_files:
|
||||||
|
seen_files.add(file_url)
|
||||||
|
title = Path(unquote(final_path)).name or file_url
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"url": file_url,
|
||||||
|
"title": title,
|
||||||
|
"snippet": "Discovered via in-site crawl",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
content_type = str((response.headers or {}).get("content-type", "") or "").lower()
|
||||||
|
if "html" not in content_type and "xhtml" not in content_type:
|
||||||
|
continue
|
||||||
|
|
||||||
|
html_text = str(getattr(response, "text", "") or "")
|
||||||
|
if not html_text:
|
||||||
|
continue
|
||||||
|
if len(html_text) > 2_500_000:
|
||||||
|
continue
|
||||||
|
|
||||||
|
discovered_links = _extract_html_links(cls, html_text=html_text, base_url=final_url)
|
||||||
|
for idx, target in enumerate(discovered_links):
|
||||||
|
if len(rows) >= normalized_limit:
|
||||||
|
break
|
||||||
|
if idx >= 300:
|
||||||
|
break
|
||||||
|
if time.monotonic() >= crawl_deadline:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
parsed_target = urlparse(target)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
target_host = _normalize_host(getattr(parsed_target, "hostname", "") or "")
|
||||||
|
if not target_host or not (target_host == final_host or target_host.endswith(f".{site_host}")):
|
||||||
|
if not _url_matches_site(target, site_host):
|
||||||
|
continue
|
||||||
|
|
||||||
|
target_clean = parsed_target._replace(fragment="").geturl()
|
||||||
|
target_path = str(getattr(parsed_target, "path", "") or "")
|
||||||
|
target_ext = _normalize_extension(Path(target_path).suffix)
|
||||||
|
|
||||||
|
if target_ext == normalized_ext:
|
||||||
|
if target_clean in seen_files:
|
||||||
|
continue
|
||||||
|
seen_files.add(target_clean)
|
||||||
|
title = Path(unquote(target_path)).name or target_clean
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"url": target_clean,
|
||||||
|
"title": title,
|
||||||
|
"snippet": f"Discovered via crawl from {final_path or '/'}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if _is_probable_html_path(target_path):
|
||||||
|
if target_clean not in visited_pages and target_clean not in queued:
|
||||||
|
queue.append(target_clean)
|
||||||
|
queued.add(target_clean)
|
||||||
|
|
||||||
|
if time.monotonic() >= crawl_deadline:
|
||||||
|
debug(
|
||||||
|
"Web crawl fallback reached time budget",
|
||||||
|
{
|
||||||
|
"site": site_host,
|
||||||
|
"visited_pages": len(visited_pages),
|
||||||
|
"queued_pages": len(queue),
|
||||||
|
"results": len(rows),
|
||||||
|
"time_budget_seconds": max_duration_seconds,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return rows[:normalized_limit]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_site_host(cls, candidate: Any) -> Optional[str]:
|
||||||
|
"""Extract a host/domain from URL-like input."""
|
||||||
|
raw = str(candidate or "").strip().strip('"').strip("'")
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if raw.lower().startswith("site:"):
|
||||||
|
raw = raw.split(":", 1)[1].strip()
|
||||||
|
|
||||||
|
parsed = None
|
||||||
|
try:
|
||||||
|
parsed = urlparse(raw)
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
|
||||||
|
if parsed is None or not getattr(parsed, "hostname", None):
|
||||||
|
try:
|
||||||
|
parsed = urlparse(f"https://{raw}")
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
|
||||||
|
host = ""
|
||||||
|
try:
|
||||||
|
host = str(getattr(parsed, "hostname", "") or "").strip().lower()
|
||||||
|
except Exception:
|
||||||
|
host = ""
|
||||||
|
|
||||||
|
host = _normalize_host(host)
|
||||||
|
if not host or "." not in host:
|
||||||
|
return None
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_seed_url(cls, seed_value: Any, site_host: str) -> str:
|
||||||
|
"""Build a safe crawl starting URL from user input and resolved host."""
|
||||||
|
raw = str(seed_value or "").strip().strip("'\"")
|
||||||
|
if not raw:
|
||||||
|
raw = str(site_host or "").strip()
|
||||||
|
|
||||||
|
if raw and not raw.startswith(("http://", "https://")):
|
||||||
|
raw = f"https://{raw}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(raw)
|
||||||
|
except Exception:
|
||||||
|
parsed = urlparse("")
|
||||||
|
|
||||||
|
target = _normalize_host(site_host)
|
||||||
|
host = _normalize_host(getattr(parsed, "hostname", "") or "")
|
||||||
|
if target and host and not (host == target or host.endswith(f".{target}")):
|
||||||
|
return f"https://{target}/"
|
||||||
|
|
||||||
|
scheme = str(getattr(parsed, "scheme", "") or "https").lower()
|
||||||
|
if scheme not in {"http", "https"}:
|
||||||
|
scheme = "https"
|
||||||
|
|
||||||
|
netloc = str(getattr(parsed, "netloc", "") or "").strip()
|
||||||
|
if not netloc:
|
||||||
|
netloc = target
|
||||||
|
path = str(getattr(parsed, "path", "") or "").strip()
|
||||||
|
if not path:
|
||||||
|
path = "/"
|
||||||
|
|
||||||
|
return f"{scheme}://{netloc}{path}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_web_search_plan(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
positional_args: List[str],
|
||||||
|
storage_backend: Optional[str],
|
||||||
|
store_filter: Optional[str],
|
||||||
|
hash_query: List[str],
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Build web-search plan for URL + ext/filetype query syntax.
|
||||||
|
|
||||||
|
Example input:
|
||||||
|
search-file "example.com/foo" -query "ext:pdf"
|
||||||
|
Produces:
|
||||||
|
site:example.com filetype:pdf
|
||||||
|
"""
|
||||||
|
if storage_backend or store_filter or hash_query:
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = _normalize_space(query)
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
local_markers = ("url:", "hash:", "tag:", "instance:", "system:")
|
||||||
|
if any(marker in text.lower() for marker in local_markers):
|
||||||
|
return None
|
||||||
|
|
||||||
|
site_host: Optional[str] = None
|
||||||
|
site_from_positional = False
|
||||||
|
site_token_to_strip = ""
|
||||||
|
seed_url = ""
|
||||||
|
|
||||||
|
site_match = _SITE_TOKEN_RE.search(text)
|
||||||
|
if site_match:
|
||||||
|
site_host = _extract_site_host(cls, site_match.group(1))
|
||||||
|
seed_url = str(site_match.group(1) or "").strip()
|
||||||
|
|
||||||
|
if not site_host and positional_args:
|
||||||
|
site_host = _extract_site_host(cls, positional_args[0])
|
||||||
|
site_from_positional = bool(site_host)
|
||||||
|
if site_from_positional:
|
||||||
|
site_token_to_strip = str(positional_args[0] or "").strip()
|
||||||
|
seed_url = site_token_to_strip
|
||||||
|
|
||||||
|
if not site_host:
|
||||||
|
for token in text.split():
|
||||||
|
candidate = str(token or "").strip().strip(",")
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
lower_candidate = candidate.lower()
|
||||||
|
if lower_candidate.startswith(("ext:", "filetype:", "type:", "site:")):
|
||||||
|
continue
|
||||||
|
if _SCHEME_PREFIX_RE.match(lower_candidate) and not lower_candidate.startswith(
|
||||||
|
("http://", "https://")
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
guessed = _extract_site_host(cls, candidate)
|
||||||
|
if guessed:
|
||||||
|
site_host = guessed
|
||||||
|
site_token_to_strip = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if not site_host:
|
||||||
|
return None
|
||||||
|
|
||||||
|
filetype_match = _FILETYPE_TOKEN_RE.search(text)
|
||||||
|
filetype = _normalize_extension(filetype_match.group(1)) if filetype_match else ""
|
||||||
|
|
||||||
|
has_explicit_site = bool(site_match)
|
||||||
|
if not filetype and not has_explicit_site:
|
||||||
|
return None
|
||||||
|
|
||||||
|
residual = text
|
||||||
|
residual = _SITE_REMOVE_RE.sub(" ", residual)
|
||||||
|
residual = _FILETYPE_REMOVE_RE.sub(" ", residual)
|
||||||
|
|
||||||
|
if site_from_positional and positional_args:
|
||||||
|
first = str(positional_args[0] or "").strip()
|
||||||
|
if first:
|
||||||
|
residual = re.sub(rf"(?:^|\s){re.escape(first)}(?:\s|$)", " ", residual, count=1)
|
||||||
|
elif site_token_to_strip:
|
||||||
|
residual = re.sub(
|
||||||
|
rf"(?:^|\s){re.escape(site_token_to_strip)}(?:\s|$)",
|
||||||
|
" ",
|
||||||
|
residual,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
residual = _normalize_space(residual)
|
||||||
|
|
||||||
|
search_terms: List[str] = [f"site:{site_host}"]
|
||||||
|
if filetype:
|
||||||
|
search_terms.append(f"filetype:{filetype}")
|
||||||
|
if residual:
|
||||||
|
search_terms.append(residual)
|
||||||
|
|
||||||
|
search_query = " ".join(search_terms).strip()
|
||||||
|
if not search_query:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized_seed_url = _normalize_seed_url(cls, seed_url, site_host)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"site_host": site_host,
|
||||||
|
"filetype": filetype,
|
||||||
|
"search_query": search_query,
|
||||||
|
"residual": residual,
|
||||||
|
"seed_url": normalized_seed_url,
|
||||||
|
}
|
||||||
+1
-1
@@ -475,7 +475,7 @@ class Get_Url(Cmdlet):
|
|||||||
|
|
||||||
publish_result_table(ctx, table if display_items else None, display_items, subject=result)
|
publish_result_table(ctx, table if display_items else None, display_items, subject=result)
|
||||||
|
|
||||||
# Emit after table state is finalized to prevent side effects in TUI rendering
|
# Emit after table state is finalized
|
||||||
for d in display_items:
|
for d in display_items:
|
||||||
ctx.emit(d)
|
ctx.emit(d)
|
||||||
|
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ CMDLET = Cmdlet(
|
|||||||
"- Mode 2: Use -king to explicitly set which item/hash is the king: @1-3 | add-relationship -king @4",
|
"- Mode 2: Use -king to explicitly set which item/hash is the king: @1-3 | add-relationship -king @4",
|
||||||
"- Mode 2b: Use -king and -alt to select both sides from the last table: add-relationship -king @1 -alt @3-5",
|
"- Mode 2b: Use -king and -alt to select both sides from the last table: add-relationship -king @1 -alt @3-5",
|
||||||
"- Mode 3: Read relationships from sidecar tags:",
|
"- Mode 3: Read relationships from sidecar tags:",
|
||||||
" - New format: 'relationship: <KING_HASH>,<ALT_HASH>,<ALT_HASH>' (first hash is king)",
|
" - Format: 'relationship: <KING_HASH>,<ALT_HASH>,<ALT_HASH>' (first hash is king)",
|
||||||
" - Legacy: 'relationship: hash(king)<HASH>,hash(alt)<HASH>...'",
|
|
||||||
"- Supports three relationship types: king (primary), alt (alternative), related (other versions)",
|
"- Supports three relationship types: king (primary), alt (alternative), related (other versions)",
|
||||||
"- When using -king, all piped items become the specified relationship type to the king",
|
"- When using -king, all piped items become the specified relationship type to the king",
|
||||||
],
|
],
|
||||||
@@ -71,13 +70,10 @@ CMDLET = Cmdlet(
|
|||||||
_normalize_hash_hex = sh.normalize_hash
|
_normalize_hash_hex = sh.normalize_hash
|
||||||
|
|
||||||
|
|
||||||
def _extract_relationships_from_tag(tag_value: str) -> Dict[str, list[str]]:
|
def _extract_relationships_from_tag(tag_value: str) -> Dict[str, list[str]]:
|
||||||
"""Parse relationship tags.
|
"""Parse relationship tags.
|
||||||
|
|
||||||
Supported formats:
|
Format: relationship: <KING_HASH>,<ALT_HASH>,<ALT_HASH>
|
||||||
- New: relationship: <KING_HASH>,<ALT_HASH>,<ALT_HASH>
|
|
||||||
- Old: relationship: hash(king)<HASH>,hash(alt)<HASH>...
|
|
||||||
|
|
||||||
Returns a dict like {"king": ["HASH1"], "alt": ["HASH2"], ...}
|
Returns a dict like {"king": ["HASH1"], "alt": ["HASH2"], ...}
|
||||||
"""
|
"""
|
||||||
result: Dict[str,
|
result: Dict[str,
|
||||||
@@ -85,20 +81,7 @@ def _extract_relationships_from_tag(tag_value: str) -> Dict[str, list[str]]:
|
|||||||
if not isinstance(tag_value, str):
|
if not isinstance(tag_value, str):
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Match patterns like hash(king)HASH or hash(type)<HASH>
|
# Extract hashes; first is king
|
||||||
pattern = r"hash\((\w+)\)<?([a-fA-F0-9]{64})>?"
|
|
||||||
matches = re.findall(pattern, tag_value)
|
|
||||||
|
|
||||||
if matches:
|
|
||||||
for rel_type, hash_value in matches:
|
|
||||||
normalized = _normalize_hash_hex(hash_value)
|
|
||||||
if normalized:
|
|
||||||
if rel_type not in result:
|
|
||||||
result[rel_type] = []
|
|
||||||
result[rel_type].append(normalized)
|
|
||||||
return result
|
|
||||||
|
|
||||||
# New format: extract hashes, first is king
|
|
||||||
hashes = re.findall(r"\b[a-fA-F0-9]{64}\b", tag_value)
|
hashes = re.findall(r"\b[a-fA-F0-9]{64}\b", tag_value)
|
||||||
hashes = [h.strip().lower() for h in hashes if isinstance(h, str)]
|
hashes = [h.strip().lower() for h in hashes if isinstance(h, str)]
|
||||||
if not hashes:
|
if not hashes:
|
||||||
@@ -833,7 +816,7 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
hydrus_client.set_relationship(h, king_hash, str(rel_type))
|
hydrus_client.set_relationship(h, king_hash, str(rel_type))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# Process each item in the list (legacy path-based mode)
|
# Process each item in the list (path-based mode)
|
||||||
for item in items_to_process:
|
for item in items_to_process:
|
||||||
# Extract hash and path from current item
|
# Extract hash and path from current item
|
||||||
file_hash = None
|
file_hash = None
|
||||||
@@ -1033,136 +1016,6 @@ def _run(result: Any, _args: Sequence[str], config: Dict[str, Any]) -> int:
|
|||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# FILE MODE: Read relationships from sidecar (legacy mode - for -path arg only)
|
|
||||||
log(
|
|
||||||
"Note: Use piping mode for easier relationships. Example: 1,2,3 | add-relationship",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resolve media path from -path arg or result target
|
|
||||||
target = getattr(result, "target", None) or getattr(result, "path", None)
|
|
||||||
media_path = (
|
|
||||||
arg_path
|
|
||||||
if arg_path is not None else Path(str(target)) if isinstance(target,
|
|
||||||
str) else None
|
|
||||||
)
|
|
||||||
if media_path is None:
|
|
||||||
log("Provide -path <file> or pipe a local file result", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Validate local file
|
|
||||||
if str(media_path).lower().startswith(("http://", "https://")):
|
|
||||||
log("This cmdlet requires a local file path, not a URL", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
if not media_path.exists() or not media_path.is_file():
|
|
||||||
log(f"File not found: {media_path}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Build Hydrus client
|
|
||||||
hydrus_provider = get_plugin("hydrusnetwork", config)
|
|
||||||
try:
|
|
||||||
hydrus_client = hydrus_provider.get_client() if hydrus_provider is not None else None
|
|
||||||
except Exception as exc:
|
|
||||||
log(f"Hydrus client unavailable: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
if hydrus_client is None:
|
|
||||||
log("Hydrus client unavailable", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Read sidecar to find relationship tags
|
|
||||||
sidecar_path = find_sidecar(media_path)
|
|
||||||
if sidecar_path is None:
|
|
||||||
log(f"No sidecar found for {media_path.name}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
_, tags, _ = read_sidecar(sidecar_path)
|
|
||||||
except Exception as exc:
|
|
||||||
log(f"Failed to read sidecar: {exc}", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Find relationship tags (format: "relationship: hash(king)<HASH>,hash(alt)<HASH>,hash(related)<HASH>")
|
|
||||||
relationship_tags = [
|
|
||||||
t for t in tags if isinstance(t, str) and t.lower().startswith("relationship:")
|
|
||||||
]
|
|
||||||
|
|
||||||
if not relationship_tags:
|
|
||||||
log("No relationship tags found in sidecar", file=sys.stderr)
|
|
||||||
return 0 # Not an error, just nothing to do
|
|
||||||
|
|
||||||
# Get the file hash from result (should have been set by add-file)
|
|
||||||
file_hash = getattr(result, "hash_hex", None)
|
|
||||||
if not file_hash:
|
|
||||||
log("File hash not available (run add-file first)", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
file_hash = _normalize_hash_hex(file_hash)
|
|
||||||
if not file_hash:
|
|
||||||
log("Invalid file hash format", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Parse relationships from tags and apply them
|
|
||||||
success_count = 0
|
|
||||||
error_count = 0
|
|
||||||
|
|
||||||
for rel_tag in relationship_tags:
|
|
||||||
try:
|
|
||||||
# Parse: "relationship: hash(king)<HASH>,hash(alt)<HASH>,hash(related)<HASH>"
|
|
||||||
rel_str = rel_tag.split(":", 1)[1].strip() # Get part after "relationship:"
|
|
||||||
|
|
||||||
# Parse relationships
|
|
||||||
rels = _extract_relationships_from_tag(f"relationship: {rel_str}")
|
|
||||||
|
|
||||||
# Set the relationships in Hydrus
|
|
||||||
for rel_type, related_hashes in rels.items():
|
|
||||||
if not related_hashes:
|
|
||||||
continue
|
|
||||||
|
|
||||||
for related_hash in related_hashes:
|
|
||||||
# Don't set relationship between hash and itself
|
|
||||||
if file_hash == related_hash:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
hydrus_client.set_relationship(
|
|
||||||
file_hash,
|
|
||||||
related_hash,
|
|
||||||
rel_type
|
|
||||||
)
|
|
||||||
log(
|
|
||||||
f"[add-relationship] Set {rel_type} relationship: "
|
|
||||||
f"{file_hash} <-> {related_hash}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
success_count += 1
|
|
||||||
except Exception as exc:
|
|
||||||
log(
|
|
||||||
f"Failed to set {rel_type} relationship: {exc}",
|
|
||||||
file=sys.stderr
|
|
||||||
)
|
|
||||||
error_count += 1
|
|
||||||
|
|
||||||
except Exception as exc:
|
|
||||||
log(f"Failed to parse relationship tag: {exc}", file=sys.stderr)
|
|
||||||
error_count += 1
|
|
||||||
|
|
||||||
if success_count > 0:
|
|
||||||
log(
|
|
||||||
f"Successfully set {success_count} relationship(s) for {media_path.name}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
ctx.emit(
|
|
||||||
f"add-relationship: {media_path.name} ({success_count} relationships set)"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
elif error_count == 0:
|
|
||||||
log("No relationships to set", file=sys.stderr)
|
|
||||||
return 0 # Success with nothing to do
|
|
||||||
else:
|
|
||||||
log(f"Failed with {error_count} error(s)", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
# Register cmdlet (no legacy decorator)
|
# Register cmdlet (no legacy decorator)
|
||||||
CMDLET.exec = _run
|
CMDLET.exec = _run
|
||||||
|
|||||||
@@ -1081,6 +1081,8 @@ class Add_Tag(Cmdlet):
|
|||||||
ok_add = True
|
ok_add = True
|
||||||
|
|
||||||
if not queued_bulk:
|
if not queued_bulk:
|
||||||
|
ok_add = False
|
||||||
|
ok_remove = True
|
||||||
try:
|
try:
|
||||||
ok_add = backend.add_tag(
|
ok_add = backend.add_tag(
|
||||||
resolved_hash,
|
resolved_hash,
|
||||||
@@ -1088,11 +1090,19 @@ class Add_Tag(Cmdlet):
|
|||||||
config=config,
|
config=config,
|
||||||
existing_tags=existing_tag_list,
|
existing_tags=existing_tag_list,
|
||||||
)
|
)
|
||||||
if not ok_add:
|
|
||||||
log("[add_tag] Warning: Store rejected tag update", file=sys.stderr)
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log(f"[add_tag] Warning: Failed adding tag: {exc}", file=sys.stderr)
|
log(f"[add_tag] Warning: Failed adding tag: {exc}", file=sys.stderr)
|
||||||
ok_add = False
|
ok_add = False
|
||||||
|
if tags_to_remove:
|
||||||
|
try:
|
||||||
|
delete_fn = getattr(backend, "delete_tag", None)
|
||||||
|
if callable(delete_fn):
|
||||||
|
ok_remove = delete_fn(resolved_hash, list(tags_to_remove), config=config)
|
||||||
|
except Exception as exc:
|
||||||
|
log(f"[add_tag] Warning: Failed removing tag: {exc}", file=sys.stderr)
|
||||||
|
ok_remove = False
|
||||||
|
if not ok_add and not ok_remove:
|
||||||
|
log("[add_tag] Warning: Store rejected tag update", file=sys.stderr)
|
||||||
|
|
||||||
if ok_add and merged_tags:
|
if ok_add and merged_tags:
|
||||||
refreshed_list = list(merged_tags)
|
refreshed_list = list(merged_tags)
|
||||||
|
|||||||
+1
-2
@@ -1158,8 +1158,7 @@ def _run(piped_result: Any, args: List[str], config: Dict[str, Any]) -> int:
|
|||||||
if not args:
|
if not args:
|
||||||
if sys.stdin.isatty() and not piped_result:
|
if sys.stdin.isatty() and not piped_result:
|
||||||
print(
|
print(
|
||||||
"Interactive TUI config editor has been discontinued. "
|
"Run `.config <key>` to view or edit a specific setting. "
|
||||||
"Showing configuration table instead."
|
|
||||||
)
|
)
|
||||||
return _show_config_table(current_config)
|
return _show_config_table(current_config)
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -60,8 +60,7 @@ def _run(result: Any, args: List[str], config: Dict[str, Any]) -> int:
|
|||||||
_add_startup_check(startup_table, "ERROR", "STATUS", detail=str(exc))
|
_add_startup_check(startup_table, "ERROR", "STATUS", detail=str(exc))
|
||||||
|
|
||||||
if startup_table.rows:
|
if startup_table.rows:
|
||||||
# Mark as rendered to prevent CLI.py from auto-printing it to stdout
|
# Mark as rendered to prevent CLI.py from auto-printing it to stdout
|
||||||
# (avoiding duplication in TUI logs, while keeping it in TUI Results)
|
|
||||||
setattr(startup_table, "_rendered_by_cmdlet", True)
|
setattr(startup_table, "_rendered_by_cmdlet", True)
|
||||||
ctx.set_current_stage_table(startup_table)
|
ctx.set_current_stage_table(startup_table)
|
||||||
|
|
||||||
|
|||||||
+165
-3
@@ -4,11 +4,27 @@ 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 PluginCore.base import Provider
|
from PluginCore.base import Provider, SearchResult
|
||||||
from SYS.metadata import write_metadata, write_tags
|
from SYS.metadata import _read_sidecar_metadata, read_tags_from_file, write_metadata, write_tags
|
||||||
from SYS.utils import sanitize_filename, sha256_file, unique_path
|
from SYS.utils import sanitize_filename, sha256_file, unique_path
|
||||||
|
|
||||||
|
|
||||||
|
def _format_size_safe(size_bytes: Any) -> str:
|
||||||
|
if size_bytes is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
size = int(size_bytes)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return str(size_bytes or "")
|
||||||
|
if size < 1024:
|
||||||
|
return f"{size} B"
|
||||||
|
if size < 1024 * 1024:
|
||||||
|
return f"{size / 1024:.1f} KB"
|
||||||
|
if size < 1024 * 1024 * 1024:
|
||||||
|
return f"{size / (1024 * 1024):.1f} MB"
|
||||||
|
return f"{size / (1024 * 1024 * 1024):.2f} GB"
|
||||||
|
|
||||||
|
|
||||||
def _copy_sidecars(source_path: Path, target_path: Path) -> None:
|
def _copy_sidecars(source_path: Path, target_path: Path) -> None:
|
||||||
possible_sidecars = [
|
possible_sidecars = [
|
||||||
source_path.with_suffix(source_path.suffix + ".json"),
|
source_path.with_suffix(source_path.suffix + ".json"),
|
||||||
@@ -80,7 +96,7 @@ class Local(Provider):
|
|||||||
PLUGIN_NAME = "local"
|
PLUGIN_NAME = "local"
|
||||||
PLUGIN_ALIASES = ("filesystem", "fs")
|
PLUGIN_ALIASES = ("filesystem", "fs")
|
||||||
MULTI_INSTANCE = True
|
MULTI_INSTANCE = True
|
||||||
SUPPORTED_CMDLETS = frozenset({"add-file"})
|
SUPPORTED_CMDLETS = frozenset({"add-file", "search-file"})
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def label(self) -> str:
|
def label(self) -> str:
|
||||||
@@ -165,6 +181,152 @@ class Local(Provider):
|
|||||||
def validate(self) -> bool:
|
def validate(self) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _infer_media_kind(ext: str) -> str:
|
||||||
|
e = str(ext or "").strip().lower().lstrip(".")
|
||||||
|
if e in {"mp3", "flac", "wav", "ogg", "opus", "m4a", "aac", "wma", "aiff"}:
|
||||||
|
return "audio"
|
||||||
|
if e in {"mp4", "mkv", "avi", "mov", "webm", "wmv", "flv", "m4v"}:
|
||||||
|
return "video"
|
||||||
|
if e in {"pdf", "epub", "mobi", "azw3", "djvu", "cbr", "cbz", "chm"}:
|
||||||
|
return "book"
|
||||||
|
if e in {"zip", "rar", "7z", "tar", "gz", "bz2", "xz", "iso"}:
|
||||||
|
return "archive"
|
||||||
|
if e in {"exe", "msi", "apk", "deb", "rpm", "appimage", "bin"}:
|
||||||
|
return "software"
|
||||||
|
if e in {"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg", "tiff", "ico"}:
|
||||||
|
return "image"
|
||||||
|
return "file"
|
||||||
|
|
||||||
|
def search(
|
||||||
|
self,
|
||||||
|
query: str,
|
||||||
|
limit: int = 50,
|
||||||
|
filters: Optional[Dict[str, Any]] = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> List[SearchResult]:
|
||||||
|
q = str(query or "").strip()
|
||||||
|
match_all = not q or q == "*"
|
||||||
|
query_tokens = [t.lower() for t in q.split()] if not match_all else []
|
||||||
|
max_results = max(1, int(limit))
|
||||||
|
|
||||||
|
target_instance = None
|
||||||
|
if filters:
|
||||||
|
target_instance = str(filters.get("instance") or filters.get("store") or "").strip() or None
|
||||||
|
|
||||||
|
results: List[SearchResult] = []
|
||||||
|
instances = self.plugin_instance_configs()
|
||||||
|
|
||||||
|
for inst_name, inst_cfg in instances.items():
|
||||||
|
if target_instance and str(inst_name or "").strip().lower() != target_instance.lower():
|
||||||
|
continue
|
||||||
|
|
||||||
|
settings = self._settings_from_config(inst_cfg, instance_name=inst_name)
|
||||||
|
root_text = str(settings.get("path") or "").strip()
|
||||||
|
if not root_text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
root = Path(root_text).expanduser()
|
||||||
|
if not root.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
for entry in root.rglob("*"):
|
||||||
|
if len(results) >= max_results:
|
||||||
|
break
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_name = entry.name
|
||||||
|
file_stem = entry.stem
|
||||||
|
|
||||||
|
tag_path = entry.parent / (file_name + ".tag")
|
||||||
|
meta_path = entry.parent / (file_name + ".metadata")
|
||||||
|
|
||||||
|
has_tag = tag_path.is_file()
|
||||||
|
has_meta = meta_path.is_file()
|
||||||
|
if not has_tag and not has_meta:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tags: List[str] = []
|
||||||
|
if has_tag:
|
||||||
|
try:
|
||||||
|
tags = read_tags_from_file(tag_path)
|
||||||
|
except Exception:
|
||||||
|
tags = []
|
||||||
|
|
||||||
|
hash_value: Optional[str] = None
|
||||||
|
urls: List[str] = []
|
||||||
|
if has_meta:
|
||||||
|
try:
|
||||||
|
hash_value, _extra_tags, urls = _read_sidecar_metadata(meta_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not match_all and query_tokens:
|
||||||
|
url_text = " ".join(urls) if urls else ""
|
||||||
|
search_text = " ".join([file_name.lower(), file_stem.lower().replace("_", " "), *tags, url_text])
|
||||||
|
if hash_value:
|
||||||
|
search_text += " " + hash_value.lower()
|
||||||
|
if not all(token in search_text for token in query_tokens):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
size_bytes = int(entry.stat().st_size)
|
||||||
|
except Exception:
|
||||||
|
size_bytes = None
|
||||||
|
|
||||||
|
ext = entry.suffix.lstrip(".")
|
||||||
|
media_kind = self._infer_media_kind(ext)
|
||||||
|
|
||||||
|
inst_label = str(inst_name or "").strip()
|
||||||
|
if inst_label.lower() == "default":
|
||||||
|
inst_label = ""
|
||||||
|
|
||||||
|
store_label = f"local:{inst_label}" if inst_label else "local"
|
||||||
|
tag_text = ", ".join(tags[:8]) if tags else ""
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = {
|
||||||
|
"store": store_label,
|
||||||
|
"ext": ext,
|
||||||
|
"size": size_bytes,
|
||||||
|
}
|
||||||
|
if inst_label:
|
||||||
|
metadata["instance"] = inst_label
|
||||||
|
if hash_value:
|
||||||
|
metadata["hash"] = hash_value
|
||||||
|
if urls:
|
||||||
|
metadata["url"] = urls
|
||||||
|
|
||||||
|
sr = SearchResult(
|
||||||
|
table="local",
|
||||||
|
title=file_name,
|
||||||
|
path=str(entry),
|
||||||
|
detail=store_label,
|
||||||
|
annotations=[store_label],
|
||||||
|
media_kind=media_kind,
|
||||||
|
size_bytes=size_bytes,
|
||||||
|
tag=set(tags),
|
||||||
|
columns=[
|
||||||
|
("Title", file_name),
|
||||||
|
("Tag", tag_text),
|
||||||
|
("Store", store_label),
|
||||||
|
("Size", _format_size_safe(size_bytes)),
|
||||||
|
("Ext", ext),
|
||||||
|
],
|
||||||
|
full_metadata=metadata,
|
||||||
|
)
|
||||||
|
sr.ext = ext
|
||||||
|
sr.size = size_bytes
|
||||||
|
if hash_value:
|
||||||
|
sr.hash = hash_value
|
||||||
|
results.append(sr)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return results[:max_results]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _folder_name_from_pipe(pipe_obj: Any) -> str:
|
def _folder_name_from_pipe(pipe_obj: Any) -> str:
|
||||||
metadata = getattr(pipe_obj, "metadata", None)
|
metadata = getattr(pipe_obj, "metadata", None)
|
||||||
|
|||||||
+51
-35
@@ -1,5 +1,6 @@
|
|||||||
# pyright: reportUnusedFunction=false
|
# pyright: reportUnusedFunction=false
|
||||||
from typing import Any, Dict, Sequence, List, Optional
|
from typing import Any, Dict, Sequence, List, Optional
|
||||||
|
import atexit
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
@@ -21,6 +22,8 @@ from SYS.config import get_hydrus_access_key, get_hydrus_url, resolve_cookies_pa
|
|||||||
|
|
||||||
_NOTES_PREFETCH_INFLIGHT: set[str] = set()
|
_NOTES_PREFETCH_INFLIGHT: set[str] = set()
|
||||||
_NOTES_PREFETCH_LOCK = threading.Lock()
|
_NOTES_PREFETCH_LOCK = threading.Lock()
|
||||||
|
_PREFETCH_THREADS: set[threading.Thread] = set()
|
||||||
|
_PREFETCH_THREADS_LOCK = threading.Lock()
|
||||||
_PLAYLIST_STORE_CACHE: Optional[Dict[str, Any]] = None
|
_PLAYLIST_STORE_CACHE: Optional[Dict[str, Any]] = None
|
||||||
_PLAYLIST_STORE_MTIME_NS: Optional[int] = None
|
_PLAYLIST_STORE_MTIME_NS: Optional[int] = None
|
||||||
_SHA256_RE = re.compile(r"[0-9a-f]{64}")
|
_SHA256_RE = re.compile(r"[0-9a-f]{64}")
|
||||||
@@ -347,21 +350,20 @@ def _get_latest_mpv_run_marker(log_db_path: str) -> Optional[tuple[str, str]]:
|
|||||||
try:
|
try:
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
conn = sqlite3.connect(log_db_path, timeout=5.0)
|
with sqlite3.connect(log_db_path, timeout=5.0) as conn:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
(
|
(
|
||||||
"SELECT timestamp, message FROM logs "
|
"SELECT timestamp, message FROM logs "
|
||||||
"WHERE module = 'mpv' AND ("
|
"WHERE module = 'mpv' AND ("
|
||||||
"message LIKE '[helper] version=% started ipc=%' "
|
"message LIKE '[helper] version=% started ipc=%' "
|
||||||
"OR message LIKE 'medeia lua loaded version=%'"
|
"OR message LIKE 'medeia lua loaded version=%'"
|
||||||
") "
|
") "
|
||||||
"ORDER BY timestamp DESC LIMIT 1"
|
"ORDER BY timestamp DESC LIMIT 1"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
row = cur.fetchone()
|
||||||
row = cur.fetchone()
|
cur.close()
|
||||||
cur.close()
|
|
||||||
conn.close()
|
|
||||||
if row and row[0]:
|
if row and row[0]:
|
||||||
return str(row[0]), str(row[1] or "")
|
return str(row[0]), str(row[1] or "")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -381,26 +383,25 @@ def _get_mpv_logs_for_latest_run(
|
|||||||
try:
|
try:
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
conn = sqlite3.connect(log_db_path, timeout=5.0)
|
with sqlite3.connect(log_db_path, timeout=5.0) as conn:
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
query = "SELECT timestamp, level, module, message FROM logs WHERE module = 'mpv'"
|
query = "SELECT timestamp, level, module, message FROM logs WHERE module = 'mpv'"
|
||||||
params: List[str] = []
|
params: List[str] = []
|
||||||
if marker_ts:
|
if marker_ts:
|
||||||
query += " AND timestamp >= ?"
|
query += " AND timestamp >= ?"
|
||||||
params.append(marker_ts)
|
params.append(marker_ts)
|
||||||
else:
|
else:
|
||||||
cutoff = (datetime.utcnow() - timedelta(hours=6)).strftime("%Y-%m-%d %H:%M:%S")
|
cutoff = (datetime.utcnow() - timedelta(hours=6)).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
query += " AND timestamp >= ?"
|
query += " AND timestamp >= ?"
|
||||||
params.append(cutoff)
|
params.append(cutoff)
|
||||||
if log_filter_text:
|
if log_filter_text:
|
||||||
query += " AND LOWER(message) LIKE ?"
|
query += " AND LOWER(message) LIKE ?"
|
||||||
params.append(f"%{log_filter_text.lower()}%")
|
params.append(f"%{log_filter_text.lower()}%")
|
||||||
query += " ORDER BY timestamp DESC LIMIT ?"
|
query += " ORDER BY timestamp DESC LIMIT ?"
|
||||||
params.append(str(max(1, int(limit))))
|
params.append(str(max(1, int(limit))))
|
||||||
cur.execute(query, tuple(params))
|
cur.execute(query, tuple(params))
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
|
||||||
rows.reverse()
|
rows.reverse()
|
||||||
return marker_ts, marker_msg, rows
|
return marker_ts, marker_msg, rows
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -849,11 +850,26 @@ def _prefetch_notes_async(
|
|||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=_worker,
|
target=_worker,
|
||||||
name=f"mpv-notes-prefetch-{file_hash[:8]}",
|
name=f"mpv-notes-prefetch-{file_hash[:8]}",
|
||||||
daemon=True,
|
|
||||||
)
|
)
|
||||||
|
with _PREFETCH_THREADS_LOCK:
|
||||||
|
_PREFETCH_THREADS.add(thread)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _shutdown_prefetch_threads() -> None:
|
||||||
|
with _PREFETCH_THREADS_LOCK:
|
||||||
|
threads = list(_PREFETCH_THREADS)
|
||||||
|
_PREFETCH_THREADS.clear()
|
||||||
|
for t in threads:
|
||||||
|
try:
|
||||||
|
t.join(timeout=1.0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
atexit.register(_shutdown_prefetch_threads)
|
||||||
|
|
||||||
|
|
||||||
def _schedule_notes_prefetch(items: Sequence[Any], config: Optional[Dict[str, Any]]) -> None:
|
def _schedule_notes_prefetch(items: Sequence[Any], config: Optional[Dict[str, Any]]) -> None:
|
||||||
limit = _get_lyric_prefetch_limit(config)
|
limit = _get_lyric_prefetch_limit(config)
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
|
|||||||
@@ -14,11 +14,6 @@ def _repo_root() -> Path:
|
|||||||
return package_dir.parent
|
return package_dir.parent
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = _repo_root()
|
|
||||||
if str(REPO_ROOT) not in sys.path:
|
|
||||||
sys.path.insert(0, str(REPO_ROOT))
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
args = list(sys.argv[1:] if argv is None else argv)
|
args = list(sys.argv[1:] if argv is None else argv)
|
||||||
if not args:
|
if not args:
|
||||||
@@ -35,10 +30,19 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
url = str(args[0] or "").strip()
|
url = str(args[0] or "").strip()
|
||||||
captured_stdout = io.StringIO()
|
captured_stdout = io.StringIO()
|
||||||
captured_stderr = io.StringIO()
|
captured_stderr = io.StringIO()
|
||||||
with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr):
|
|
||||||
from plugins.mpv.pipeline_helper import _run_op
|
|
||||||
|
|
||||||
payload = _run_op("ytdlp-formats", {"url": url})
|
_root_str = str(_repo_root())
|
||||||
|
_path_added = _root_str not in sys.path
|
||||||
|
if _path_added:
|
||||||
|
sys.path.insert(0, _root_str)
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr):
|
||||||
|
from plugins.mpv.pipeline_helper import _run_op
|
||||||
|
|
||||||
|
payload = _run_op("ytdlp-formats", {"url": url})
|
||||||
|
finally:
|
||||||
|
if _path_added:
|
||||||
|
sys.path.remove(_root_str)
|
||||||
|
|
||||||
noisy_stdout = captured_stdout.getvalue().strip()
|
noisy_stdout = captured_stdout.getvalue().strip()
|
||||||
noisy_stderr = captured_stderr.getvalue().strip()
|
noisy_stderr = captured_stderr.getvalue().strip()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ This is the central hub for all Python-mpv IPC communication. The Lua script
|
|||||||
should use the Python CLI, which uses this module to manage mpv connections.
|
should use the Python CLI, which uses this module to manage mpv connections.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import atexit
|
||||||
import ctypes
|
import ctypes
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -901,6 +902,7 @@ class MPVIPCClient:
|
|||||||
self.is_windows = platform.system() == "Windows"
|
self.is_windows = platform.system() == "Windows"
|
||||||
self.silent = bool(silent)
|
self.silent = bool(silent)
|
||||||
self._recv_buffer: bytes = b""
|
self._recv_buffer: bytes = b""
|
||||||
|
atexit.register(self.disconnect)
|
||||||
|
|
||||||
def _write_payload(self, payload: str) -> None:
|
def _write_payload(self, payload: str) -> None:
|
||||||
if not self.sock:
|
if not self.sock:
|
||||||
@@ -1229,10 +1231,6 @@ class MPVIPCClient:
|
|||||||
self.sock = None
|
self.sock = None
|
||||||
self._recv_buffer = b""
|
self._recv_buffer = b""
|
||||||
|
|
||||||
def __del__(self) -> None:
|
|
||||||
"""Cleanup on object destruction."""
|
|
||||||
self.disconnect()
|
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
"""Context manager entry."""
|
"""Context manager entry."""
|
||||||
self.connect()
|
self.connect()
|
||||||
|
|||||||
@@ -61,17 +61,22 @@ def _runtime_config_root() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
# Make repo-local packages importable even when mpv starts us from another cwd.
|
# Make repo-local packages importable even when mpv starts us from another cwd.
|
||||||
_ROOT = str(_repo_root())
|
_root_str = str(_repo_root())
|
||||||
if _ROOT not in sys.path:
|
_path_added = _root_str not in sys.path
|
||||||
sys.path.insert(0, _ROOT)
|
if _path_added:
|
||||||
|
sys.path.insert(0, _root_str)
|
||||||
from plugins.mpv.mpv_ipc import MPVIPCClient, _windows_kill_pids, _windows_hidden_subprocess_kwargs, _windows_list_mpv_pids # noqa: E402
|
try:
|
||||||
from SYS.config import load_config, reload_config # noqa: E402
|
from plugins.mpv.mpv_ipc import MPVIPCClient, _windows_kill_pids, _windows_hidden_subprocess_kwargs, _windows_list_mpv_pids # noqa: E402
|
||||||
from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402
|
from SYS.config import load_config, reload_config # noqa: E402
|
||||||
from SYS.repl_queue import enqueue_repl_command, repl_state_is_alive # noqa: E402
|
from SYS.logger import set_debug, debug, set_thread_stream # noqa: E402
|
||||||
from SYS.utils import format_bytes # noqa: E402
|
from SYS.repl_queue import enqueue_repl_command, repl_state_is_alive # noqa: E402
|
||||||
from PluginCore.registry import get_plugin, get_plugin_class # noqa: E402
|
from SYS.utils import format_bytes # noqa: E402
|
||||||
from plugins.ytdlp.tooling import get_display_format_id, get_selection_format_id # noqa: E402
|
from PluginCore.registry import get_plugin, get_plugin_class # noqa: E402
|
||||||
|
from plugins.ytdlp.tooling import get_display_format_id, get_selection_format_id # noqa: E402
|
||||||
|
finally:
|
||||||
|
if _path_added:
|
||||||
|
sys.path.remove(_root_str)
|
||||||
|
del _root_str, _path_added
|
||||||
|
|
||||||
REQUEST_PROP = "user-data/medeia-pipeline-request"
|
REQUEST_PROP = "user-data/medeia-pipeline-request"
|
||||||
RESPONSE_PROP = "user-data/medeia-pipeline-response"
|
RESPONSE_PROP = "user-data/medeia-pipeline-response"
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import `plugins.playwright` even when Playwright itself is not installed.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .runtime import USER_AGENT
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"USER_AGENT",
|
||||||
"PlaywrightTimeoutError",
|
"PlaywrightTimeoutError",
|
||||||
"PlaywrightTool",
|
"PlaywrightTool",
|
||||||
"PlaywrightDefaults",
|
"PlaywrightDefaults",
|
||||||
|
|||||||
@@ -65,15 +65,18 @@ def _find_filename_from_cd(cd: str) -> Optional[str]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
USER_AGENT = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class PlaywrightDefaults:
|
class PlaywrightDefaults:
|
||||||
browser: str = "chromium" # chromium|firefox|webkit
|
browser: str = "chromium" # chromium|firefox|webkit
|
||||||
headless: bool = True
|
headless: bool = True
|
||||||
user_agent: str = (
|
user_agent: str = USER_AGENT
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
||||||
"Chrome/120.0.0.0 Safari/537.36"
|
|
||||||
)
|
|
||||||
viewport_width: int = 1920
|
viewport_width: int = 1920
|
||||||
viewport_height: int = 1080
|
viewport_height: int = 1080
|
||||||
navigation_timeout_ms: int = 90_000
|
navigation_timeout_ms: int = 90_000
|
||||||
|
|||||||
@@ -243,9 +243,11 @@ class _LineFilterStream(io.TextIOBase):
|
|||||||
def _suppress_aioslsk_noise() -> Any:
|
def _suppress_aioslsk_noise() -> Any:
|
||||||
"""Temporarily suppress known aioslsk noise printed to stdout/stderr.
|
"""Temporarily suppress known aioslsk noise printed to stdout/stderr.
|
||||||
|
|
||||||
Opt out by setting DOWNLOW_SOULSEEK_VERBOSE=1.
|
Opt out by setting DOWNLOAD_SOULSEEK_VERBOSE=1.
|
||||||
|
|
||||||
|
Note: The old env var name DOWNLOW_SOULSEEK_VERBOSE (typo) is deprecated.
|
||||||
"""
|
"""
|
||||||
if os.environ.get("DOWNLOW_SOULSEEK_VERBOSE"):
|
if os.environ.get("DOWNLOAD_SOULSEEK_VERBOSE") or os.environ.get("DOWNLOW_SOULSEEK_VERBOSE"):
|
||||||
yield
|
yield
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -404,7 +406,7 @@ class Soulseek(Provider):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
# If we're already inside an event loop (e.g., TUI), fall back to a
|
# If we're already inside an event loop, fall back to a
|
||||||
# dedicated loop in this thread.
|
# dedicated loop in this thread.
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
try:
|
try:
|
||||||
@@ -568,6 +570,21 @@ class Soulseek(Provider):
|
|||||||
timeout=9.0,
|
timeout=9.0,
|
||||||
limit=limit)
|
limit=limit)
|
||||||
)
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
flat_results, search_summary = loop.run_until_complete(
|
||||||
|
self.perform_search(query, timeout=9.0, limit=limit)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
try:
|
||||||
if not flat_results:
|
if not flat_results:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -319,7 +319,7 @@ class Telegram(Provider):
|
|||||||
def _run_async_blocking(self, coro):
|
def _run_async_blocking(self, coro):
|
||||||
"""Run an awaitable to completion using a fresh event loop.
|
"""Run an awaitable to completion using a fresh event loop.
|
||||||
|
|
||||||
If an event loop is already running in this thread (common in REPL/TUI),
|
If an event loop is already running in this thread (common in REPL),
|
||||||
runs the coroutine in a worker thread with its own loop.
|
runs the coroutine in a worker thread with its own loop.
|
||||||
"""
|
"""
|
||||||
result: Dict[str,
|
result: Dict[str,
|
||||||
@@ -358,6 +358,13 @@ class Telegram(Provider):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(loop.shutdown_default_executor())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
loop.close()
|
loop.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -559,6 +566,10 @@ class Telegram(Provider):
|
|||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
loop.run_until_complete(_auth_async())
|
loop.run_until_complete(_auth_async())
|
||||||
finally:
|
finally:
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(loop.shutdown_default_executor())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
try:
|
try:
|
||||||
loop.close()
|
loop.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -510,6 +510,14 @@ class ytdlp(TablePluginMixin, Provider):
|
|||||||
SEARCH_QUERY_KEYS = ("search", "q")
|
SEARCH_QUERY_KEYS = ("search", "q")
|
||||||
SUPPORTED_CMDLETS = frozenset({"download-file", "search-file"})
|
SUPPORTED_CMDLETS = frozenset({"download-file", "search-file"})
|
||||||
|
|
||||||
|
QUERY_ARG_CHOICES = {
|
||||||
|
"format": ["audio", "1080", "720", "480", "360", "best", "worst", "bestvideo+bestaudio"],
|
||||||
|
"clip": ["0:00-1:00"],
|
||||||
|
"audio": ["true", "false", "1", "0"],
|
||||||
|
"item": ["1"],
|
||||||
|
}
|
||||||
|
INLINE_QUERY_FIELD_CHOICES = QUERY_ARG_CHOICES
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def config_schema() -> List[Dict[str, Any]]:
|
def config_schema() -> List[Dict[str, Any]]:
|
||||||
return _ytdlp_config_schema()
|
return _ytdlp_config_schema()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ description = "Comprehensive media management and search platform with support f
|
|||||||
requires-python = ">=3.9,<3.14"
|
requires-python = ">=3.9,<3.14"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Your Name", email = "your.email@example.com"}
|
{name = "Nose", email = "goyimnose@nothing.blah"}
|
||||||
]
|
]
|
||||||
keywords = ["media", "search", "management", "hydrus", "download", "cli", "tui"]
|
keywords = ["media", "search", "management", "hydrus", "download", "cli", "tui"]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
@@ -31,6 +31,7 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
# Core CLI and TUI frameworks
|
# Core CLI and TUI frameworks
|
||||||
"typer>=0.9.0",
|
"typer>=0.9.0",
|
||||||
|
"rich>=13.7.0",
|
||||||
"prompt-toolkit>=3.0.0",
|
"prompt-toolkit>=3.0.0",
|
||||||
"textual>=0.30.0",
|
"textual>=0.30.0",
|
||||||
|
|
||||||
@@ -41,12 +42,14 @@ dependencies = [
|
|||||||
"charset-normalizer>=3.2.0",
|
"charset-normalizer>=3.2.0",
|
||||||
"certifi>=2024.12.0",
|
"certifi>=2024.12.0",
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
|
"internetarchive>=4.1.0",
|
||||||
|
|
||||||
# Document and data handling
|
# Document and data handling
|
||||||
"pypdf>=3.0.0",
|
"pypdf>=3.0.0",
|
||||||
"mutagen>=1.46.0",
|
"mutagen>=1.46.0",
|
||||||
"cbor2>=4.0",
|
"cbor2>=4.0",
|
||||||
"zstandard>=0.23.0",
|
"zstandard>=0.23.0",
|
||||||
|
"pypandoc-binary",
|
||||||
|
|
||||||
# Image and media support
|
# Image and media support
|
||||||
"Pillow>=10.0.0",
|
"Pillow>=10.0.0",
|
||||||
@@ -109,10 +112,10 @@ mm = "scripts.cli_entry:main"
|
|||||||
medeia = "scripts.cli_entry:main"
|
medeia = "scripts.cli_entry:main"
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
Homepage = "https://github.com/yourusername/medeia-macina"
|
Homepage = "https://code.glowers.club/goyimnose/Medios-Macina"
|
||||||
Documentation = "https://medeia-macina.readthedocs.io"
|
Documentation = "https://code.glowers.club/goyimnose/Medios-Macina"
|
||||||
Repository = "https://github.com/yourusername/medeia-macina.git"
|
Repository = "https://code.glowers.club/goyimnose/Medios-Macina.git"
|
||||||
Issues = "https://github.com/yourusername/medeia-macina/issues"
|
Issues = "https://code.glowers.club/goyimnose/Medios-Macina/issues"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
packages = [
|
packages = [
|
||||||
@@ -123,7 +126,7 @@ packages = [
|
|||||||
"PluginCore",
|
"PluginCore",
|
||||||
"SYS",
|
"SYS",
|
||||||
]
|
]
|
||||||
package-dir = {"" = ".."}
|
package-dir = {"" = "."}
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
scripts = ["*.py"]
|
scripts = ["*.py"]
|
||||||
@@ -41,9 +41,7 @@ See [plugins/README.md](plugins/README.md) for plugin packaging and discovery de
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The old interactive TUI config editor has been discontinued.
|
Use `.config` from inside the CLI:
|
||||||
|
|
||||||
Use `.config` from inside the CLI instead:
|
|
||||||
|
|
||||||
- `.config` opens the root configuration table.
|
- `.config` opens the root configuration table.
|
||||||
- `@N` drills into a selected section.
|
- `@N` drills into a selected section.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from CLI import MedeiaLexer
|
||||||
|
|
||||||
|
|
||||||
|
class DummyDocument:
|
||||||
|
def __init__(self, lines):
|
||||||
|
self.lines = list(lines)
|
||||||
|
|
||||||
|
lexer = MedeiaLexer()
|
||||||
|
doc = DummyDocument([r'C:\path\to\file'])
|
||||||
|
print(lexer.lex_document(doc)(0))
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
sys.path.insert(0, r'C:\Forgejo\Medios-Macina')
|
||||||
|
from tests.test_cli_parsing import *
|
||||||
|
|
||||||
|
failed = 0
|
||||||
|
tests = [(n, o) for n, o in globals().items() if n.startswith('test_') and callable(o)]
|
||||||
|
for n, o in tests:
|
||||||
|
try:
|
||||||
|
o()
|
||||||
|
print(n, 'OK')
|
||||||
|
except Exception as e:
|
||||||
|
print(n, 'FAIL', e)
|
||||||
|
traceback.print_exc()
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if failed:
|
||||||
|
sys.exit(1)
|
||||||
|
print('All tests passed')
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
importlib.import_module("CLI")
|
||||||
|
print("CLI imported OK")
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
p = Path('Store/registry.py')
|
||||||
|
lines = p.read_text(encoding='utf-8').splitlines()
|
||||||
|
stack = [0]
|
||||||
|
for i, line in enumerate(lines, start=1):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
leading = len(line) - len(line.lstrip(' '))
|
||||||
|
if leading > stack[-1]:
|
||||||
|
# increased indent -> push
|
||||||
|
stack.append(leading)
|
||||||
|
else:
|
||||||
|
# dedent: must match an existing level
|
||||||
|
if leading != stack[-1]:
|
||||||
|
if leading in stack:
|
||||||
|
# pop until match
|
||||||
|
while stack and stack[-1] != leading:
|
||||||
|
stack.pop()
|
||||||
|
else:
|
||||||
|
print(f"Line {i}: inconsistent indent {leading}, stack levels {stack}")
|
||||||
|
print(repr(line))
|
||||||
|
break
|
||||||
|
print('Done')
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
p = Path(r'c:\Forgejo\Medios-Macina\CLI.py')
|
||||||
|
s = p.read_text(encoding='utf-8')
|
||||||
|
pattern = re.compile(r'(?s)if False:\s*class _OldPipelineExecutor:.*?from rich\\.markdown import Markdown\\s*')
|
||||||
|
m = pattern.search(s)
|
||||||
|
print('found', bool(m))
|
||||||
|
if m:
|
||||||
|
print('start', m.start(), 'end', m.end())
|
||||||
|
print('snippet:', s[m.start():m.start()+120])
|
||||||
|
else:
|
||||||
|
# print a slice around the if False for debugging
|
||||||
|
i = s.find('if False:')
|
||||||
|
print('if False index', i)
|
||||||
|
print('around if False:', s[max(0,i-50):i+200])
|
||||||
|
j = s.find('from rich.markdown import Markdown', i)
|
||||||
|
print('next from rich index after if False', j)
|
||||||
|
if j!=-1:
|
||||||
|
print('around that:', s[j-50:j+80])
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PluginCore.backend_registry import BackendRegistry
|
||||||
|
s = BackendRegistry(config={}, suppress_debug=True)
|
||||||
|
print('INSTANCE TYPE:', type(s))
|
||||||
|
print('HAS is_available:', hasattr(s, 'is_available'))
|
||||||
|
if hasattr(s, 'is_available'):
|
||||||
|
print('is_available callable:', callable(getattr(s, 'is_available')))
|
||||||
|
print('DIR:', sorted([n for n in dir(s) if not n.startswith('__')]))
|
||||||
|
except Exception:
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
p=Path('SYS/pipeline.py')
|
||||||
|
s=p.read_text(encoding='utf-8')
|
||||||
|
lines=s.splitlines()
|
||||||
|
stack=[]
|
||||||
|
for i,l in enumerate(lines,1):
|
||||||
|
stripped=l.strip()
|
||||||
|
# Skip commented lines
|
||||||
|
if stripped.startswith('#'):
|
||||||
|
continue
|
||||||
|
# compute indent as leading spaces (tabs are converted)
|
||||||
|
indent = len(l) - len(l.lstrip(' '))
|
||||||
|
if stripped.startswith('try:'):
|
||||||
|
stack.append((indent, i))
|
||||||
|
if stripped.startswith('except ') or stripped=='except:' or stripped.startswith('finally:'):
|
||||||
|
# find the most recent try with same indent
|
||||||
|
for idx in range(len(stack)-1, -1, -1):
|
||||||
|
if stack[idx][0] == indent:
|
||||||
|
stack.pop(idx)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# no matching try at same indent
|
||||||
|
print(f"Found {stripped.split()[0]} at line {i} with no matching try at same indent")
|
||||||
|
|
||||||
|
print('Unmatched try count', len(stack))
|
||||||
|
if stack:
|
||||||
|
print('Unmatched try positions (indent, line):', stack)
|
||||||
|
for indent, lineno in stack:
|
||||||
|
start = max(1, lineno - 10)
|
||||||
|
end = min(len(lines), lineno + 10)
|
||||||
|
print(f"Context around line {lineno}:")
|
||||||
|
for i in range(start, end + 1):
|
||||||
|
print(f"{i:5d}: {lines[i-1]}")
|
||||||
|
else:
|
||||||
|
print("All try statements appear matched")
|
||||||
+9
-85
@@ -8,7 +8,7 @@ running from a development checkout (by importing the top-level
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional, List, Tuple
|
from typing import Optional, List
|
||||||
import importlib
|
import importlib
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import os
|
import os
|
||||||
@@ -64,91 +64,32 @@ def _ensure_repo_root_on_sys_path(pkg_file: Optional[Path] = None) -> Optional[P
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _parse_mode_and_strip_args(args: List[str]) -> Tuple[Optional[str], List[str]]:
|
def _strip_mode_args(args: List[str]) -> List[str]:
|
||||||
"""Parse --gui/--cli/--mode flags and return (mode, cleaned_args).
|
"""Strip --gui/--cli/--mode flags from argument list.
|
||||||
|
|
||||||
The function removes any mode flags from the argument list so the selected
|
The GUI/TUI mode has been discontinued. Any mode flags are silently
|
||||||
runner can receive the remaining arguments untouched.
|
consumed so they don't interfere with remaining argument parsing.
|
||||||
|
|
||||||
Supported forms:
|
|
||||||
--gui, -g, --gui=true
|
|
||||||
--cli, -c, --cli=true
|
|
||||||
--mode=gui|cli
|
|
||||||
--mode gui|cli
|
|
||||||
|
|
||||||
Raises ValueError on conflicting or invalid flags.
|
|
||||||
"""
|
"""
|
||||||
mode: Optional[str] = None
|
|
||||||
out: List[str] = []
|
out: List[str] = []
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(args):
|
while i < len(args):
|
||||||
a = args[i]
|
a = args[i]
|
||||||
la = a.lower()
|
la = a.lower()
|
||||||
|
if la in ("--gui", "-g", "--cli", "-c"):
|
||||||
# --gui / -g
|
|
||||||
if la in ("--gui", "-g"):
|
|
||||||
if mode and mode != "gui":
|
|
||||||
raise ValueError("Conflicting mode flags: found both 'gui' and 'cli'")
|
|
||||||
mode = "gui"
|
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
if la.startswith("--gui="):
|
if la.startswith("--gui=") or la.startswith("--cli="):
|
||||||
val = la.split("=", 1)[1]
|
|
||||||
if val and val not in ("0", "false", "no", "off"):
|
|
||||||
if mode and mode != "gui":
|
|
||||||
raise ValueError(
|
|
||||||
"Conflicting mode flags: found both 'gui' and 'cli'"
|
|
||||||
)
|
|
||||||
mode = "gui"
|
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# --cli / -c
|
|
||||||
if la in ("--cli", "-c"):
|
|
||||||
if mode and mode != "cli":
|
|
||||||
raise ValueError("Conflicting mode flags: found both 'gui' and 'cli'")
|
|
||||||
mode = "cli"
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
if la.startswith("--cli="):
|
|
||||||
val = la.split("=", 1)[1]
|
|
||||||
if val and val not in ("0", "false", "no", "off"):
|
|
||||||
if mode and mode != "cli":
|
|
||||||
raise ValueError(
|
|
||||||
"Conflicting mode flags: found both 'gui' and 'cli'"
|
|
||||||
)
|
|
||||||
mode = "cli"
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# --mode
|
|
||||||
if la.startswith("--mode="):
|
if la.startswith("--mode="):
|
||||||
val = la.split("=", 1)[1]
|
|
||||||
val = val.lower()
|
|
||||||
if val not in ("gui", "cli"):
|
|
||||||
raise ValueError("--mode must be 'gui' or 'cli'")
|
|
||||||
if mode and mode != val:
|
|
||||||
raise ValueError("Conflicting mode flags: found both 'gui' and 'cli'")
|
|
||||||
mode = val
|
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
if la == "--mode":
|
if la == "--mode":
|
||||||
if i + 1 >= len(args):
|
|
||||||
raise ValueError("--mode requires a value ('gui' or 'cli')")
|
|
||||||
val = args[i + 1].lower()
|
|
||||||
if val not in ("gui", "cli"):
|
|
||||||
raise ValueError("--mode must be 'gui' or 'cli'")
|
|
||||||
if mode and mode != val:
|
|
||||||
raise ValueError("Conflicting mode flags: found both 'gui' and 'cli'")
|
|
||||||
mode = val
|
|
||||||
i += 2
|
i += 2
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Not a mode flag; keep it
|
|
||||||
out.append(a)
|
out.append(a)
|
||||||
i += 1
|
i += 1
|
||||||
|
return out
|
||||||
return mode, out
|
|
||||||
|
|
||||||
|
|
||||||
def _run_cli(clean_args: List[str]) -> int:
|
def _run_cli(clean_args: List[str]) -> int:
|
||||||
@@ -221,15 +162,6 @@ def _run_cli(clean_args: List[str]) -> int:
|
|||||||
return int(getattr(exc, "code", 0) or 0)
|
return int(getattr(exc, "code", 0) or 0)
|
||||||
|
|
||||||
|
|
||||||
def _run_gui(clean_args: List[str]) -> int:
|
|
||||||
"""Report that the discontinued GUI/TUI mode is no longer available."""
|
|
||||||
_ = clean_args
|
|
||||||
print(
|
|
||||||
"Error: GUI/TUI mode has been discontinued and is no longer available.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: Optional[List[str]] = None) -> int:
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
"""Entry point for console_scripts.
|
"""Entry point for console_scripts.
|
||||||
@@ -239,11 +171,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
"""
|
"""
|
||||||
args = list(argv) if argv is not None else list(sys.argv[1:])
|
args = list(argv) if argv is not None else list(sys.argv[1:])
|
||||||
|
|
||||||
try:
|
clean_args = _strip_mode_args(args)
|
||||||
mode, clean_args = _parse_mode_and_strip_args(args)
|
|
||||||
except ValueError as exc:
|
|
||||||
print(f"Error parsing mode flags: {exc}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
# Early environment sanity check to detect urllib3/urllib3-future conflicts.
|
# Early environment sanity check to detect urllib3/urllib3-future conflicts.
|
||||||
# When a broken urllib3 is detected we print an actionable message and
|
# When a broken urllib3 is detected we print an actionable message and
|
||||||
@@ -262,10 +190,6 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
# startup; we'll continue and let normal import errors surface.
|
# startup; we'll continue and let normal import errors surface.
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# If GUI requested, delegate directly (GUI may decide to honor any args itself)
|
|
||||||
if mode == "gui":
|
|
||||||
return _run_gui(clean_args)
|
|
||||||
|
|
||||||
# Support quoting a pipeline (or even a single full command) on the command line.
|
# Support quoting a pipeline (or even a single full command) on the command line.
|
||||||
#
|
#
|
||||||
# - If the user provides a single argument that contains a pipe character,
|
# - If the user provides a single argument that contains a pipe character,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import importlib
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
try:
|
||||||
|
m = importlib.import_module('Provider.vimm')
|
||||||
|
print('Imported', m)
|
||||||
|
print('Vimm class:', getattr(m, 'Vimm', None))
|
||||||
|
except Exception as e:
|
||||||
|
print('Import failed:', e)
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
p = Path('Store/registry.py')
|
||||||
|
for i, line in enumerate(p.read_text(encoding='utf-8').splitlines(), start=1):
|
||||||
|
leading = len(line) - len(line.lstrip(' '))
|
||||||
|
if leading > 20:
|
||||||
|
print(i, leading, repr(line))
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Format all tracked Python files using the repo's YAPF style.
|
||||||
|
|
||||||
|
This script intentionally formats only files tracked by git to avoid touching
|
||||||
|
files that are ignored (e.g., venv, site-packages).
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(["git", "ls-files", "*.py"]).decode("utf-8")
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
print("Failed to get tracked files from git:", exc, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
files = [f for f in (line.strip() for line in out.splitlines()) if f]
|
||||||
|
print(f"Formatting {len(files)} tracked python files...")
|
||||||
|
if not files:
|
||||||
|
print("No tracked python files found.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
for path in files:
|
||||||
|
print(path)
|
||||||
|
subprocess.run([sys.executable, "-m", "yapf", "-i", "--style", ".style.yapf", path], check=False)
|
||||||
|
|
||||||
|
print("Done")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import pathlib
|
||||||
|
p = pathlib.Path('Store/registry.py')
|
||||||
|
with p.open('r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
for i in range(312, 328):
|
||||||
|
if i-1 < len(lines):
|
||||||
|
line = lines[i-1]
|
||||||
|
leading = line[:len(line)-len(line.lstrip('\t '))]
|
||||||
|
print(f"{i}: {repr(line.rstrip())} | leading={repr(leading)} len={len(leading)} chars={[ord(c) for c in leading]}")
|
||||||
|
else:
|
||||||
|
print(f"{i}: <EOF>")
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
p = Path('Store/registry.py')
|
||||||
|
counts = {}
|
||||||
|
for i, line in enumerate(p.read_text(encoding='utf-8').splitlines(), start=1):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
leading = len(line) - len(line.lstrip(' '))
|
||||||
|
counts[leading] = counts.get(leading, 0) + 1
|
||||||
|
for k in sorted(counts.keys()):
|
||||||
|
print(k, counts[k])
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from PluginCore.registry import list_plugins
|
||||||
|
|
||||||
|
print('All plugins:', list_plugins())
|
||||||
@@ -14,6 +14,7 @@ charset-normalizer>=3.2.0
|
|||||||
certifi>=2024.12.0
|
certifi>=2024.12.0
|
||||||
# Optional Telegram support installs telethon>=1.36.0 when [provider=telegram] is configured.
|
# Optional Telegram support installs telethon>=1.36.0 when [provider=telegram] is configured.
|
||||||
internetarchive>=4.1.0
|
internetarchive>=4.1.0
|
||||||
|
yt-dlp-ejs
|
||||||
|
|
||||||
# Document and data handling
|
# Document and data handling
|
||||||
pypdf>=3.0.0
|
pypdf>=3.0.0
|
||||||
@@ -44,7 +45,8 @@ tqdm>=4.66.0
|
|||||||
|
|
||||||
# Browser automation (for web scraping if needed)
|
# Browser automation (for web scraping if needed)
|
||||||
playwright>=1.40.0
|
playwright>=1.40.0
|
||||||
|
paramiko>=3.5.0
|
||||||
|
scp>=0.15.0
|
||||||
|
|
||||||
# Development and utilities
|
# Development and utilities
|
||||||
python-dateutil>=2.8.0
|
python-dateutil>=2.8.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user